diff options
| author | Nick Brassel <nick@tzarc.org> | 2025-11-28 00:36:49 +1100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-28 00:36:49 +1100 |
| commit | 9c2ca00074784dbee27b459d71cfc8e75f47b976 (patch) | |
| tree | a59576289fd024bf35b0573db70eb8862ed44568 /lib/python/qmk/cli | |
| parent | 594558ec7b9ac1963870447778426682065e0d20 (diff) | |
QMK CLI Environment bootstrapper (#25038)
Co-authored-by: Joel Challis <git@zvecr.com>
Co-authored-by: Pascal Getreuer <getreuer@google.com>
Diffstat (limited to 'lib/python/qmk/cli')
| -rw-r--r-- | lib/python/qmk/cli/__init__.py | 24 | ||||
| -rw-r--r-- | lib/python/qmk/cli/doctor/check.py | 74 | ||||
| -rwxr-xr-x | lib/python/qmk/cli/doctor/main.py | 69 |
3 files changed, 143 insertions, 24 deletions
diff --git a/lib/python/qmk/cli/__init__.py b/lib/python/qmk/cli/__init__.py index 26905ec134..dc2e4726a5 100644 --- a/lib/python/qmk/cli/__init__.py +++ b/lib/python/qmk/cli/__init__.py | |||
| @@ -3,6 +3,8 @@ | |||
| 3 | We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup. | 3 | We list each subcommand here explicitly because all the reliable ways of searching for modules are slow and delay startup. |
| 4 | """ | 4 | """ |
| 5 | import os | 5 | import os |
| 6 | import platform | ||
| 7 | import platformdirs | ||
| 6 | import shlex | 8 | import shlex |
| 7 | import sys | 9 | import sys |
| 8 | from importlib.util import find_spec | 10 | from importlib.util import find_spec |
| @@ -12,6 +14,28 @@ from subprocess import run | |||
| 12 | from milc import cli, __VERSION__ | 14 | from milc import cli, __VERSION__ |
| 13 | from milc.questions import yesno | 15 | from milc.questions import yesno |
| 14 | 16 | ||
| 17 | |||
| 18 | def _get_default_distrib_path(): | ||
| 19 | if 'windows' in platform.platform().lower(): | ||
| 20 | try: | ||
| 21 | result = cli.run(['cygpath', '-w', '/opt/qmk']) | ||
| 22 | if result.returncode == 0: | ||
| 23 | return result.stdout.strip() | ||
| 24 | except Exception: | ||
| 25 | pass | ||
| 26 | |||
| 27 | return platformdirs.user_data_dir('qmk') | ||
| 28 | |||
| 29 | |||
| 30 | # Ensure the QMK distribution is on the `$PATH` if present. This must be kept in sync with qmk/qmk_cli. | ||
| 31 | QMK_DISTRIB_DIR = Path(os.environ.get('QMK_DISTRIB_DIR', _get_default_distrib_path())) | ||
| 32 | if QMK_DISTRIB_DIR.exists(): | ||
| 33 | os.environ['PATH'] = str(QMK_DISTRIB_DIR / 'bin') + os.pathsep + os.environ['PATH'] | ||
| 34 | |||
| 35 | # Prepend any user-defined path prefix | ||
| 36 | if 'QMK_PATH_PREFIX' in os.environ: | ||
| 37 | os.environ['PATH'] = os.environ['QMK_PATH_PREFIX'] + os.pathsep + os.environ['PATH'] | ||
| 38 | |||
| 15 | import_names = { | 39 | import_names = { |
| 16 | # A mapping of package name to importable name | 40 | # A mapping of package name to importable name |
| 17 | 'pep8-naming': 'pep8ext_naming', | 41 | 'pep8-naming': 'pep8ext_naming', |
diff --git a/lib/python/qmk/cli/doctor/check.py b/lib/python/qmk/cli/doctor/check.py index 51b0f0c80a..8a13cb0832 100644 --- a/lib/python/qmk/cli/doctor/check.py +++ b/lib/python/qmk/cli/doctor/check.py | |||
| @@ -1,7 +1,6 @@ | |||
| 1 | """Check for specific programs. | 1 | """Check for specific programs. |
| 2 | """ | 2 | """ |
| 3 | from enum import Enum | 3 | from enum import Enum |
| 4 | import re | ||
| 5 | import shutil | 4 | import shutil |
| 6 | from subprocess import DEVNULL, TimeoutExpired | 5 | from subprocess import DEVNULL, TimeoutExpired |
| 7 | from tempfile import TemporaryDirectory | 6 | from tempfile import TemporaryDirectory |
| @@ -9,6 +8,7 @@ from pathlib import Path | |||
| 9 | 8 | ||
| 10 | from milc import cli | 9 | from milc import cli |
| 11 | from qmk import submodules | 10 | from qmk import submodules |
| 11 | from qmk.commands import find_make | ||
| 12 | 12 | ||
| 13 | 13 | ||
| 14 | class CheckStatus(Enum): | 14 | class CheckStatus(Enum): |
| @@ -17,7 +17,13 @@ class CheckStatus(Enum): | |||
| 17 | ERROR = 3 | 17 | ERROR = 3 |
| 18 | 18 | ||
| 19 | 19 | ||
| 20 | WHICH_MAKE = Path(find_make()).name | ||
| 21 | |||
| 20 | ESSENTIAL_BINARIES = { | 22 | ESSENTIAL_BINARIES = { |
| 23 | WHICH_MAKE: {}, | ||
| 24 | 'git': {}, | ||
| 25 | 'dos2unix': {}, | ||
| 26 | 'diff': {}, | ||
| 21 | 'dfu-programmer': {}, | 27 | 'dfu-programmer': {}, |
| 22 | 'avrdude': {}, | 28 | 'avrdude': {}, |
| 23 | 'dfu-util': {}, | 29 | 'dfu-util': {}, |
| @@ -30,14 +36,39 @@ ESSENTIAL_BINARIES = { | |||
| 30 | } | 36 | } |
| 31 | 37 | ||
| 32 | 38 | ||
| 33 | def _parse_gcc_version(version): | 39 | def _check_make_version(): |
| 34 | m = re.match(r"(\d+)(?:\.(\d+))?(?:\.(\d+))?", version) | 40 | last_line = ESSENTIAL_BINARIES[WHICH_MAKE]['output'].split('\n')[0] |
| 41 | version_number = last_line.split()[2] | ||
| 42 | cli.log.info('Found %s version %s', WHICH_MAKE, version_number) | ||
| 35 | 43 | ||
| 36 | return { | 44 | return CheckStatus.OK |
| 37 | 'major': int(m.group(1)), | 45 | |
| 38 | 'minor': int(m.group(2)) if m.group(2) else 0, | 46 | |
| 39 | 'patch': int(m.group(3)) if m.group(3) else 0, | 47 | def _check_git_version(): |
| 40 | } | 48 | last_line = ESSENTIAL_BINARIES['git']['output'].split('\n')[0] |
| 49 | version_number = last_line.split()[2] | ||
| 50 | cli.log.info('Found git version %s', version_number) | ||
| 51 | |||
| 52 | return CheckStatus.OK | ||
| 53 | |||
| 54 | |||
| 55 | def _check_dos2unix_version(): | ||
| 56 | last_line = ESSENTIAL_BINARIES['dos2unix']['output'].split('\n')[0] | ||
| 57 | version_number = last_line.split()[1] | ||
| 58 | cli.log.info('Found dos2unix version %s', version_number) | ||
| 59 | |||
| 60 | return CheckStatus.OK | ||
| 61 | |||
| 62 | |||
| 63 | def _check_diff_version(): | ||
| 64 | last_line = ESSENTIAL_BINARIES['diff']['output'].split('\n')[0] | ||
| 65 | if 'Apple diff' in last_line: | ||
| 66 | version_number = last_line | ||
| 67 | else: | ||
| 68 | version_number = last_line.split()[3] | ||
| 69 | cli.log.info('Found diff version %s', version_number) | ||
| 70 | |||
| 71 | return CheckStatus.OK | ||
| 41 | 72 | ||
| 42 | 73 | ||
| 43 | def _check_arm_gcc_version(): | 74 | def _check_arm_gcc_version(): |
| @@ -148,16 +179,24 @@ def check_binaries(): | |||
| 148 | """Iterates through ESSENTIAL_BINARIES and tests them. | 179 | """Iterates through ESSENTIAL_BINARIES and tests them. |
| 149 | """ | 180 | """ |
| 150 | ok = CheckStatus.OK | 181 | ok = CheckStatus.OK |
| 182 | missing_from_path = [] | ||
| 151 | 183 | ||
| 152 | for binary in sorted(ESSENTIAL_BINARIES): | 184 | for binary in sorted(ESSENTIAL_BINARIES): |
| 153 | try: | 185 | try: |
| 154 | if not is_executable(binary): | 186 | if not is_in_path(binary): |
| 187 | ok = CheckStatus.ERROR | ||
| 188 | missing_from_path.append(binary) | ||
| 189 | elif not is_executable(binary): | ||
| 155 | ok = CheckStatus.ERROR | 190 | ok = CheckStatus.ERROR |
| 156 | except TimeoutExpired: | 191 | except TimeoutExpired: |
| 157 | cli.log.debug('Timeout checking %s', binary) | 192 | cli.log.debug('Timeout checking %s', binary) |
| 158 | if ok != CheckStatus.ERROR: | 193 | if ok != CheckStatus.ERROR: |
| 159 | ok = CheckStatus.WARNING | 194 | ok = CheckStatus.WARNING |
| 160 | 195 | ||
| 196 | if missing_from_path: | ||
| 197 | location_noun = 'its location' if len(missing_from_path) == 1 else 'their locations' | ||
| 198 | cli.log.error('{fg_red}' + ', '.join(missing_from_path) + f' may need to be installed, or {location_noun} added to your path.') | ||
| 199 | |||
| 161 | return ok | 200 | return ok |
| 162 | 201 | ||
| 163 | 202 | ||
| @@ -165,6 +204,10 @@ def check_binary_versions(): | |||
| 165 | """Check the versions of ESSENTIAL_BINARIES | 204 | """Check the versions of ESSENTIAL_BINARIES |
| 166 | """ | 205 | """ |
| 167 | checks = { | 206 | checks = { |
| 207 | WHICH_MAKE: _check_make_version, | ||
| 208 | 'git': _check_git_version, | ||
| 209 | 'dos2unix': _check_dos2unix_version, | ||
| 210 | 'diff': _check_diff_version, | ||
| 168 | 'arm-none-eabi-gcc': _check_arm_gcc_version, | 211 | 'arm-none-eabi-gcc': _check_arm_gcc_version, |
| 169 | 'avr-gcc': _check_avr_gcc_version, | 212 | 'avr-gcc': _check_avr_gcc_version, |
| 170 | 'avrdude': _check_avrdude_version, | 213 | 'avrdude': _check_avrdude_version, |
| @@ -196,15 +239,18 @@ def check_submodules(): | |||
| 196 | return CheckStatus.OK | 239 | return CheckStatus.OK |
| 197 | 240 | ||
| 198 | 241 | ||
| 199 | def is_executable(command): | 242 | def is_in_path(command): |
| 200 | """Returns True if command exists and can be executed. | 243 | """Returns True if command is found in the path. |
| 201 | """ | 244 | """ |
| 202 | # Make sure the command is in the path. | 245 | if shutil.which(command) is None: |
| 203 | res = shutil.which(command) | ||
| 204 | if res is None: | ||
| 205 | cli.log.error("{fg_red}Can't find %s in your path.", command) | 246 | cli.log.error("{fg_red}Can't find %s in your path.", command) |
| 206 | return False | 247 | return False |
| 248 | return True | ||
| 249 | |||
| 207 | 250 | ||
| 251 | def is_executable(command): | ||
| 252 | """Returns True if command can be executed. | ||
| 253 | """ | ||
| 208 | # Make sure the command can be executed | 254 | # Make sure the command can be executed |
| 209 | version_arg = ESSENTIAL_BINARIES[command].get('version_arg', '--version') | 255 | version_arg = ESSENTIAL_BINARIES[command].get('version_arg', '--version') |
| 210 | check = cli.run([command, version_arg], combined_output=True, stdin=DEVNULL, timeout=5) | 256 | check = cli.run([command, version_arg], combined_output=True, stdin=DEVNULL, timeout=5) |
diff --git a/lib/python/qmk/cli/doctor/main.py b/lib/python/qmk/cli/doctor/main.py index 391353ebbf..45667e8ce2 100755 --- a/lib/python/qmk/cli/doctor/main.py +++ b/lib/python/qmk/cli/doctor/main.py | |||
| @@ -3,7 +3,6 @@ | |||
| 3 | Check out the user's QMK environment and make sure it's ready to compile. | 3 | Check out the user's QMK environment and make sure it's ready to compile. |
| 4 | """ | 4 | """ |
| 5 | import platform | 5 | import platform |
| 6 | from subprocess import DEVNULL | ||
| 7 | 6 | ||
| 8 | from milc import cli | 7 | from milc import cli |
| 9 | from milc.questions import yesno | 8 | from milc.questions import yesno |
| @@ -16,6 +15,60 @@ from qmk.commands import in_virtualenv | |||
| 16 | from qmk.userspace import qmk_userspace_paths, qmk_userspace_validate, UserspaceValidationError | 15 | from qmk.userspace import qmk_userspace_paths, qmk_userspace_validate, UserspaceValidationError |
| 17 | 16 | ||
| 18 | 17 | ||
| 18 | def distrib_tests(): | ||
| 19 | def _load_kvp_file(file): | ||
| 20 | """Load a simple key=value file into a dictionary | ||
| 21 | """ | ||
| 22 | vars = {} | ||
| 23 | with open(file, 'r') as f: | ||
| 24 | for line in f: | ||
| 25 | if '=' in line: | ||
| 26 | key, value = line.split('=', 1) | ||
| 27 | vars[key.strip()] = value.strip() | ||
| 28 | return vars | ||
| 29 | |||
| 30 | def _parse_toolchain_release_file(file): | ||
| 31 | """Parse the QMK toolchain release info file | ||
| 32 | """ | ||
| 33 | try: | ||
| 34 | vars = _load_kvp_file(file) | ||
| 35 | return f'{vars.get("TOOLCHAIN_HOST", "unknown")}:{vars.get("TOOLCHAIN_TARGET", "unknown")}:{vars.get("COMMIT_HASH", "unknown")}' | ||
| 36 | except Exception as e: | ||
| 37 | cli.log.warning('Error reading QMK toolchain release info file: %s', e) | ||
| 38 | return f'Unknown toolchain release info file: {file}' | ||
| 39 | |||
| 40 | def _parse_flashutils_release_file(file): | ||
| 41 | """Parse the QMK flashutils release info file | ||
| 42 | """ | ||
| 43 | try: | ||
| 44 | vars = _load_kvp_file(file) | ||
| 45 | return f'{vars.get("FLASHUTILS_HOST", "unknown")}:{vars.get("COMMIT_HASH", "unknown")}' | ||
| 46 | except Exception as e: | ||
| 47 | cli.log.warning('Error reading QMK flashutils release info file: %s', e) | ||
| 48 | return f'Unknown flashutils release info file: {file}' | ||
| 49 | |||
| 50 | try: | ||
| 51 | from qmk.cli import QMK_DISTRIB_DIR | ||
| 52 | if (QMK_DISTRIB_DIR / 'etc').exists(): | ||
| 53 | cli.log.info('Found QMK tools distribution directory: {fg_cyan}%s', QMK_DISTRIB_DIR) | ||
| 54 | |||
| 55 | toolchains = [_parse_toolchain_release_file(file) for file in (QMK_DISTRIB_DIR / 'etc').glob('toolchain_release_*')] | ||
| 56 | if len(toolchains) > 0: | ||
| 57 | cli.log.info('Found QMK toolchains: {fg_cyan}%s', ', '.join(toolchains)) | ||
| 58 | else: | ||
| 59 | cli.log.warning('No QMK toolchains manifest found.') | ||
| 60 | |||
| 61 | flashutils = [_parse_flashutils_release_file(file) for file in (QMK_DISTRIB_DIR / 'etc').glob('flashutils_release_*')] | ||
| 62 | if len(flashutils) > 0: | ||
| 63 | cli.log.info('Found QMK flashutils: {fg_cyan}%s', ', '.join(flashutils)) | ||
| 64 | else: | ||
| 65 | cli.log.warning('No QMK flashutils manifest found.') | ||
| 66 | except ImportError: | ||
| 67 | cli.log.info('QMK tools distribution not found.') | ||
| 68 | |||
| 69 | return CheckStatus.OK | ||
| 70 | |||
| 71 | |||
| 19 | def os_tests(): | 72 | def os_tests(): |
| 20 | """Determine our OS and run platform specific tests | 73 | """Determine our OS and run platform specific tests |
| 21 | """ | 74 | """ |
| @@ -124,10 +177,12 @@ def doctor(cli): | |||
| 124 | * [ ] Compile a trivial program with each compiler | 177 | * [ ] Compile a trivial program with each compiler |
| 125 | """ | 178 | """ |
| 126 | cli.log.info('QMK Doctor is checking your environment.') | 179 | cli.log.info('QMK Doctor is checking your environment.') |
| 180 | cli.log.info('Python version: %s', platform.python_version()) | ||
| 127 | cli.log.info('CLI version: %s', cli.version) | 181 | cli.log.info('CLI version: %s', cli.version) |
| 128 | cli.log.info('QMK home: {fg_cyan}%s', QMK_FIRMWARE) | 182 | cli.log.info('QMK home: {fg_cyan}%s', QMK_FIRMWARE) |
| 129 | 183 | ||
| 130 | status = os_status = os_tests() | 184 | status = os_status = os_tests() |
| 185 | distrib_tests() | ||
| 131 | 186 | ||
| 132 | userspace_tests(None) | 187 | userspace_tests(None) |
| 133 | 188 | ||
| @@ -141,12 +196,6 @@ def doctor(cli): | |||
| 141 | 196 | ||
| 142 | # Make sure the basic CLI tools we need are available and can be executed. | 197 | # Make sure the basic CLI tools we need are available and can be executed. |
| 143 | bin_ok = check_binaries() | 198 | bin_ok = check_binaries() |
| 144 | |||
| 145 | if bin_ok == CheckStatus.ERROR: | ||
| 146 | if yesno('Would you like to install dependencies?', default=True): | ||
| 147 | cli.run(['util/qmk_install.sh', '-y'], stdin=DEVNULL, capture_output=False) | ||
| 148 | bin_ok = check_binaries() | ||
| 149 | |||
| 150 | if bin_ok == CheckStatus.OK: | 199 | if bin_ok == CheckStatus.OK: |
| 151 | cli.log.info('All dependencies are installed.') | 200 | cli.log.info('All dependencies are installed.') |
| 152 | elif bin_ok == CheckStatus.WARNING: | 201 | elif bin_ok == CheckStatus.WARNING: |
| @@ -163,7 +212,6 @@ def doctor(cli): | |||
| 163 | 212 | ||
| 164 | # Check out the QMK submodules | 213 | # Check out the QMK submodules |
| 165 | sub_ok = check_submodules() | 214 | sub_ok = check_submodules() |
| 166 | |||
| 167 | if sub_ok == CheckStatus.OK: | 215 | if sub_ok == CheckStatus.OK: |
| 168 | cli.log.info('Submodules are up to date.') | 216 | cli.log.info('Submodules are up to date.') |
| 169 | else: | 217 | else: |
| @@ -186,6 +234,7 @@ def doctor(cli): | |||
| 186 | cli.log.info('{fg_yellow}QMK is ready to go, but minor problems were found') | 234 | cli.log.info('{fg_yellow}QMK is ready to go, but minor problems were found') |
| 187 | return 1 | 235 | return 1 |
| 188 | else: | 236 | else: |
| 189 | cli.log.info('{fg_red}Major problems detected, please fix these problems before proceeding.') | 237 | cli.log.info('{fg_red}Major problems detected, please fix these problems before proceeding.{fg_reset}') |
| 190 | cli.log.info('{fg_blue}Check out the FAQ (https://docs.qmk.fm/#/faq_build) or join the QMK Discord (https://discord.gg/qmk) for help.') | 238 | cli.log.info('{fg_blue}If you\'re missing dependencies, try following the instructions on: https://docs.qmk.fm/newbs_getting_started{fg_reset}') |
| 239 | cli.log.info('{fg_blue}Additionally, check out the FAQ (https://docs.qmk.fm/#/faq_build) or join the QMK Discord (https://discord.gg/qmk) for help.{fg_reset}') | ||
| 191 | return 2 | 240 | return 2 |
