aboutsummaryrefslogtreecommitdiff
path: root/lib/python
diff options
context:
space:
mode:
Diffstat (limited to 'lib/python')
-rw-r--r--lib/python/milc.py28
-rw-r--r--lib/python/qmk/cli/cformat.py6
-rwxr-xr-xlib/python/qmk/cli/doctor.py241
-rw-r--r--lib/python/qmk/questions.py178
-rw-r--r--lib/python/qmk/submodules.py71
5 files changed, 452 insertions, 72 deletions
diff --git a/lib/python/milc.py b/lib/python/milc.py
index 4392c8376..36072ca76 100644
--- a/lib/python/milc.py
+++ b/lib/python/milc.py
@@ -178,8 +178,9 @@ class ConfigurationSection(Configuration):
178 178
179 def __getitem__(self, key): 179 def __getitem__(self, key):
180 """Returns a config value, pulling from the `user` section as a fallback. 180 """Returns a config value, pulling from the `user` section as a fallback.
181 This is called when the attribute is accessed either via the get method or through [ ] index.
181 """ 182 """
182 if key in self._config: 183 if key in self._config and self._config.get(key) is not None:
183 return self._config[key] 184 return self._config[key]
184 185
185 elif key in self.parent.user: 186 elif key in self.parent.user:
@@ -187,6 +188,15 @@ class ConfigurationSection(Configuration):
187 188
188 return None 189 return None
189 190
191 def __getattr__(self, key):
192 """Returns the config value from the `user` section.
193 This is called when the attribute is accessed via dot notation but does not exists.
194 """
195 if key in self.parent.user:
196 return self.parent.user[key]
197
198 return None
199
190 200
191def handle_store_boolean(self, *args, **kwargs): 201def handle_store_boolean(self, *args, **kwargs):
192 """Does the add_argument for action='store_boolean'. 202 """Does the add_argument for action='store_boolean'.
@@ -501,7 +511,10 @@ class MILC(object):
501 511
502 if argument not in self.arg_only: 512 if argument not in self.arg_only:
503 # Find the argument's section 513 # Find the argument's section
504 if self._entrypoint.__name__ in self.default_arguments and argument in self.default_arguments[self._entrypoint.__name__]: 514 # Underscores in command's names are converted to dashes during initialization.
515 # TODO(Erovia) Find a better solution
516 entrypoint_name = self._entrypoint.__name__.replace("_", "-")
517 if entrypoint_name in self.default_arguments and argument in self.default_arguments[entrypoint_name]:
505 argument_found = True 518 argument_found = True
506 section = self._entrypoint.__name__ 519 section = self._entrypoint.__name__
507 if argument in self.default_arguments['general']: 520 if argument in self.default_arguments['general']:
@@ -513,13 +526,16 @@ class MILC(object):
513 exit(1) 526 exit(1)
514 527
515 # Merge this argument into self.config 528 # Merge this argument into self.config
516 if argument in self.default_arguments: 529 if argument in self.default_arguments['general'] or argument in self.default_arguments[entrypoint_name]:
517 arg_value = getattr(self.args, argument) 530 arg_value = getattr(self.args, argument)
518 if arg_value: 531 if arg_value is not None:
519 self.config[section][argument] = arg_value 532 self.config[section][argument] = arg_value
520 else: 533 else:
521 if argument not in self.config[section]: 534 if argument not in self.config[entrypoint_name]:
522 self.config[section][argument] = getattr(self.args, argument) 535 # Check if the argument exist for this section
536 arg = getattr(self.args, argument)
537 if arg is not None:
538 self.config[section][argument] = arg
523 539
524 self.release_lock() 540 self.release_lock()
525 541
diff --git a/lib/python/qmk/cli/cformat.py b/lib/python/qmk/cli/cformat.py
index 17ca91b3b..de55218ae 100644
--- a/lib/python/qmk/cli/cformat.py
+++ b/lib/python/qmk/cli/cformat.py
@@ -24,13 +24,15 @@ def cformat(cli):
24 if cli.args.files: 24 if cli.args.files:
25 cli.args.files = [os.path.join(os.environ['ORIG_CWD'], file) for file in cli.args.files] 25 cli.args.files = [os.path.join(os.environ['ORIG_CWD'], file) for file in cli.args.files]
26 else: 26 else:
27 ignores = ['tmk_core/protocol/usb_hid', 'quantum/template']
27 for dir in ['drivers', 'quantum', 'tests', 'tmk_core']: 28 for dir in ['drivers', 'quantum', 'tests', 'tmk_core']:
28 for dirpath, dirnames, filenames in os.walk(dir): 29 for dirpath, dirnames, filenames in os.walk(dir):
29 if 'tmk_core/protocol/usb_hid' in dirpath: 30 if any(i in dirpath for i in ignores):
31 dirnames.clear()
30 continue 32 continue
31 33
32 for name in filenames: 34 for name in filenames:
33 if name.endswith('.c') or name.endswith('.h') or name.endswith('.cpp'): 35 if name.endswith(('.c', '.h', '.cpp')):
34 cli.args.files.append(os.path.join(dirpath, name)) 36 cli.args.files.append(os.path.join(dirpath, name))
35 37
36 # Run clang-format on the files we've found 38 # Run clang-format on the files we've found
diff --git a/lib/python/qmk/cli/doctor.py b/lib/python/qmk/cli/doctor.py
index 6ddc5571b..0d6c1c5b0 100755
--- a/lib/python/qmk/cli/doctor.py
+++ b/lib/python/qmk/cli/doctor.py
@@ -1,14 +1,18 @@
1"""QMK Python Doctor 1"""QMK Doctor
2 2
3Check up for QMK environment. 3Check out the user's QMK environment and make sure it's ready to compile.
4""" 4"""
5import os
6import platform 5import platform
7import shutil 6import shutil
8import subprocess 7import subprocess
9import glob 8from pathlib import Path
10 9
11from milc import cli 10from milc import cli
11from qmk import submodules
12from qmk.questions import yesno
13
14ESSENTIAL_BINARIES = ['dfu-programmer', 'avrdude', 'dfu-util', 'avr-gcc', 'arm-none-eabi-gcc', 'bin/qmk']
15ESSENTIAL_SUBMODULES = ['lib/chibios', 'lib/lufa']
12 16
13 17
14def _udev_rule(vid, pid=None): 18def _udev_rule(vid, pid=None):
@@ -20,6 +24,137 @@ def _udev_rule(vid, pid=None):
20 return 'SUBSYSTEMS=="usb", ATTRS{idVendor}=="%s", MODE:="0666"' % vid 24 return 'SUBSYSTEMS=="usb", ATTRS{idVendor}=="%s", MODE:="0666"' % vid
21 25
22 26
27def check_binaries():
28 """Iterates through ESSENTIAL_BINARIES and tests them.
29 """
30 ok = True
31
32 for binary in ESSENTIAL_BINARIES:
33 if not is_executable(binary):
34 ok = False
35
36 return ok
37
38
39def check_submodules():
40 """Iterates through all submodules to make sure they're cloned and up to date.
41 """
42 ok = True
43
44 for submodule in submodules.status().values():
45 if submodule['status'] is None:
46 if submodule['name'] in ESSENTIAL_SUBMODULES:
47 cli.log.error('Submodule %s has not yet been cloned!', submodule['name'])
48 ok = False
49 else:
50 cli.log.warn('Submodule %s is not available.', submodule['name'])
51 elif not submodule['status']:
52 if submodule['name'] in ESSENTIAL_SUBMODULES:
53 cli.log.warn('Submodule %s is not up to date!')
54
55 return ok
56
57
58def check_udev_rules():
59 """Make sure the udev rules look good.
60 """
61 ok = True
62 udev_dir = Path("/etc/udev/rules.d/")
63 desired_rules = {
64 'dfu': {_udev_rule("03eb", "2ff4"), _udev_rule("03eb", "2ffb"), _udev_rule("03eb", "2ff0")},
65 'tmk': {_udev_rule("feed")},
66 'input_club': {_udev_rule("1c11")},
67 'stm32': {_udev_rule("1eaf", "0003"), _udev_rule("0483", "df11")},
68 'caterina': {'ATTRS{idVendor}=="2a03", ENV{ID_MM_DEVICE_IGNORE}="1"', 'ATTRS{idVendor}=="2341", ENV{ID_MM_DEVICE_IGNORE}="1"'},
69 }
70
71 if udev_dir.exists():
72 udev_rules = [str(rule_file) for rule_file in udev_dir.glob('*.rules')]
73 current_rules = set()
74
75 # Collect all rules from the config files
76 for rule_file in udev_rules:
77 with open(rule_file, "r") as fd:
78 for line in fd.readlines():
79 line = line.strip()
80 if not line.startswith("#") and len(line):
81 current_rules.add(line)
82
83 # Check if the desired rules are among the currently present rules
84 for bootloader, rules in desired_rules.items():
85 if not rules.issubset(current_rules):
86 # If the rules for catalina are not present, check if ModemManager is running
87 if bootloader == "caterina":
88 if check_modem_manager():
89 ok = False
90 cli.log.warn("{bg_yellow}Detected ModemManager without udev rules. Please either disable it or set the appropriate udev rules if you are using a Pro Micro.")
91 else:
92 cli.log.warn("{bg_yellow}Missing udev rules for '%s' boards. You'll need to use `sudo` in order to flash them.", bootloader)
93
94 return ok
95
96
97def check_modem_manager():
98 """Returns True if ModemManager is running.
99 """
100 if shutil.which("systemctl"):
101 mm_check = subprocess.run(["systemctl", "--quiet", "is-active", "ModemManager.service"], timeout=10)
102 if mm_check.returncode == 0:
103 return True
104
105 else:
106 cli.log.warn("Can't find systemctl to check for ModemManager.")
107
108
109def is_executable(command):
110 """Returns True if command exists and can be executed.
111 """
112 # Make sure the command is in the path.
113 res = shutil.which(command)
114 if res is None:
115 cli.log.error("{fg_red}Can't find %s in your path.", command)
116 return False
117
118 # Make sure the command can be executed
119 check = subprocess.run([command, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
120 if check.returncode in [0, 1]: # Older versions of dfu-programmer exit 1
121 cli.log.debug('Found {fg_cyan}%s', command)
122 return True
123
124 cli.log.error("{fg_red}Can't run `%s --version`", command)
125 return False
126
127
128def os_test_linux():
129 """Run the Linux specific tests.
130 """
131 cli.log.info("Detected {fg_cyan}Linux.")
132 ok = True
133
134 if not check_udev_rules():
135 ok = False
136
137 return ok
138
139
140def os_test_macos():
141 """Run the Mac specific tests.
142 """
143 cli.log.info("Detected {fg_cyan}macOS.")
144
145 return True
146
147
148def os_test_windows():
149 """Run the Windows specific tests.
150 """
151 cli.log.info("Detected {fg_cyan}Windows.")
152
153 return True
154
155
156@cli.argument('-y', '--yes', action='store_true', arg_only=True, help='Answer yes to all questions.')
157@cli.argument('-n', '--no', action='store_true', arg_only=True, help='Answer no to all questions.')
23@cli.subcommand('Basic QMK environment checks') 158@cli.subcommand('Basic QMK environment checks')
24def doctor(cli): 159def doctor(cli):
25 """Basic QMK environment checks. 160 """Basic QMK environment checks.
@@ -30,75 +165,53 @@ def doctor(cli):
30 * [ ] Compile a trivial program with each compiler 165 * [ ] Compile a trivial program with each compiler
31 """ 166 """
32 cli.log.info('QMK Doctor is checking your environment.') 167 cli.log.info('QMK Doctor is checking your environment.')
33
34 # Make sure the basic CLI tools we need are available and can be executed.
35 binaries = ['dfu-programmer', 'avrdude', 'dfu-util', 'avr-gcc', 'arm-none-eabi-gcc', 'bin/qmk']
36 ok = True 168 ok = True
37 169
38 for binary in binaries: 170 # Determine our OS and run platform specific tests
39 res = shutil.which(binary) 171 OS = platform.platform().lower() # noqa (N806), uppercase name is ok in this instance
40 if res is None: 172
41 cli.log.error("{fg_red}QMK can't find %s in your path.", binary) 173 if 'darwin' in OS:
174 if not os_test_macos():
42 ok = False 175 ok = False
43 else: 176 elif 'linux' in OS:
44 check = subprocess.run([binary, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5) 177 if not os_test_linux():
45 if check.returncode in [0, 1]: 178 ok = False
46 cli.log.info('Found {fg_cyan}%s', binary) 179 elif 'windows' in OS:
47 else: 180 if not os_test_windows():
48 cli.log.error("{fg_red}Can't run `%s --version`", binary) 181 ok = False
49 ok = False 182 else:
183 cli.log.error('Unsupported OS detected: %s', OS)
184 ok = False
50 185
51 # Determine our OS and run platform specific tests 186 # Make sure the basic CLI tools we need are available and can be executed.
52 OS = platform.system() # noqa (N806), uppercase name is ok in this instance 187 bin_ok = check_binaries()
53 188
54 if OS == "Darwin": 189 if not bin_ok:
55 cli.log.info("Detected {fg_cyan}macOS.") 190 if yesno('Would you like to install dependencies?', default=True):
56 191 subprocess.run(['util/qmk_install.sh'])
57 elif OS == "Linux": 192 bin_ok = check_binaries()
58 cli.log.info("Detected {fg_cyan}Linux.")
59 # Checking for udev rules
60 udev_dir = "/etc/udev/rules.d/"
61 # These are the recommended udev rules
62 desired_rules = {
63 'dfu': {_udev_rule("03eb", "2ff4"), _udev_rule("03eb", "2ffb"), _udev_rule("03eb", "2ff0")},
64 'tmk': {_udev_rule("feed")},
65 'input_club': {_udev_rule("1c11")},
66 'stm32': {_udev_rule("1eaf", "0003"), _udev_rule("0483", "df11")},
67 'caterina': {'ATTRS{idVendor}=="2a03", ENV{ID_MM_DEVICE_IGNORE}="1"', 'ATTRS{idVendor}=="2341", ENV{ID_MM_DEVICE_IGNORE}="1"'},
68 }
69
70 if os.path.exists(udev_dir):
71 udev_rules = [rule for rule in glob.iglob(os.path.join(udev_dir, "*.rules")) if os.path.isfile(rule)]
72 # Collect all rules from the config files
73 current_rules = set()
74 for rule in udev_rules:
75 with open(rule, "r") as fd:
76 for line in fd.readlines():
77 line = line.strip()
78 if not line.startswith("#") and len(line):
79 current_rules.add(line)
80
81 # Check if the desired rules are among the currently present rules
82 for bootloader, rules in desired_rules.items():
83 if not rules.issubset(current_rules):
84 # If the rules for catalina are not present, check if ModemManager is running
85 if bootloader == "caterina":
86 if shutil.which("systemctl"):
87 mm_check = subprocess.run(["systemctl", "--quiet", "is-active", "ModemManager.service"], timeout=10)
88 if mm_check.returncode == 0:
89 ok = False
90 cli.log.warn("{bg_yellow}Detected ModemManager without udev rules. Please either disable it or set the appropriate udev rules if you are using a Pro Micro.")
91 else:
92 cli.log.warn("Can't find systemctl to check for ModemManager.")
93 else:
94 cli.log.warn("{bg_yellow}Missing udev rules for '%s' boards. You'll need to use `sudo` in order to flash them.", bootloader)
95 193
194 if bin_ok:
195 cli.log.info('All dependencies are installed.')
96 else: 196 else:
97 cli.log.info("Assuming {fg_cyan}Windows.") 197 ok = False
198
199 # Check out the QMK submodules
200 sub_ok = check_submodules()
201
202 if sub_ok:
203 cli.log.info('Submodules are up to date.')
204 else:
205 if yesno('Would you like to clone the submodules?', default=True):
206 submodules.update()
207 sub_ok = check_submodules()
208
209 if not sub_ok:
210 ok = False
98 211
99 # Report a summary of our findings to the user 212 # Report a summary of our findings to the user
100 if ok: 213 if ok:
101 cli.log.info('{fg_green}QMK is ready to go') 214 cli.log.info('{fg_green}QMK is ready to go')
102 else: 215 else:
103 cli.log.info('{fg_yellow}Problems detected, please fix these problems before proceeding.') 216 cli.log.info('{fg_yellow}Problems detected, please fix these problems before proceeding.')
104 # FIXME(skullydazed): Link to a document about troubleshooting, or discord or something 217 # FIXME(skullydazed/unclaimed): Link to a document about troubleshooting, or discord or something
diff --git a/lib/python/qmk/questions.py b/lib/python/qmk/questions.py
new file mode 100644
index 000000000..27f43ac1e
--- /dev/null
+++ b/lib/python/qmk/questions.py
@@ -0,0 +1,178 @@
1"""Functions to collect user input.
2"""
3
4from milc import cli, format_ansi
5
6
7def yesno(prompt, *args, default=None, **kwargs):
8 """Displays prompt to the user and gets a yes or no response.
9
10 Returns True for a yes and False for a no.
11
12 If you add `--yes` and `--no` arguments to your program the user can answer questions by passing command line flags.
13
14 @add_argument('-y', '--yes', action='store_true', arg_only=True, help='Answer yes to all questions.')
15 @add_argument('-n', '--no', action='store_true', arg_only=True, help='Answer no to all questions.')
16
17 Arguments:
18 prompt
19 The prompt to present to the user. Can include ANSI and format strings like milc's `cli.echo()`.
20
21 default
22 Whether to default to a Yes or No when the user presses enter.
23
24 None- force the user to enter Y or N
25
26 True- Default to yes
27
28 False- Default to no
29 """
30 if not args and kwargs:
31 args = kwargs
32
33 if 'no' in cli.args and cli.args.no:
34 return False
35
36 if 'yes' in cli.args and cli.args.yes:
37 return True
38
39 if default is not None:
40 if default:
41 prompt = prompt + ' [Y/n] '
42 else:
43 prompt = prompt + ' [y/N] '
44
45 while True:
46 cli.echo('')
47 answer = input(format_ansi(prompt % args))
48 cli.echo('')
49
50 if not answer and prompt is not None:
51 return default
52
53 elif answer.lower() in ['y', 'yes']:
54 return True
55
56 elif answer.lower() in ['n', 'no']:
57 return False
58
59
60def question(prompt, *args, default=None, confirm=False, answer_type=str, validate=None, **kwargs):
61 """Prompt the user to answer a question with a free-form input.
62
63 Arguments:
64 prompt
65 The prompt to present to the user. Can include ANSI and format strings like milc's `cli.echo()`.
66
67 default
68 The value to return when the user doesn't enter any value. Use None to prompt until they enter a value.
69
70 confirm
71 Present the user with a confirmation dialog before accepting their answer.
72
73 answer_type
74 Specify a type function for the answer. Will re-prompt the user if the function raises any errors. Common choices here include int, float, and decimal.Decimal.
75
76 validate
77 This is an optional function that can be used to validate the answer. It should return True or False and have the following signature:
78
79 def function_name(answer, *args, **kwargs):
80 """
81 if not args and kwargs:
82 args = kwargs
83
84 if default is not None:
85 prompt = '%s [%s] ' % (prompt, default)
86
87 while True:
88 cli.echo('')
89 answer = input(format_ansi(prompt % args))
90 cli.echo('')
91
92 if answer:
93 if validate is not None and not validate(answer, *args, **kwargs):
94 continue
95
96 elif confirm:
97 if yesno('Is the answer "%s" correct?', answer, default=True):
98 try:
99 return answer_type(answer)
100 except Exception as e:
101 cli.log.error('Could not convert answer (%s) to type %s: %s', answer, answer_type.__name__, str(e))
102
103 else:
104 try:
105 return answer_type(answer)
106 except Exception as e:
107 cli.log.error('Could not convert answer (%s) to type %s: %s', answer, answer_type.__name__, str(e))
108
109 elif default is not None:
110 return default
111
112
113def choice(heading, options, *args, default=None, confirm=False, prompt='Please enter your choice: ', **kwargs):
114 """Present the user with a list of options and let them pick one.
115
116 Users can enter either the number or the text of their choice.
117
118 This will return the value of the item they choose, not the numerical index.
119
120 Arguments:
121 heading
122 The text to place above the list of options.
123
124 options
125 A sequence of items to choose from.
126
127 default
128 The index of the item to return when the user doesn't enter any value. Use None to prompt until they enter a value.
129
130 confirm
131 Present the user with a confirmation dialog before accepting their answer.
132
133 prompt
134 The prompt to present to the user. Can include ANSI and format strings like milc's `cli.echo()`.
135 """
136 if not args and kwargs:
137 args = kwargs
138
139 if prompt and default:
140 prompt = prompt + ' [%s] ' % (default + 1,)
141
142 while True:
143 # Prompt for an answer.
144 cli.echo('')
145 cli.echo(heading % args)
146 cli.echo('')
147 for i, option in enumerate(options, 1):
148 cli.echo('\t{fg_cyan}%d.{fg_reset} %s', i, option)
149
150 cli.echo('')
151 answer = input(format_ansi(prompt))
152 cli.echo('')
153
154 # If the user types in one of the options exactly use that
155 if answer in options:
156 return answer
157
158 # Massage the answer into a valid integer
159 if answer == '' and default:
160 answer = default
161 else:
162 try:
163 answer = int(answer) - 1
164 except Exception:
165 # Normally we would log the exception here, but in the interest of clean UI we do not.
166 cli.log.error('Invalid choice: %s', answer + 1)
167 continue
168
169 # Validate the answer
170 if answer >= len(options) or answer < 0:
171 cli.log.error('Invalid choice: %s', answer + 1)
172 continue
173
174 if confirm and not yesno('Is the answer "%s" correct?', answer + 1, default=True):
175 continue
176
177 # Return the answer they chose.
178 return options[answer]
diff --git a/lib/python/qmk/submodules.py b/lib/python/qmk/submodules.py
new file mode 100644
index 000000000..be51a6804
--- /dev/null
+++ b/lib/python/qmk/submodules.py
@@ -0,0 +1,71 @@
1"""Functions for working with QMK's submodules.
2"""
3
4import subprocess
5
6
7def status():
8 """Returns a dictionary of submodules.
9
10 Each entry is a dict of the form:
11
12 {
13 'name': 'submodule_name',
14 'status': None/False/True,
15 'githash': '<sha-1 hash for the submodule>
16 }
17
18 status is None when the submodule doesn't exist, False when it's out of date, and True when it's current
19 """
20 submodules = {}
21 git_cmd = subprocess.run(['git', 'submodule', 'status'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30, universal_newlines=True)
22
23 for line in git_cmd.stdout.split('\n'):
24 if not line:
25 continue
26
27 status = line[0]
28 githash, submodule = line[1:].split()[:2]
29 submodules[submodule] = {'name': submodule, 'githash': githash}
30
31 if status == '-':
32 submodules[submodule]['status'] = None
33 elif status == '+':
34 submodules[submodule]['status'] = False
35 elif status == ' ':
36 submodules[submodule]['status'] = True
37 else:
38 raise ValueError('Unknown `git submodule status` sha-1 prefix character: "%s"' % status)
39
40 return submodules
41
42
43def update(submodules=None):
44 """Update the submodules.
45
46 submodules
47 A string containing a single submodule or a list of submodules.
48 """
49 git_sync_cmd = ['git', 'submodule', 'sync']
50 git_update_cmd = ['git', 'submodule', 'update', '--init']
51
52 if submodules is None:
53 # Update everything
54 git_sync_cmd.append('--recursive')
55 git_update_cmd.append('--recursive')
56 subprocess.run(git_sync_cmd, check=True)
57 subprocess.run(git_update_cmd, check=True)
58
59 else:
60 if isinstance(submodules, str):
61 # Update only a single submodule
62 git_sync_cmd.append(submodules)
63 git_update_cmd.append(submodules)
64 subprocess.run(git_sync_cmd, check=True)
65 subprocess.run(git_update_cmd, check=True)
66
67 else:
68 # Update submodules in a list
69 for submodule in submodules:
70 subprocess.run(git_sync_cmd + [submodule], check=True)
71 subprocess.run(git_update_cmd + [submodule], check=True)