diff options
| author | Joel Challis <git@zvecr.com> | 2022-02-08 19:03:30 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-02-09 06:03:30 +1100 |
| commit | 2e279f1b889a59156f30524cc83358f64ee53287 (patch) | |
| tree | be7b7fc6718f2401bbca1820d043e24ddf194c77 /lib/python/qmk/cli/new | |
| parent | a239051c4a4779767059140892144dedea09aaf2 (diff) | |
Initial pass at data driven new-keyboard subcommand (#12795)
* Initial pass at a data driven keyboard subcommand
* format
* lint
* Handle bootloader now its mandatory
Diffstat (limited to 'lib/python/qmk/cli/new')
| -rw-r--r-- | lib/python/qmk/cli/new/keyboard.py | 293 |
1 files changed, 205 insertions, 88 deletions
diff --git a/lib/python/qmk/cli/new/keyboard.py b/lib/python/qmk/cli/new/keyboard.py index 4093b8c90d..59e781a932 100644 --- a/lib/python/qmk/cli/new/keyboard.py +++ b/lib/python/qmk/cli/new/keyboard.py | |||
| @@ -1,15 +1,82 @@ | |||
| 1 | """This script automates the creation of new keyboard directories using a starter template. | 1 | """This script automates the creation of new keyboard directories using a starter template. |
| 2 | """ | 2 | """ |
| 3 | import re | ||
| 4 | import json | ||
| 5 | import shutil | ||
| 3 | from datetime import date | 6 | from datetime import date |
| 4 | from pathlib import Path | 7 | from pathlib import Path |
| 5 | import re | 8 | from dotty_dict import dotty |
| 6 | 9 | ||
| 7 | from qmk.commands import git_get_username | ||
| 8 | import qmk.path | ||
| 9 | from milc import cli | 10 | from milc import cli |
| 10 | from milc.questions import choice, question | 11 | from milc.questions import choice, question |
| 11 | 12 | ||
| 12 | KEYBOARD_TYPES = ['avr', 'ps2avrgb'] | 13 | from qmk.commands import git_get_username |
| 14 | from qmk.json_schema import load_jsonschema | ||
| 15 | from qmk.path import keyboard | ||
| 16 | from qmk.json_encoders import InfoJSONEncoder | ||
| 17 | from qmk.json_schema import deep_update | ||
| 18 | |||
| 19 | COMMUNITY = Path('layouts/default/') | ||
| 20 | TEMPLATE = Path('data/templates/keyboard/') | ||
| 21 | |||
| 22 | MCU2BOOTLOADER = { | ||
| 23 | "MKL26Z64": "halfkay", | ||
| 24 | "MK20DX128": "halfkay", | ||
| 25 | "MK20DX256": "halfkay", | ||
| 26 | "MK66FX1M0": "halfkay", | ||
| 27 | "STM32F042": "stm32-dfu", | ||
| 28 | "STM32F072": "stm32-dfu", | ||
| 29 | "STM32F103": "stm32duino", | ||
| 30 | "STM32F303": "stm32-dfu", | ||
| 31 | "STM32F401": "stm32-dfu", | ||
| 32 | "STM32F405": "stm32-dfu", | ||
| 33 | "STM32F407": "stm32-dfu", | ||
| 34 | "STM32F411": "stm32-dfu", | ||
| 35 | "STM32F446": "stm32-dfu", | ||
| 36 | "STM32G431": "stm32-dfu", | ||
| 37 | "STM32G474": "stm32-dfu", | ||
| 38 | "STM32L412": "stm32-dfu", | ||
| 39 | "STM32L422": "stm32-dfu", | ||
| 40 | "STM32L432": "stm32-dfu", | ||
| 41 | "STM32L433": "stm32-dfu", | ||
| 42 | "STM32L442": "stm32-dfu", | ||
| 43 | "STM32L443": "stm32-dfu", | ||
| 44 | "GD32VF103": "gd32v-dfu", | ||
| 45 | "WB32F3G71": "wb32-dfu", | ||
| 46 | "atmega16u2": "atmel-dfu", | ||
| 47 | "atmega32u2": "atmel-dfu", | ||
| 48 | "atmega16u4": "atmel-dfu", | ||
| 49 | "atmega32u4": "atmel-dfu", | ||
| 50 | "at90usb162": "atmel-dfu", | ||
| 51 | "at90usb646": "atmel-dfu", | ||
| 52 | "at90usb647": "atmel-dfu", | ||
| 53 | "at90usb1286": "atmel-dfu", | ||
| 54 | "at90usb1287": "atmel-dfu", | ||
| 55 | "atmega32a": "bootloadhid", | ||
| 56 | "atmega328p": "usbasploader", | ||
| 57 | "atmega328": "usbasploader", | ||
| 58 | } | ||
| 59 | |||
| 60 | # defaults | ||
| 61 | schema = dotty(load_jsonschema('keyboard')) | ||
| 62 | mcu_types = sorted(schema["properties.processor.enum"], key=str.casefold) | ||
| 63 | available_layouts = sorted([x.name for x in COMMUNITY.iterdir() if x.is_dir()]) | ||
| 64 | |||
| 65 | |||
| 66 | def mcu_type(mcu): | ||
| 67 | """Callable for argparse validation. | ||
| 68 | """ | ||
| 69 | if mcu not in mcu_types: | ||
| 70 | raise ValueError | ||
| 71 | return mcu | ||
| 72 | |||
| 73 | |||
| 74 | def layout_type(layout): | ||
| 75 | """Callable for argparse validation. | ||
| 76 | """ | ||
| 77 | if layout not in available_layouts: | ||
| 78 | raise ValueError | ||
| 79 | return layout | ||
| 13 | 80 | ||
| 14 | 81 | ||
| 15 | def keyboard_name(name): | 82 | def keyboard_name(name): |
| @@ -27,113 +94,163 @@ def validate_keyboard_name(name): | |||
| 27 | return bool(regex.match(name)) | 94 | return bool(regex.match(name)) |
| 28 | 95 | ||
| 29 | 96 | ||
| 30 | @cli.argument('-kb', '--keyboard', help='Specify the name for the new keyboard directory', arg_only=True, type=keyboard_name) | 97 | def select_default_bootloader(mcu): |
| 31 | @cli.argument('-t', '--type', help='Specify the keyboard type', arg_only=True, choices=KEYBOARD_TYPES) | 98 | """Provide sane defaults for bootloader |
| 32 | @cli.argument('-u', '--username', help='Specify your username (default from Git config)', arg_only=True) | ||
| 33 | @cli.argument('-n', '--realname', help='Specify your real name if you want to use that. Defaults to username', arg_only=True) | ||
| 34 | @cli.subcommand('Creates a new keyboard directory') | ||
| 35 | def new_keyboard(cli): | ||
| 36 | """Creates a new keyboard. | ||
| 37 | """ | 99 | """ |
| 38 | cli.log.info('{style_bright}Generating a new QMK keyboard directory{style_normal}') | 100 | return MCU2BOOTLOADER.get(mcu, "custom") |
| 39 | cli.echo('') | 101 | |
| 102 | |||
| 103 | def replace_placeholders(src, dest, tokens): | ||
| 104 | """Replaces the given placeholders in each template file. | ||
| 105 | """ | ||
| 106 | content = src.read_text() | ||
| 107 | for key, value in tokens.items(): | ||
| 108 | content = content.replace(f'%{key}%', value) | ||
| 40 | 109 | ||
| 41 | # Get keyboard name | 110 | dest.write_text(content) |
| 42 | new_keyboard_name = None | ||
| 43 | while not new_keyboard_name: | ||
| 44 | new_keyboard_name = cli.args.keyboard if cli.args.keyboard else question('Keyboard Name:') | ||
| 45 | if not validate_keyboard_name(new_keyboard_name): | ||
| 46 | cli.log.error('Keyboard names must contain only {fg_cyan}lowercase a-z{fg_reset}, {fg_cyan}0-9{fg_reset}, and {fg_cyan}_{fg_reset}! Please choose a different name.') | ||
| 47 | 111 | ||
| 48 | # Exit if passed by arg | ||
| 49 | if cli.args.keyboard: | ||
| 50 | return False | ||
| 51 | 112 | ||
| 52 | new_keyboard_name = None | 113 | def augment_community_info(src, dest): |
| 53 | continue | 114 | """Splice in any additional data into info.json |
| 115 | """ | ||
| 116 | info = json.loads(src.read_text()) | ||
| 117 | template = json.loads(dest.read_text()) | ||
| 54 | 118 | ||
| 55 | keyboard_path = qmk.path.keyboard(new_keyboard_name) | 119 | # merge community with template |
| 56 | if keyboard_path.exists(): | 120 | deep_update(info, template) |
| 57 | cli.log.error(f'Keyboard {{fg_cyan}}{new_keyboard_name}{{fg_reset}} already exists! Please choose a different name.') | ||
| 58 | 121 | ||
| 59 | # Exit if passed by arg | 122 | # avoid assumptions on macro name by using the first available |
| 60 | if cli.args.keyboard: | 123 | first_layout = next(iter(info["layouts"].values()))["layout"] |
| 61 | return False | ||
| 62 | 124 | ||
| 63 | new_keyboard_name = None | 125 | # guess at width and height now its optional |
| 126 | width, height = (0, 0) | ||
| 127 | for item in first_layout: | ||
| 128 | width = max(width, int(item["x"]) + 1) | ||
| 129 | height = max(height, int(item["y"]) + 1) | ||
| 64 | 130 | ||
| 65 | # Get keyboard type | 131 | info["matrix_pins"] = { |
| 66 | keyboard_type = cli.args.type if cli.args.type else choice('Keyboard Type:', KEYBOARD_TYPES, default=0) | 132 | "cols": ["C2"] * width, |
| 133 | "rows": ["D1"] * height, | ||
| 134 | } | ||
| 67 | 135 | ||
| 68 | # Get username | 136 | # assume a 1:1 mapping on matrix to electrical |
| 69 | user_name = None | 137 | for item in first_layout: |
| 70 | while not user_name: | 138 | item["matrix"] = [int(item["y"]), int(item["x"])] |
| 71 | user_name = question('Your GitHub User Name:', default=find_user_name()) | ||
| 72 | 139 | ||
| 73 | if not user_name: | 140 | # finally write out the updated info.json |
| 74 | cli.log.error('You didn\'t provide a username, and we couldn\'t find one set in your QMK or Git configs. Please try again.') | 141 | dest.write_text(json.dumps(info, cls=InfoJSONEncoder)) |
| 75 | 142 | ||
| 76 | # Exit if passed by arg | ||
| 77 | if cli.args.username: | ||
| 78 | return False | ||
| 79 | 143 | ||
| 80 | real_name = None | 144 | def prompt_keyboard(): |
| 81 | while not real_name: | 145 | prompt = """{fg_yellow}Name Your Keyboard Project{style_reset_all} |
| 82 | real_name = question('Your real name:', default=user_name) | ||
| 83 | 146 | ||
| 84 | keyboard_basename = keyboard_path.name | 147 | For more infomation, see: |
| 85 | replacements = { | 148 | https://docs.qmk.fm/#/hardware_keyboard_guidelines?id=naming-your-keyboardproject |
| 86 | "YEAR": str(date.today().year), | ||
| 87 | "KEYBOARD": keyboard_basename, | ||
| 88 | "USER_NAME": user_name, | ||
| 89 | "YOUR_NAME": real_name, | ||
| 90 | } | ||
| 91 | 149 | ||
| 92 | template_dir = Path('data/templates') | 150 | keyboard Name? """ |
| 93 | template_tree(template_dir / 'base', keyboard_path, replacements) | 151 | |
| 94 | template_tree(template_dir / keyboard_type, keyboard_path, replacements) | 152 | return question(prompt, validate=lambda x: not keyboard(x).exists()) |
| 95 | 153 | ||
| 96 | cli.echo('') | ||
| 97 | cli.log.info(f'{{fg_green}}Created a new keyboard called {{fg_cyan}}{new_keyboard_name}{{fg_green}}.{{fg_reset}}') | ||
| 98 | cli.log.info(f'To start working on things, `cd` into {{fg_cyan}}{keyboard_path}{{fg_reset}},') | ||
| 99 | cli.log.info('or open the directory in your preferred text editor.') | ||
| 100 | 154 | ||
| 155 | def prompt_user(): | ||
| 156 | prompt = """{fg_yellow}Attribution{style_reset_all} | ||
| 101 | 157 | ||
| 102 | def find_user_name(): | 158 | Used for maintainer, copyright, etc |
| 103 | if cli.args.username: | ||
| 104 | return cli.args.username | ||
| 105 | elif cli.config.user.name: | ||
| 106 | return cli.config.user.name | ||
| 107 | else: | ||
| 108 | return git_get_username() | ||
| 109 | 159 | ||
| 160 | Your GitHub Username? """ | ||
| 161 | return question(prompt, default=git_get_username()) | ||
| 110 | 162 | ||
| 111 | def template_tree(src: Path, dst: Path, replacements: dict): | ||
| 112 | """Recursively copy template and replace placeholders | ||
| 113 | 163 | ||
| 114 | Args: | 164 | def prompt_name(def_name): |
| 115 | src (Path) | 165 | prompt = """{fg_yellow}More Attribution{style_reset_all} |
| 116 | The source folder to copy from | ||
| 117 | dst (Path) | ||
| 118 | The destination folder to copy to | ||
| 119 | replacements (dict) | ||
| 120 | a dictionary with "key":"value" pairs to replace. | ||
| 121 | 166 | ||
| 122 | Raises: | 167 | Used for maintainer, copyright, etc |
| 123 | FileExistsError | 168 | |
| 124 | When trying to overwrite existing files | 169 | Your Real Name? """ |
| 170 | return question(prompt, default=def_name) | ||
| 171 | |||
| 172 | |||
| 173 | def prompt_layout(): | ||
| 174 | prompt = """{fg_yellow}Pick Base Layout{style_reset_all} | ||
| 175 | |||
| 176 | As a starting point, one of the common layouts can be used to bootstrap the process | ||
| 177 | |||
| 178 | Default Layout? """ | ||
| 179 | # avoid overwhelming user - remove some? | ||
| 180 | filtered_layouts = [x for x in available_layouts if not any(xs in x for xs in ['_split', '_blocker', '_tsangan', '_f13'])] | ||
| 181 | filtered_layouts.append("none of the above") | ||
| 182 | |||
| 183 | return choice(prompt, filtered_layouts, default=len(filtered_layouts) - 1) | ||
| 184 | |||
| 185 | |||
| 186 | def prompt_mcu(): | ||
| 187 | prompt = """{fg_yellow}What Powers Your Project{style_reset_all} | ||
| 188 | |||
| 189 | For more infomation, see: | ||
| 190 | https://docs.qmk.fm/#/compatible_microcontrollers | ||
| 191 | |||
| 192 | MCU? """ | ||
| 193 | # remove any options strictly used for compatibility | ||
| 194 | filtered_mcu = [x for x in mcu_types if not any(xs in x for xs in ['cortex', 'unknown'])] | ||
| 195 | |||
| 196 | return choice(prompt, filtered_mcu, default=filtered_mcu.index("atmega32u4")) | ||
| 197 | |||
| 198 | |||
| 199 | @cli.argument('-kb', '--keyboard', help='Specify the name for the new keyboard directory', arg_only=True, type=keyboard_name) | ||
| 200 | @cli.argument('-l', '--layout', help='Community layout to bootstrap with', arg_only=True, type=layout_type) | ||
| 201 | @cli.argument('-t', '--type', help='Specify the keyboard MCU type', arg_only=True, type=mcu_type) | ||
| 202 | @cli.argument('-u', '--username', help='Specify your username (default from Git config)', arg_only=True) | ||
| 203 | @cli.argument('-n', '--realname', help='Specify your real name if you want to use that. Defaults to username', arg_only=True) | ||
| 204 | @cli.subcommand('Creates a new keyboard directory') | ||
| 205 | def new_keyboard(cli): | ||
| 206 | """Creates a new keyboard. | ||
| 125 | """ | 207 | """ |
| 208 | cli.log.info('{style_bright}Generating a new QMK keyboard directory{style_normal}') | ||
| 209 | cli.echo('') | ||
| 210 | |||
| 211 | kb_name = cli.args.keyboard if cli.args.keyboard else prompt_keyboard() | ||
| 212 | user_name = cli.args.username if cli.args.username else prompt_user() | ||
| 213 | real_name = cli.args.realname or cli.args.username if cli.args.realname or cli.args.username else prompt_name(user_name) | ||
| 214 | default_layout = cli.args.layout if cli.args.layout else prompt_layout() | ||
| 215 | mcu = cli.args.type if cli.args.type else prompt_mcu() | ||
| 216 | bootloader = select_default_bootloader(mcu) | ||
| 126 | 217 | ||
| 127 | dst.mkdir(parents=True, exist_ok=True) | 218 | if not validate_keyboard_name(kb_name): |
| 219 | cli.log.error('Keyboard names must contain only {fg_cyan}lowercase a-z{fg_reset}, {fg_cyan}0-9{fg_reset}, and {fg_cyan}_{fg_reset}! Please choose a different name.') | ||
| 220 | return 1 | ||
| 128 | 221 | ||
| 129 | for child in src.iterdir(): | 222 | if keyboard(kb_name).exists(): |
| 130 | if child.is_dir(): | 223 | cli.log.error(f'Keyboard {{fg_cyan}}{kb_name}{{fg_reset}} already exists! Please choose a different name.') |
| 131 | template_tree(child, dst / child.name, replacements=replacements) | 224 | return 1 |
| 132 | 225 | ||
| 133 | if child.is_file(): | 226 | tokens = {'YEAR': str(date.today().year), 'KEYBOARD': kb_name, 'USER_NAME': user_name, 'REAL_NAME': real_name, 'LAYOUT': default_layout, 'MCU': mcu, 'BOOTLOADER': bootloader} |
| 134 | file_name = dst / (child.name % replacements) | ||
| 135 | 227 | ||
| 136 | with file_name.open(mode='x') as dst_f: | 228 | if cli.config.general.verbose: |
| 137 | with child.open() as src_f: | 229 | cli.log.info("Creating keyboard with:") |
| 138 | template = src_f.read() | 230 | for key, value in tokens.items(): |
| 139 | dst_f.write(template % replacements) | 231 | cli.echo(f" {key.ljust(10)}: {value}") |
| 232 | |||
| 233 | # TODO: detach community layout and rename to just "LAYOUT" | ||
| 234 | if default_layout == 'none of the above': | ||
| 235 | default_layout = "ortho_4x4" | ||
| 236 | |||
| 237 | # begin with making the deepest folder in the tree | ||
| 238 | keymaps_path = keyboard(kb_name) / 'keymaps/' | ||
| 239 | keymaps_path.mkdir(parents=True) | ||
| 240 | |||
| 241 | # copy in keymap.c or keymap.json | ||
| 242 | community_keymap = Path(COMMUNITY / f'{default_layout}/default_{default_layout}/') | ||
| 243 | shutil.copytree(community_keymap, keymaps_path / 'default') | ||
| 244 | |||
| 245 | # process template files | ||
| 246 | for file in list(TEMPLATE.iterdir()): | ||
| 247 | replace_placeholders(file, keyboard(kb_name) / file.name, tokens) | ||
| 248 | |||
| 249 | # merge in infos | ||
| 250 | community_info = Path(COMMUNITY / f'{default_layout}/info.json') | ||
| 251 | augment_community_info(community_info, keyboard(kb_name) / community_info.name) | ||
| 252 | |||
| 253 | cli.log.info(f'{{fg_green}}Created a new keyboard called {{fg_cyan}}{kb_name}{{fg_green}}.{{fg_reset}}') | ||
| 254 | cli.log.info(f'To start working on things, `cd` into {{fg_cyan}}keyboards/{kb_name}{{fg_reset}},') | ||
| 255 | cli.log.info('or open the directory in your preferred text editor.') | ||
| 256 | cli.log.info(f"And build with {{fg_yellow}}qmk compile -kb {kb_name} -km default{{fg_reset}}.") | ||
