Co-authored-by: Duncan Sutherland <dunk2k_2000@hotmail.com>pull/22553/head
@ -0,0 +1,14 @@ | |||
{ | |||
"$schema": "https://json-schema.org/draft/2020-12/schema#", | |||
"$id": "qmk.user_repo.v0", | |||
"title": "User Repository Information", | |||
"type": "object", | |||
"required": [ | |||
"userspace_version" | |||
], | |||
"properties": { | |||
"userspace_version": { | |||
"type": "string", | |||
}, | |||
} | |||
} |
@ -0,0 +1,22 @@ | |||
{ | |||
"$schema": "https://json-schema.org/draft/2020-12/schema#", | |||
"$id": "qmk.user_repo.v1", | |||
"title": "User Repository Information", | |||
"type": "object", | |||
"required": [ | |||
"userspace_version", | |||
"build_targets" | |||
], | |||
"properties": { | |||
"userspace_version": { | |||
"type": "string", | |||
"enum": ["1.0"] | |||
}, | |||
"build_targets": { | |||
"type": "array", | |||
"items": { | |||
"$ref": "qmk.definitions.v1#/build_target" | |||
} | |||
} | |||
} | |||
} |
@ -0,0 +1,96 @@ | |||
# External QMK Userspace | |||
QMK Firmware now officially supports storing user keymaps outside of the normal QMK Firmware repository, allowing users to maintain their own keymaps without having to fork, modify, and maintain a copy of QMK Firmware themselves. | |||
External Userspace mirrors the structure of the main QMK Firmware repository, but only contains the keymaps that you wish to build. You can still use `keyboards/<my keyboard>/keymaps/<my keymap>` to store your keymaps, or you can use the `layouts/<my layout>/<my keymap>` system as before -- they're just stored external to QMK Firmware. | |||
The build system will still honor the use of `users/<my keymap>` if you rely on the traditional QMK Firmware [userspace feature](feature_userspace.md) -- it's now supported externally too, using the same location inside the External Userspace directory. | |||
Additionally, there is first-class support for using GitHub Actions to build your keymaps, allowing you to automatically compile your keymaps whenever you push changes to your External Userspace repository. | |||
!> External Userspace is new functionality and may have issues. Tighter integration with the `qmk` command will occur over time. | |||
?> Historical keymap.json and GitHub-based firmware build instructions can be found [here](newbs_building_firmware_workflow.md). This document supersedes those instructions, but they should still function correctly. | |||
## Setting up QMK Locally | |||
If you wish to build on your local machine, you will need to set up QMK locally. This is a one-time process, and is documented in the [newbs setup guide](https://docs.qmk.fm/#/newbs). | |||
!> If you wish to use any QMK CLI commands related to manipulating External Userspace definitions, you will currently need a copy of QMK Firmware as well. | |||
!> Building locally has a much shorter turnaround time than waiting for GitHub Actions to complete. | |||
## External Userspace Repository Setup (forked on GitHub) | |||
A basic skeleton External Userspace repository can be found [here](https://github.com/qmk/qmk_userspace). If you wish to keep your keymaps on GitHub (strongly recommended!), you can fork the repository and use it as a base: | |||
![Userspace Fork](https://i.imgur.com/hcegguh.png) | |||
Going ahead with your fork will copy it to your account, at which point you can clone it to your local machine and begin adding your keymaps: | |||
![Userspace Clone](https://i.imgur.com/CWYmsk8.png) | |||
```sh | |||
cd $HOME | |||
git clone https://github.com/{myusername}/qmk_userspace.git | |||
qmk config user.overlay_dir="$(realpath qmk_userspace)" | |||
``` | |||
## External Userspace Setup (locally stored only) | |||
If you don't want to use GitHub and prefer to keep everything local, you can clone a copy of the default External Userspace locally instead: | |||
```sh | |||
cd $HOME | |||
git clone https://github.com/qmk/qmk_userspace.git | |||
qmk config user.overlay_dir="$(realpath qmk_userspace)" | |||
``` | |||
## Adding a Keymap | |||
_These instructions assume you have already set up QMK locally, and have a copy of the QMK Firmware repository on your machine._ | |||
Keymaps within External Userspace are defined in the same way as they are in the main QMK repository. You can either use the `qmk new-keymap` command to create a new keymap, or manually create a new directory in the `keyboards` directory. | |||
Alternatively, you can use the `layouts` directory to store your keymaps, using the same layout system as the main QMK repository -- if you choose to do so you'll want to use the path `layouts/<layout name>/<keymap name>/keymap.*` to store your keymap files, where `layout name` matches an existing layout in QMK, such as `tkl_ansi`. | |||
After creating your new keymap, building the keymap matches normal QMK usage: | |||
```sh | |||
qmk compile -kb <keyboard> -km <keymap> | |||
``` | |||
!> The `qmk config user.overlay_dir=...` command must have been run when cloning the External Userspace repository for this to work correctly. | |||
## Adding the keymap to External Userspace build targets | |||
Once you have created your keymap, if you want to use GitHub Actions to build your firmware, you will need to add it to the External Userspace build targets. This is done using the `qmk userspace-add` command: | |||
```sh | |||
# for a keyboard/keymap combo: | |||
qmk userspace-add -kb <keyboard> -km <keymap> | |||
# or, for a json-based keymap (if kept "loose"): | |||
qmk userspace-add <relative/path/to/my/keymap.json> | |||
``` | |||
This updates the `qmk.json` file in the root of your External Userspace directory. If you're using a git repository to store your keymaps, now is a great time to commit and push to your own fork. | |||
## Compiling External Userspace build targets | |||
Once you have added your keymaps to the External Userspace build targets, you can compile all of them at once using the `qmk userspace-compile` command: | |||
```sh | |||
qmk userspace-compile | |||
``` | |||
All firmware builds you've added to the External Userspace build targets will be built, and the resulting firmware files will be placed in the root of your External Userspace directory. | |||
## Using GitHub Actions | |||
GitHub Actions can be used to automatically build your keymaps whenever you push changes to your External Userspace repository. If you have set up your list of build targets, this is as simple as enabling workflows in the GitHub repository settings: | |||
![Repo Settings](https://i.imgur.com/EVkxOt1.png) | |||
Any push will result in compilation of all configured builds, and once completed a new release containing the newly-minted firmware files will be created on GitHub, which you can subsequently download and flash to your keyboard: | |||
![Releases](https://i.imgur.com/zmwOL5P.png) |
@ -0,0 +1,5 @@ | |||
from . import doctor | |||
from . import add | |||
from . import remove | |||
from . import list | |||
from . import compile |
@ -0,0 +1,51 @@ | |||
# Copyright 2023 Nick Brassel (@tzarc) | |||
# SPDX-License-Identifier: GPL-2.0-or-later | |||
from pathlib import Path | |||
from milc import cli | |||
from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE | |||
from qmk.keyboard import keyboard_completer, keyboard_folder_or_all | |||
from qmk.keymap import keymap_completer, is_keymap_target | |||
from qmk.userspace import UserspaceDefs | |||
@cli.argument('builds', nargs='*', arg_only=True, help="List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.") | |||
@cli.argument('-kb', '--keyboard', type=keyboard_folder_or_all, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.') | |||
@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.') | |||
@cli.subcommand('Adds a build target to userspace `qmk.json`.') | |||
def userspace_add(cli): | |||
if not HAS_QMK_USERSPACE: | |||
cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.') | |||
return False | |||
userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json') | |||
if len(cli.args.builds) > 0: | |||
json_like_targets = list([Path(p) for p in filter(lambda e: Path(e).exists() and Path(e).suffix == '.json', cli.args.builds)]) | |||
make_like_targets = list(filter(lambda e: Path(e) not in json_like_targets, cli.args.builds)) | |||
for e in json_like_targets: | |||
userspace.add_target(json_path=e) | |||
for e in make_like_targets: | |||
s = e.split(':') | |||
userspace.add_target(keyboard=s[0], keymap=s[1]) | |||
else: | |||
failed = False | |||
try: | |||
if not is_keymap_target(cli.args.keyboard, cli.args.keymap): | |||
failed = True | |||
except KeyError: | |||
failed = True | |||
if failed: | |||
from qmk.cli.new.keymap import new_keymap | |||
cli.config.new_keymap.keyboard = cli.args.keyboard | |||
cli.config.new_keymap.keymap = cli.args.keymap | |||
if new_keymap(cli) is not False: | |||
userspace.add_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap) | |||
else: | |||
userspace.add_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap) | |||
return userspace.save() |
@ -0,0 +1,38 @@ | |||
# Copyright 2023 Nick Brassel (@tzarc) | |||
# SPDX-License-Identifier: GPL-2.0-or-later | |||
from pathlib import Path | |||
from milc import cli | |||
from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE | |||
from qmk.commands import build_environment | |||
from qmk.userspace import UserspaceDefs | |||
from qmk.build_targets import JsonKeymapBuildTarget | |||
from qmk.search import search_keymap_targets | |||
from qmk.cli.mass_compile import mass_compile_targets | |||
@cli.argument('-t', '--no-temp', arg_only=True, action='store_true', help="Remove temporary files during build.") | |||
@cli.argument('-j', '--parallel', type=int, default=1, help="Set the number of parallel make jobs; 0 means unlimited.") | |||
@cli.argument('-c', '--clean', arg_only=True, action='store_true', help="Remove object files before compiling.") | |||
@cli.argument('-n', '--dry-run', arg_only=True, action='store_true', help="Don't actually build, just show the commands to be run.") | |||
@cli.argument('-e', '--env', arg_only=True, action='append', default=[], help="Set a variable to be passed to make. May be passed multiple times.") | |||
@cli.subcommand('Compiles the build targets specified in userspace `qmk.json`.') | |||
def userspace_compile(cli): | |||
if not HAS_QMK_USERSPACE: | |||
cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.') | |||
return False | |||
userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json') | |||
build_targets = [] | |||
keyboard_keymap_targets = [] | |||
for e in userspace.build_targets: | |||
if isinstance(e, Path): | |||
build_targets.append(JsonKeymapBuildTarget(e)) | |||
elif isinstance(e, dict): | |||
keyboard_keymap_targets.append((e['keyboard'], e['keymap'])) | |||
if len(keyboard_keymap_targets) > 0: | |||
build_targets.extend(search_keymap_targets(keyboard_keymap_targets)) | |||
mass_compile_targets(list(set(build_targets)), cli.args.clean, cli.args.dry_run, cli.config.userspace_compile.no_temp, cli.config.userspace_compile.parallel, **build_environment(cli.args.env)) |
@ -0,0 +1,11 @@ | |||
# Copyright 2023 Nick Brassel (@tzarc) | |||
# SPDX-License-Identifier: GPL-2.0-or-later | |||
from milc import cli | |||
from qmk.constants import QMK_FIRMWARE | |||
from qmk.cli.doctor.main import userspace_tests | |||
@cli.subcommand('Checks userspace configuration.') | |||
def userspace_doctor(cli): | |||
userspace_tests(QMK_FIRMWARE) |
@ -0,0 +1,51 @@ | |||
# Copyright 2023 Nick Brassel (@tzarc) | |||
# SPDX-License-Identifier: GPL-2.0-or-later | |||
from pathlib import Path | |||
from dotty_dict import Dotty | |||
from milc import cli | |||
from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE | |||
from qmk.userspace import UserspaceDefs | |||
from qmk.build_targets import BuildTarget | |||
from qmk.keyboard import is_all_keyboards, keyboard_folder | |||
from qmk.keymap import is_keymap_target | |||
from qmk.search import search_keymap_targets | |||
@cli.argument('-e', '--expand', arg_only=True, action='store_true', help="Expands any use of `all` for either keyboard or keymap.") | |||
@cli.subcommand('Lists the build targets specified in userspace `qmk.json`.') | |||
def userspace_list(cli): | |||
if not HAS_QMK_USERSPACE: | |||
cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.') | |||
return False | |||
userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json') | |||
if cli.args.expand: | |||
build_targets = [] | |||
for e in userspace.build_targets: | |||
if isinstance(e, Path): | |||
build_targets.append(e) | |||
elif isinstance(e, dict) or isinstance(e, Dotty): | |||
build_targets.extend(search_keymap_targets([(e['keyboard'], e['keymap'])])) | |||
else: | |||
build_targets = userspace.build_targets | |||
for e in build_targets: | |||
if isinstance(e, Path): | |||
# JSON keymap from userspace | |||
cli.log.info(f'JSON keymap: {{fg_cyan}}{e}{{fg_reset}}') | |||
continue | |||
elif isinstance(e, dict) or isinstance(e, Dotty): | |||
# keyboard/keymap dict from userspace | |||
keyboard = e['keyboard'] | |||
keymap = e['keymap'] | |||
elif isinstance(e, BuildTarget): | |||
# BuildTarget from search_keymap_targets() | |||
keyboard = e.keyboard | |||
keymap = e.keymap | |||
if is_all_keyboards(keyboard) or is_keymap_target(keyboard_folder(keyboard), keymap): | |||
cli.log.info(f'Keyboard: {{fg_cyan}}{keyboard}{{fg_reset}}, keymap: {{fg_cyan}}{keymap}{{fg_reset}}') | |||
else: | |||
cli.log.warn(f'Keyboard: {{fg_cyan}}{keyboard}{{fg_reset}}, keymap: {{fg_cyan}}{keymap}{{fg_reset}} -- not found!') |
@ -0,0 +1,37 @@ | |||
# Copyright 2023 Nick Brassel (@tzarc) | |||
# SPDX-License-Identifier: GPL-2.0-or-later | |||
from pathlib import Path | |||
from milc import cli | |||
from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE | |||
from qmk.keyboard import keyboard_completer, keyboard_folder_or_all | |||
from qmk.keymap import keymap_completer | |||
from qmk.userspace import UserspaceDefs | |||
@cli.argument('builds', nargs='*', arg_only=True, help="List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.") | |||
@cli.argument('-kb', '--keyboard', type=keyboard_folder_or_all, completer=keyboard_completer, help='The keyboard to build a firmware for. Ignored when a configurator export is supplied.') | |||
@cli.argument('-km', '--keymap', completer=keymap_completer, help='The keymap to build a firmware for. Ignored when a configurator export is supplied.') | |||
@cli.subcommand('Removes a build target from userspace `qmk.json`.') | |||
def userspace_remove(cli): | |||
if not HAS_QMK_USERSPACE: | |||
cli.log.error('Could not determine QMK userspace location. Please run `qmk doctor` or `qmk userspace-doctor` to diagnose.') | |||
return False | |||
userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json') | |||
if len(cli.args.builds) > 0: | |||
json_like_targets = list([Path(p) for p in filter(lambda e: Path(e).exists() and Path(e).suffix == '.json', cli.args.builds)]) | |||
make_like_targets = list(filter(lambda e: Path(e) not in json_like_targets, cli.args.builds)) | |||
for e in json_like_targets: | |||
userspace.remove_target(json_path=e) | |||
for e in make_like_targets: | |||
s = e.split(':') | |||
userspace.remove_target(keyboard=s[0], keymap=s[1]) | |||
else: | |||
userspace.remove_target(keyboard=cli.args.keyboard, keymap=cli.args.keymap) | |||
return userspace.save() |
@ -0,0 +1,185 @@ | |||
# Copyright 2023 Nick Brassel (@tzarc) | |||
# SPDX-License-Identifier: GPL-2.0-or-later | |||
from os import environ | |||
from pathlib import Path | |||
import json | |||
import jsonschema | |||
from milc import cli | |||
from qmk.json_schema import validate, json_load | |||
from qmk.json_encoders import UserspaceJSONEncoder | |||
def qmk_userspace_paths(): | |||
test_dirs = [] | |||
# If we're already in a directory with a qmk.json and a keyboards or layouts directory, interpret it as userspace | |||
current_dir = Path(environ['ORIG_CWD']) | |||
while len(current_dir.parts) > 1: | |||
if (current_dir / 'qmk.json').is_file(): | |||
test_dirs.append(current_dir) | |||
current_dir = current_dir.parent | |||
# If we have a QMK_USERSPACE environment variable, use that | |||
if environ.get('QMK_USERSPACE') is not None: | |||
current_dir = Path(environ.get('QMK_USERSPACE')) | |||
if current_dir.is_dir(): | |||
test_dirs.append(current_dir) | |||
# If someone has configured a directory, use that | |||
if cli.config.user.overlay_dir is not None: | |||
current_dir = Path(cli.config.user.overlay_dir) | |||
if current_dir.is_dir(): | |||
test_dirs.append(current_dir) | |||
return test_dirs | |||
def qmk_userspace_validate(path): | |||
# Construct a UserspaceDefs object to ensure it validates correctly | |||
if (path / 'qmk.json').is_file(): | |||
UserspaceDefs(path / 'qmk.json') | |||
return | |||
# No qmk.json file found | |||
raise FileNotFoundError('No qmk.json file found.') | |||
def detect_qmk_userspace(): | |||
# Iterate through all the detected userspace paths and return the first one that validates correctly | |||
test_dirs = qmk_userspace_paths() | |||
for test_dir in test_dirs: | |||
try: | |||
qmk_userspace_validate(test_dir) | |||
return test_dir | |||
except FileNotFoundError: | |||
continue | |||
except UserspaceValidationError: | |||
continue | |||
return None | |||
class UserspaceDefs: | |||
def __init__(self, userspace_json: Path): | |||
self.path = userspace_json | |||
self.build_targets = [] | |||
json = json_load(userspace_json) | |||
exception = UserspaceValidationError() | |||
success = False | |||
try: | |||
validate(json, 'qmk.user_repo.v0') # `qmk.json` must have a userspace_version at minimum | |||
except jsonschema.ValidationError as err: | |||
exception.add('qmk.user_repo.v0', err) | |||
raise exception | |||
# Iterate through each version of the schema, starting with the latest and decreasing to v1 | |||
try: | |||
validate(json, 'qmk.user_repo.v1') | |||
self.__load_v1(json) | |||
success = True | |||
except jsonschema.ValidationError as err: | |||
exception.add('qmk.user_repo.v1', err) | |||
if not success: | |||
raise exception | |||
def save(self): | |||
target_json = { | |||
"userspace_version": "1.0", # Needs to match latest version | |||
"build_targets": [] | |||
} | |||
for e in self.build_targets: | |||
if isinstance(e, dict): | |||
target_json['build_targets'].append([e['keyboard'], e['keymap']]) | |||
elif isinstance(e, Path): | |||
target_json['build_targets'].append(str(e.relative_to(self.path.parent))) | |||
try: | |||
# Ensure what we're writing validates against the latest version of the schema | |||
validate(target_json, 'qmk.user_repo.v1') | |||
except jsonschema.ValidationError as err: | |||
cli.log.error(f'Could not save userspace file: {err}') | |||
return False | |||
# Only actually write out data if it changed | |||
old_data = json.dumps(json.loads(self.path.read_text()), cls=UserspaceJSONEncoder, sort_keys=True) | |||
new_data = json.dumps(target_json, cls=UserspaceJSONEncoder, sort_keys=True) | |||
if old_data != new_data: | |||
self.path.write_text(new_data) | |||
cli.log.info(f'Saved userspace file to {self.path}.') | |||
return True | |||
def add_target(self, keyboard=None, keymap=None, json_path=None, do_print=True): | |||
if json_path is not None: | |||
# Assume we're adding a json filename/path | |||
json_path = Path(json_path) | |||
if json_path not in self.build_targets: | |||
self.build_targets.append(json_path) | |||
if do_print: | |||
cli.log.info(f'Added {json_path} to userspace build targets.') | |||
else: | |||
cli.log.info(f'{json_path} is already a userspace build target.') | |||
elif keyboard is not None and keymap is not None: | |||
# Both keyboard/keymap specified | |||
e = {"keyboard": keyboard, "keymap": keymap} | |||
if e not in self.build_targets: | |||
self.build_targets.append(e) | |||
if do_print: | |||
cli.log.info(f'Added {keyboard}:{keymap} to userspace build targets.') | |||
else: | |||
if do_print: | |||
cli.log.info(f'{keyboard}:{keymap} is already a userspace build target.') | |||
def remove_target(self, keyboard=None, keymap=None, json_path=None, do_print=True): | |||
if json_path is not None: | |||
# Assume we're removing a json filename/path | |||
json_path = Path(json_path) | |||
if json_path in self.build_targets: | |||
self.build_targets.remove(json_path) | |||
if do_print: | |||
cli.log.info(f'Removed {json_path} from userspace build targets.') | |||
else: | |||
cli.log.info(f'{json_path} is not a userspace build target.') | |||
elif keyboard is not None and keymap is not None: | |||
# Both keyboard/keymap specified | |||
e = {"keyboard": keyboard, "keymap": keymap} | |||
if e in self.build_targets: | |||
self.build_targets.remove(e) | |||
if do_print: | |||
cli.log.info(f'Removed {keyboard}:{keymap} from userspace build targets.') | |||
else: | |||
if do_print: | |||
cli.log.info(f'{keyboard}:{keymap} is not a userspace build target.') | |||
def __load_v1(self, json): | |||
for e in json['build_targets']: | |||
if isinstance(e, list) and len(e) == 2: | |||
self.add_target(keyboard=e[0], keymap=e[1], do_print=False) | |||
if isinstance(e, str): | |||
p = self.path.parent / e | |||
if p.exists() and p.suffix == '.json': | |||
self.add_target(json_path=p, do_print=False) | |||
class UserspaceValidationError(Exception): | |||
def __init__(self, *args, **kwargs): | |||
super().__init__(*args, **kwargs) | |||
self.__exceptions = [] | |||
def __str__(self): | |||
return self.message | |||
@property | |||
def exceptions(self): | |||
return self.__exceptions | |||
def add(self, schema, exception): | |||
self.__exceptions.append((schema, exception)) | |||
errorlist = "\n\n".join([f"{schema}: {exception}" for schema, exception in self.__exceptions]) | |||
self.message = f'Could not validate against any version of the userspace schema. Errors:\n\n{errorlist}' |