Browse Source

QMK Userspace (#22222)

Co-authored-by: Duncan Sutherland <dunk2k_2000@hotmail.com>
pull/22553/head
Nick Brassel 5 months ago
committed by GitHub
parent
commit
5501e804ff
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1081 additions and 107 deletions
  1. +28
    -2
      Makefile
  2. +19
    -0
      builddefs/build_json.mk
  3. +68
    -28
      builddefs/build_keyboard.mk
  4. +4
    -0
      builddefs/build_layout.mk
  5. +10
    -1
      builddefs/common_rules.mk
  6. +18
    -0
      data/schemas/definitions.jsonschema
  7. +14
    -0
      data/schemas/user_repo_v0.jsonschema
  8. +22
    -0
      data/schemas/user_repo_v1.jsonschema
  9. +1
    -1
      docs/_summary.md
  10. +125
    -0
      docs/cli_commands.md
  11. +96
    -0
      docs/newbs_external_userspace.md
  12. +16
    -0
      lib/python/qmk/build_targets.py
  13. +5
    -0
      lib/python/qmk/cli/__init__.py
  14. +3
    -1
      lib/python/qmk/cli/compile.py
  15. +24
    -1
      lib/python/qmk/cli/doctor/main.py
  16. +48
    -22
      lib/python/qmk/cli/format/json.py
  17. +1
    -1
      lib/python/qmk/cli/mass_compile.py
  18. +8
    -0
      lib/python/qmk/cli/new/keymap.py
  19. +5
    -0
      lib/python/qmk/cli/userspace/__init__.py
  20. +51
    -0
      lib/python/qmk/cli/userspace/add.py
  21. +38
    -0
      lib/python/qmk/cli/userspace/compile.py
  22. +11
    -0
      lib/python/qmk/cli/userspace/doctor.py
  23. +51
    -0
      lib/python/qmk/cli/userspace/list.py
  24. +37
    -0
      lib/python/qmk/cli/userspace/remove.py
  25. +6
    -0
      lib/python/qmk/commands.py
  26. +8
    -0
      lib/python/qmk/constants.py
  27. +18
    -0
      lib/python/qmk/json_encoders.py
  28. +21
    -1
      lib/python/qmk/keyboard.py
  29. +85
    -45
      lib/python/qmk/keymap.py
  30. +55
    -4
      lib/python/qmk/path.py
  31. +185
    -0
      lib/python/qmk/userspace.py

+ 28
- 2
Makefile View File

@ -38,6 +38,11 @@ $(info QMK Firmware $(QMK_VERSION))
endif
endif
# Try to determine userspace from qmk config, if set.
ifeq ($(QMK_USERSPACE),)
QMK_USERSPACE = $(shell qmk config -ro user.overlay_dir | cut -d= -f2 | sed -e 's@^None$$@@g')
endif
# Determine which qmk cli to use
QMK_BIN := qmk
@ -191,9 +196,20 @@ define PARSE_KEYBOARD
KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(ROOT_DIR)/keyboards/$$(KEYBOARD_FOLDER_PATH_4)/keymaps/*/.)))
KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(ROOT_DIR)/keyboards/$$(KEYBOARD_FOLDER_PATH_5)/keymaps/*/.)))
ifneq ($(QMK_USERSPACE),)
KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/keyboards/$$(KEYBOARD_FOLDER_PATH_1)/keymaps/*/.)))
KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/keyboards/$$(KEYBOARD_FOLDER_PATH_2)/keymaps/*/.)))
KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/keyboards/$$(KEYBOARD_FOLDER_PATH_3)/keymaps/*/.)))
KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/keyboards/$$(KEYBOARD_FOLDER_PATH_4)/keymaps/*/.)))
KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/keyboards/$$(KEYBOARD_FOLDER_PATH_5)/keymaps/*/.)))
endif
KEYBOARD_LAYOUTS := $(shell $(QMK_BIN) list-layouts --keyboard $1)
LAYOUT_KEYMAPS :=
$$(foreach LAYOUT,$$(KEYBOARD_LAYOUTS),$$(eval LAYOUT_KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(ROOT_DIR)/layouts/*/$$(LAYOUT)/*/.)))))
ifneq ($(QMK_USERSPACE),)
$$(foreach LAYOUT,$$(KEYBOARD_LAYOUTS),$$(eval LAYOUT_KEYMAPS += $$(notdir $$(patsubst %/.,%,$$(wildcard $(QMK_USERSPACE)/layouts/$$(LAYOUT)/*/.)))))
endif
KEYMAPS := $$(sort $$(KEYMAPS) $$(LAYOUT_KEYMAPS))
@ -431,8 +447,18 @@ clean:
rm -rf $(BUILD_DIR)
echo 'done.'
.PHONY: distclean
distclean: clean
.PHONY: distclean distclean_qmk
distclean: distclean_qmk
distclean_qmk: clean
echo -n 'Deleting *.bin, *.hex, and *.uf2 ... '
rm -f *.bin *.hex *.uf2
echo 'done.'
ifneq ($(QMK_USERSPACE),)
.PHONY: distclean_userspace
distclean: distclean_userspace
distclean_userspace: clean
echo -n 'Deleting userspace *.bin, *.hex, and *.uf2 ... '
rm -f $(QMK_USERSPACE)/*.bin $(QMK_USERSPACE)/*.hex $(QMK_USERSPACE)/*.uf2
echo 'done.'
endif

+ 19
- 0
builddefs/build_json.mk View File

@ -15,3 +15,22 @@ else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_1)/keymap.json)","")
KEYMAP_JSON := $(MAIN_KEYMAP_PATH_1)/keymap.json
KEYMAP_JSON_PATH := $(MAIN_KEYMAP_PATH_1)
endif
ifneq ($(QMK_USERSPACE),)
ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)/keymap.json)","")
KEYMAP_JSON := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)/keymap.json
KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)
else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)/keymap.json)","")
KEYMAP_JSON := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)/keymap.json
KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)
else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)/keymap.json)","")
KEYMAP_JSON := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)/keymap.json
KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)
else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)/keymap.json)","")
KEYMAP_JSON := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)/keymap.json
KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)
else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)/keymap.json)","")
KEYMAP_JSON := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)/keymap.json
KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)
endif
endif

+ 68
- 28
builddefs/build_keyboard.mk View File

@ -127,34 +127,60 @@ include $(INFO_RULES_MK)
include $(BUILDDEFS_PATH)/build_json.mk
# Pull in keymap level rules.mk
# Look through the possible keymap folders until we find a matching keymap.c
ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_1)/keymap.c)","")
-include $(MAIN_KEYMAP_PATH_1)/rules.mk
KEYMAP_C := $(MAIN_KEYMAP_PATH_1)/keymap.c
KEYMAP_PATH := $(MAIN_KEYMAP_PATH_1)
else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_2)/keymap.c)","")
-include $(MAIN_KEYMAP_PATH_2)/rules.mk
KEYMAP_C := $(MAIN_KEYMAP_PATH_2)/keymap.c
KEYMAP_PATH := $(MAIN_KEYMAP_PATH_2)
else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_3)/keymap.c)","")
-include $(MAIN_KEYMAP_PATH_3)/rules.mk
KEYMAP_C := $(MAIN_KEYMAP_PATH_3)/keymap.c
KEYMAP_PATH := $(MAIN_KEYMAP_PATH_3)
else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_4)/keymap.c)","")
-include $(MAIN_KEYMAP_PATH_4)/rules.mk
KEYMAP_C := $(MAIN_KEYMAP_PATH_4)/keymap.c
KEYMAP_PATH := $(MAIN_KEYMAP_PATH_4)
else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_5)/keymap.c)","")
-include $(MAIN_KEYMAP_PATH_5)/rules.mk
KEYMAP_C := $(MAIN_KEYMAP_PATH_5)/keymap.c
KEYMAP_PATH := $(MAIN_KEYMAP_PATH_5)
else ifneq ($(LAYOUTS),)
# If we haven't found a keymap yet fall back to community layouts
include $(BUILDDEFS_PATH)/build_layout.mk
# Not finding keymap.c is fine if we found a keymap.json
else ifeq ("$(wildcard $(KEYMAP_JSON_PATH))", "")
$(call CATASTROPHIC_ERROR,Invalid keymap,Could not find keymap)
# this state should never be reached
ifeq ("$(wildcard $(KEYMAP_PATH))", "")
# Look through the possible keymap folders until we find a matching keymap.c
ifneq ($(QMK_USERSPACE),)
ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)/keymap.c)","")
-include $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)/rules.mk
KEYMAP_C := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)/keymap.c
KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_1)
else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)/keymap.c)","")
-include $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)/rules.mk
KEYMAP_C := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)/keymap.c
KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_2)
else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)/keymap.c)","")
-include $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)/rules.mk
KEYMAP_C := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)/keymap.c
KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_3)
else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)/keymap.c)","")
-include $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)/rules.mk
KEYMAP_C := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)/keymap.c
KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_4)
else ifneq ("$(wildcard $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)/keymap.c)","")
-include $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)/rules.mk
KEYMAP_C := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)/keymap.c
KEYMAP_PATH := $(QMK_USERSPACE)/$(MAIN_KEYMAP_PATH_5)
endif
endif
ifeq ($(KEYMAP_PATH),)
ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_1)/keymap.c)","")
-include $(MAIN_KEYMAP_PATH_1)/rules.mk
KEYMAP_C := $(MAIN_KEYMAP_PATH_1)/keymap.c
KEYMAP_PATH := $(MAIN_KEYMAP_PATH_1)
else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_2)/keymap.c)","")
-include $(MAIN_KEYMAP_PATH_2)/rules.mk
KEYMAP_C := $(MAIN_KEYMAP_PATH_2)/keymap.c
KEYMAP_PATH := $(MAIN_KEYMAP_PATH_2)
else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_3)/keymap.c)","")
-include $(MAIN_KEYMAP_PATH_3)/rules.mk
KEYMAP_C := $(MAIN_KEYMAP_PATH_3)/keymap.c
KEYMAP_PATH := $(MAIN_KEYMAP_PATH_3)
else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_4)/keymap.c)","")
-include $(MAIN_KEYMAP_PATH_4)/rules.mk
KEYMAP_C := $(MAIN_KEYMAP_PATH_4)/keymap.c
KEYMAP_PATH := $(MAIN_KEYMAP_PATH_4)
else ifneq ("$(wildcard $(MAIN_KEYMAP_PATH_5)/keymap.c)","")
-include $(MAIN_KEYMAP_PATH_5)/rules.mk
KEYMAP_C := $(MAIN_KEYMAP_PATH_5)/keymap.c
KEYMAP_PATH := $(MAIN_KEYMAP_PATH_5)
else ifneq ($(LAYOUTS),)
# If we haven't found a keymap yet fall back to community layouts
include $(BUILDDEFS_PATH)/build_layout.mk
else ifeq ("$(wildcard $(KEYMAP_JSON_PATH))", "") # Not finding keymap.c is fine if we found a keymap.json
$(call CATASTROPHIC_ERROR,Invalid keymap,Could not find keymap)
# this state should never be reached
endif
endif
endif
# Have we found a keymap.json?
@ -364,6 +390,16 @@ ifeq ("$(USER_NAME)","")
endif
USER_PATH := users/$(USER_NAME)
# If we have userspace, then add it to the lookup VPATH
ifneq ($(wildcard $(QMK_USERSPACE)),)
VPATH += $(QMK_USERSPACE)
endif
# If the equivalent users directory exists in userspace, use that in preference to anything currently in the main repo
ifneq ($(wildcard $(QMK_USERSPACE)/$(USER_PATH)),)
USER_PATH := $(QMK_USERSPACE)/$(USER_PATH)
endif
# Pull in user level rules.mk
-include $(USER_PATH)/rules.mk
ifneq ("$(wildcard $(USER_PATH)/config.h)","")
@ -404,6 +440,10 @@ ifneq ("$(KEYMAP_H)","")
CONFIG_H += $(KEYMAP_H)
endif
ifeq ($(KEYMAP_C),)
$(call CATASTROPHIC_ERROR,Invalid keymap,Could not find keymap)
endif
OPT_DEFS += -DKEYMAP_C=\"$(KEYMAP_C)\"
# If a keymap or userspace places their keymap array in another file instead, allow for it to be included


+ 4
- 0
builddefs/build_layout.mk View File

@ -1,6 +1,10 @@
LAYOUTS_PATH := layouts
LAYOUTS_REPOS := $(patsubst %/,%,$(sort $(dir $(wildcard $(LAYOUTS_PATH)/*/))))
ifneq ($(QMK_USERSPACE),)
LAYOUTS_REPOS += $(patsubst %/,%,$(QMK_USERSPACE)/$(LAYOUTS_PATH))
endif
define SEARCH_LAYOUTS_REPO
LAYOUT_KEYMAP_PATH := $$(LAYOUTS_REPO)/$$(LAYOUT)/$$(KEYMAP)
LAYOUT_KEYMAP_JSON := $$(LAYOUT_KEYMAP_PATH)/keymap.json


+ 10
- 1
builddefs/common_rules.mk View File

@ -191,7 +191,7 @@ DFU_SUFFIX_ARGS ?=
elf: $(BUILD_DIR)/$(TARGET).elf
hex: $(BUILD_DIR)/$(TARGET).hex
uf2: $(BUILD_DIR)/$(TARGET).uf2
cpfirmware: $(FIRMWARE_FORMAT)
cpfirmware_qmk: $(FIRMWARE_FORMAT)
$(SILENT) || printf "Copying $(TARGET).$(FIRMWARE_FORMAT) to qmk_firmware folder" | $(AWK_CMD)
$(COPY) $(BUILD_DIR)/$(TARGET).$(FIRMWARE_FORMAT) $(TARGET).$(FIRMWARE_FORMAT) && $(PRINT_OK)
eep: $(BUILD_DIR)/$(TARGET).eep
@ -200,6 +200,15 @@ sym: $(BUILD_DIR)/$(TARGET).sym
LIBNAME=lib$(TARGET).a
lib: $(LIBNAME)
cpfirmware: cpfirmware_qmk
ifneq ($(QMK_USERSPACE),)
cpfirmware: cpfirmware_userspace
cpfirmware_userspace: cpfirmware_qmk
$(SILENT) || printf "Copying $(TARGET).$(FIRMWARE_FORMAT) to userspace folder" | $(AWK_CMD)
$(COPY) $(BUILD_DIR)/$(TARGET).$(FIRMWARE_FORMAT) $(QMK_USERSPACE)/$(TARGET).$(FIRMWARE_FORMAT) && $(PRINT_OK)
endif
# Display size of file, modifying the output so people don't mistakenly grab the hex output
BINARY_SIZE = $(SIZE) --target=$(FORMAT) $(BUILD_DIR)/$(TARGET).hex | $(SED) -e 's/\.build\/.*$$/$(TARGET).$(FIRMWARE_FORMAT)/g'


+ 18
- 0
data/schemas/definitions.jsonschema View File

@ -177,5 +177,23 @@
"type": "integer",
"minimum": 0,
"maximum": 1
},
"keyboard_keymap_tuple": {
"type": "array",
"prefixItems": [
{ "$ref": "#/keyboard" },
{ "$ref": "#/filename" }
],
"unevaluatedItems": false
},
"json_file_path": {
"type": "string",
"pattern": "^[0-9a-z_/\\-]+\\.json$"
},
"build_target": {
"oneOf": [
{ "$ref": "#/keyboard_keymap_tuple" },
{ "$ref": "#/json_file_path" }
]
}
}

+ 14
- 0
data/schemas/user_repo_v0.jsonschema View File

@ -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",
},
}
}

+ 22
- 0
data/schemas/user_repo_v1.jsonschema View File

@ -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"
}
}
}
}

+ 1
- 1
docs/_summary.md View File

@ -4,7 +4,7 @@
* [Building Your First Firmware](newbs_building_firmware.md)
* [Flashing Firmware](newbs_flashing.md)
* [Getting Help/Support](support.md)
* [Building With GitHub Userspace](newbs_building_firmware_workflow.md)
* [External Userspace](newbs_external_userspace.md)
* [Other Resources](newbs_learn_more_resources.md)
* [Syllabus](syllabus.md)


+ 125
- 0
docs/cli_commands.md View File

@ -482,6 +482,131 @@ $ qmk import-kbfirmware ~/Downloads/gh62.json
---
# External Userspace Commands
## `qmk userspace-add`
This command adds a keyboard/keymap to the External Userspace build targets.
**Usage**:
```
qmk userspace-add [-h] [-km KEYMAP] [-kb KEYBOARD] [builds ...]
positional arguments:
builds List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.
options:
-h, --help show this help message and exit
-km KEYMAP, --keymap KEYMAP
The keymap to build a firmware for. Ignored when a configurator export is supplied.
-kb KEYBOARD, --keyboard KEYBOARD
The keyboard to build a firmware for. Ignored when a configurator export is supplied.
```
**Example**:
```
$ qmk userspace-add -kb planck/rev6 -km default
Ψ Added planck/rev6:default to userspace build targets
Ψ Saved userspace file to /home/you/qmk_userspace/qmk.json
```
## `qmk userspace-remove`
This command removes a keyboard/keymap from the External Userspace build targets.
**Usage**:
```
qmk userspace-remove [-h] [-km KEYMAP] [-kb KEYBOARD] [builds ...]
positional arguments:
builds List of builds in form <keyboard>:<keymap>, or path to a keymap JSON file.
options:
-h, --help show this help message and exit
-km KEYMAP, --keymap KEYMAP
The keymap to build a firmware for. Ignored when a configurator export is supplied.
-kb KEYBOARD, --keyboard KEYBOARD
The keyboard to build a firmware for. Ignored when a configurator export is supplied.
```
**Example**:
```
$ qmk userspace-remove -kb planck/rev6 -km default
Ψ Removed planck/rev6:default from userspace build targets
Ψ Saved userspace file to /home/you/qmk_userspace/qmk.json
```
## `qmk userspace-list`
This command lists the External Userspace build targets.
**Usage**:
```
qmk userspace-list [-h] [-e]
options:
-h, --help show this help message and exit
-e, --expand Expands any use of `all` for either keyboard or keymap.
```
**Example**:
```
$ qmk userspace-list
Ψ Current userspace build targets:
Ψ Keyboard: planck/rev6, keymap: you
Ψ Keyboard: clueboard/66/rev3, keymap: you
```
## `qmk userspace-compile`
This command compiles all the External Userspace build targets.
**Usage**:
```
qmk userspace-compile [-h] [-e ENV] [-n] [-c] [-j PARALLEL] [-t]
options:
-h, --help show this help message and exit
-e ENV, --env ENV Set a variable to be passed to make. May be passed multiple times.
-n, --dry-run Don't actually build, just show the commands to be run.
-c, --clean Remove object files before compiling.
-j PARALLEL, --parallel PARALLEL
Set the number of parallel make jobs; 0 means unlimited.
-t, --no-temp Remove temporary files during build.
```
**Example**:
```
$ qmk userspace-compile
Ψ Preparing target list...
Build planck/rev6:you [OK]
Build clueboard/66/rev3:you [OK]
```
## `qmk userspace-doctor`
This command examines your environment and alerts you to potential problems related to External Userspace.
**Example**:
```
% qmk userspace-doctor
Ψ QMK home: /home/you/qmk_userspace/qmk_firmware
Ψ Testing userspace candidate: /home/you/qmk_userspace -- Valid `qmk.json`
Ψ QMK userspace: /home/you/qmk_userspace
Ψ Userspace enabled: True
```
---
# Developer Commands
## `qmk format-text`


+ 96
- 0
docs/newbs_external_userspace.md View File

@ -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)

+ 16
- 0
lib/python/qmk/build_targets.py View File

@ -10,6 +10,8 @@ from qmk.constants import QMK_FIRMWARE, INTERMEDIATE_OUTPUT_PREFIX
from qmk.commands import find_make, get_make_parallel_args, parse_configurator_json
from qmk.keyboard import keyboard_folder
from qmk.info import keymap_json
from qmk.keymap import locate_keymap
from qmk.path import is_under_qmk_firmware, is_under_qmk_userspace
class BuildTarget:
@ -158,6 +160,20 @@ class KeyboardKeymapBuildTarget(BuildTarget):
for key, value in env_vars.items():
compile_args.append(f'{key}={value}')
# Need to override the keymap path if the keymap is a userspace directory.
# This also ensures keyboard aliases as per `keyboard_aliases.hjson` still work if the userspace has the keymap
# in an equivalent historical location.
keymap_location = locate_keymap(self.keyboard, self.keymap)
if is_under_qmk_userspace(keymap_location) and not is_under_qmk_firmware(keymap_location):
keymap_directory = keymap_location.parent
compile_args.extend([
f'MAIN_KEYMAP_PATH_1={keymap_directory}',
f'MAIN_KEYMAP_PATH_2={keymap_directory}',
f'MAIN_KEYMAP_PATH_3={keymap_directory}',
f'MAIN_KEYMAP_PATH_4={keymap_directory}',
f'MAIN_KEYMAP_PATH_5={keymap_directory}',
])
return compile_args


+ 5
- 0
lib/python/qmk/cli/__init__.py View File

@ -81,6 +81,11 @@ subcommands = [
'qmk.cli.new.keymap',
'qmk.cli.painter',
'qmk.cli.pytest',
'qmk.cli.userspace.add',
'qmk.cli.userspace.compile',
'qmk.cli.userspace.doctor',
'qmk.cli.userspace.list',
'qmk.cli.userspace.remove',
'qmk.cli.via2json',
]


+ 3
- 1
lib/python/qmk/cli/compile.py View File

@ -37,7 +37,9 @@ def compile(cli):
from .mass_compile import mass_compile
cli.args.builds = []
cli.args.filter = []
cli.args.no_temp = False
cli.config.mass_compile.keymap = cli.config.compile.keymap
cli.config.mass_compile.parallel = cli.config.compile.parallel
cli.config.mass_compile.no_temp = False
return mass_compile(cli)
# Build the environment vars


+ 24
- 1
lib/python/qmk/cli/doctor/main.py View File

@ -9,10 +9,11 @@ from milc import cli
from milc.questions import yesno
from qmk import submodules
from qmk.constants import QMK_FIRMWARE, QMK_FIRMWARE_UPSTREAM
from qmk.constants import QMK_FIRMWARE, QMK_FIRMWARE_UPSTREAM, QMK_USERSPACE, HAS_QMK_USERSPACE
from .check import CheckStatus, check_binaries, check_binary_versions, check_submodules
from qmk.git import git_check_repo, git_get_branch, git_get_tag, git_get_last_log_entry, git_get_common_ancestor, git_is_dirty, git_get_remotes, git_check_deviation
from qmk.commands import in_virtualenv
from qmk.userspace import qmk_userspace_paths, qmk_userspace_validate, UserspaceValidationError
def os_tests():
@ -92,6 +93,25 @@ def output_submodule_status():
cli.log.error(f'- {sub_name}: <<< missing or unknown >>>')
def userspace_tests(qmk_firmware):
if qmk_firmware:
cli.log.info(f'QMK home: {{fg_cyan}}{qmk_firmware}')
for path in qmk_userspace_paths():
try:
qmk_userspace_validate(path)
cli.log.info(f'Testing userspace candidate: {{fg_cyan}}{path}{{fg_reset}} -- {{fg_green}}Valid `qmk.json`')
except FileNotFoundError:
cli.log.warn(f'Testing userspace candidate: {{fg_cyan}}{path}{{fg_reset}} -- {{fg_red}}Missing `qmk.json`')
except UserspaceValidationError as err:
cli.log.warn(f'Testing userspace candidate: {{fg_cyan}}{path}{{fg_reset}} -- {{fg_red}}Invalid `qmk.json`')
cli.log.warn(f' -- {{fg_cyan}}{path}/qmk.json{{fg_reset}} validation error: {err}')
if QMK_USERSPACE is not None:
cli.log.info(f'QMK userspace: {{fg_cyan}}{QMK_USERSPACE}')
cli.log.info(f'Userspace enabled: {{fg_cyan}}{HAS_QMK_USERSPACE}')
@cli.argument('-y', '--yes', action='store_true', arg_only=True, help='Answer yes to all questions.')
@cli.argument('-n', '--no', action='store_true', arg_only=True, help='Answer no to all questions.')
@cli.subcommand('Basic QMK environment checks')
@ -108,6 +128,9 @@ def doctor(cli):
cli.log.info('QMK home: {fg_cyan}%s', QMK_FIRMWARE)
status = os_status = os_tests()
userspace_tests(None)
git_status = git_tests()
if git_status == CheckStatus.ERROR or (os_status == CheckStatus.OK and git_status == CheckStatus.WARNING):


+ 48
- 22
lib/python/qmk/cli/format/json.py View File

@ -9,48 +9,74 @@ from milc import cli
from qmk.info import info_json
from qmk.json_schema import json_load, validate
from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder
from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder, UserspaceJSONEncoder
from qmk.path import normpath
@cli.argument('json_file', arg_only=True, type=normpath, help='JSON file to format')
@cli.argument('-f', '--format', choices=['auto', 'keyboard', 'keymap'], default='auto', arg_only=True, help='JSON formatter to use (Default: autodetect)')
@cli.argument('-i', '--inplace', action='store_true', arg_only=True, help='If set, will operate in-place on the input file')
@cli.argument('-p', '--print', action='store_true', arg_only=True, help='If set, will print the formatted json to stdout ')
@cli.subcommand('Generate an info.json file for a keyboard.', hidden=False if cli.config.user.developer else True)
def format_json(cli):
"""Format a json file.
def _detect_json_format(file, json_data):
"""Detect the format of a json file.
"""
json_file = json_load(cli.args.json_file)
if cli.args.format == 'auto':
json_encoder = None
try:
validate(json_data, 'qmk.user_repo.v1')
json_encoder = UserspaceJSONEncoder
except ValidationError:
pass
if json_encoder is None:
try:
validate(json_file, 'qmk.keyboard.v1')
validate(json_data, 'qmk.keyboard.v1')
json_encoder = InfoJSONEncoder
except ValidationError as e:
cli.log.warning('File %s did not validate as a keyboard:\n\t%s', cli.args.json_file, e)
cli.log.info('Treating %s as a keymap file.', cli.args.json_file)
cli.log.warning('File %s did not validate as a keyboard info.json or userspace qmk.json:\n\t%s', file, e)
cli.log.info('Treating %s as a keymap file.', file)
json_encoder = KeymapJSONEncoder
return json_encoder
def _get_json_encoder(file, json_data):
"""Get the json encoder for a file.
"""
json_encoder = None
if cli.args.format == 'auto':
json_encoder = _detect_json_format(file, json_data)
elif cli.args.format == 'keyboard':
json_encoder = InfoJSONEncoder
elif cli.args.format == 'keymap':
json_encoder = KeymapJSONEncoder
elif cli.args.format == 'userspace':
json_encoder = UserspaceJSONEncoder
else:
# This should be impossible
cli.log.error('Unknown format: %s', cli.args.format)
return json_encoder
@cli.argument('json_file', arg_only=True, type=normpath, help='JSON file to format')
@cli.argument('-f', '--format', choices=['auto', 'keyboard', 'keymap', 'userspace'], default='auto', arg_only=True, help='JSON formatter to use (Default: autodetect)')
@cli.argument('-i', '--inplace', action='store_true', arg_only=True, help='If set, will operate in-place on the input file')
@cli.argument('-p', '--print', action='store_true', arg_only=True, help='If set, will print the formatted json to stdout ')
@cli.subcommand('Generate an info.json file for a keyboard.', hidden=False if cli.config.user.developer else True)
def format_json(cli):
"""Format a json file.
"""
json_data = json_load(cli.args.json_file)
json_encoder = _get_json_encoder(cli.args.json_file, json_data)
if json_encoder is None:
return False
if json_encoder == KeymapJSONEncoder and 'layout' in json_file:
if json_encoder == KeymapJSONEncoder and 'layout' in json_data:
# Attempt to format the keycodes.
layout = json_file['layout']
info_data = info_json(json_file['keyboard'])
layout = json_data['layout']
info_data = info_json(json_data['keyboard'])
if layout in info_data.get('layout_aliases', {}):
layout = json_file['layout'] = info_data['layout_aliases'][layout]
layout = json_data['layout'] = info_data['layout_aliases'][layout]
if layout in info_data.get('layouts'):
for layer_num, layer in enumerate(json_file['layers']):
for layer_num, layer in enumerate(json_data['layers']):
current_layer = []
last_row = 0
@ -61,9 +87,9 @@ def format_json(cli):
current_layer.append(keymap_key)
json_file['layers'][layer_num] = current_layer
json_data['layers'][layer_num] = current_layer
output = json.dumps(json_file, cls=json_encoder, sort_keys=True)
output = json.dumps(json_data, cls=json_encoder, sort_keys=True)
if cli.args.inplace:
with open(cli.args.json_file, 'w+', encoding='utf-8') as outfile:


+ 1
- 1
lib/python/qmk/cli/mass_compile.py View File

@ -72,7 +72,7 @@ all: {keyboard_safe}_{keymap_name}_binary
# yapf: enable
f.write('\n')
cli.run([make_cmd, *get_make_parallel_args(parallel), '-f', makefile.as_posix(), 'all'], capture_output=False, stdin=DEVNULL)
cli.run([find_make(), *get_make_parallel_args(parallel), '-f', makefile.as_posix(), 'all'], capture_output=False, stdin=DEVNULL)
# Check for failures
failures = [f for f in builddir.glob(f'failed.log.{os.getpid()}.*')]


+ 8
- 0
lib/python/qmk/cli/new/keymap.py View File

@ -5,10 +5,12 @@ import shutil
from milc import cli
from milc.questions import question
from qmk.constants import HAS_QMK_USERSPACE, QMK_USERSPACE
from qmk.path import is_keyboard, keymaps, keymap
from qmk.git import git_get_username
from qmk.decorators import automagic_keyboard, automagic_keymap
from qmk.keyboard import keyboard_completer, keyboard_folder
from qmk.userspace import UserspaceDefs
def prompt_keyboard():
@ -68,3 +70,9 @@ def new_keymap(cli):
# end message to user
cli.log.info(f'{{fg_green}}Created a new keymap called {{fg_cyan}}{user_name}{{fg_green}} in: {{fg_cyan}}{keymap_path_new}.{{fg_reset}}')
cli.log.info(f"Compile a firmware with your new keymap by typing: {{fg_yellow}}qmk compile -kb {kb_name} -km {user_name}{{fg_reset}}.")
# Add to userspace compile if we have userspace available
if HAS_QMK_USERSPACE:
userspace = UserspaceDefs(QMK_USERSPACE / 'qmk.json')
userspace.add_target(keyboard=kb_name, keymap=user_name, do_print=False)
return userspace.save()

+ 5
- 0
lib/python/qmk/cli/userspace/__init__.py View File

@ -0,0 +1,5 @@
from . import doctor
from . import add
from . import remove
from . import list
from . import compile

+ 51
- 0
lib/python/qmk/cli/userspace/add.py View File

@ -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()

+ 38
- 0
lib/python/qmk/cli/userspace/compile.py View File

@ -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))

+ 11
- 0
lib/python/qmk/cli/userspace/doctor.py View File

@ -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)

+ 51
- 0
lib/python/qmk/cli/userspace/list.py View File

@ -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!')

+ 37
- 0
lib/python/qmk/cli/userspace/remove.py View File

@ -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()

+ 6
- 0
lib/python/qmk/commands.py View File

@ -3,10 +3,12 @@
import os
import sys
import shutil
from pathlib import Path
from milc import cli
import jsonschema
from qmk.constants import QMK_USERSPACE, HAS_QMK_USERSPACE
from qmk.json_schema import json_load, validate
from qmk.keyboard import keyboard_alias_definitions
@ -75,6 +77,10 @@ def build_environment(args):
envs[key] = value
else:
cli.log.warning('Invalid environment variable: %s', env)
if HAS_QMK_USERSPACE:
envs['QMK_USERSPACE'] = Path(QMK_USERSPACE).resolve()
return envs


+ 8
- 0
lib/python/qmk/constants.py View File

@ -4,9 +4,17 @@ from os import environ
from datetime import date
from pathlib import Path
from qmk.userspace import detect_qmk_userspace
# The root of the qmk_firmware tree.
QMK_FIRMWARE = Path.cwd()
# The detected userspace tree
QMK_USERSPACE = detect_qmk_userspace()
# Whether or not we have a separate userspace directory
HAS_QMK_USERSPACE = True if QMK_USERSPACE is not None else False
# Upstream repo url
QMK_FIRMWARE_UPSTREAM = 'qmk/qmk_firmware'


+ 18
- 0
lib/python/qmk/json_encoders.py View File

@ -217,3 +217,21 @@ class KeymapJSONEncoder(QMKJSONEncoder):
return '50' + str(key)
return key
class UserspaceJSONEncoder(QMKJSONEncoder):
"""Custom encoder to make userspace qmk.json's a little nicer to work with.
"""
def sort_dict(self, item):
"""Sorts the hashes in a nice way.
"""
key = item[0]
if self.indentation_level == 1:
if key == 'userspace_version':
return '00userspace_version'
if key == 'build_targets':
return '01build_targets'
return key

+ 21
- 1
lib/python/qmk/keyboard.py View File

@ -78,13 +78,17 @@ def keyboard_alias_definitions():
def is_all_keyboards(keyboard):
"""Returns True if the keyboard is an AllKeyboards object.
"""
if isinstance(keyboard, str):
return (keyboard == 'all')
return isinstance(keyboard, AllKeyboards)
def find_keyboard_from_dir():
"""Returns a keyboard name based on the user's current directory.
"""
relative_cwd = qmk.path.under_qmk_firmware()
relative_cwd = qmk.path.under_qmk_userspace()
if not relative_cwd:
relative_cwd = qmk.path.under_qmk_firmware()
if relative_cwd and len(relative_cwd.parts) > 1 and relative_cwd.parts[0] == 'keyboards':
# Attempt to extract the keyboard name from the current directory
@ -133,6 +137,22 @@ def keyboard_folder(keyboard):
return keyboard
def keyboard_aliases(keyboard):
"""Returns the list of aliases for the supplied keyboard.
Includes the keyboard itself.
"""
aliases = json_load(Path('data/mappings/keyboard_aliases.hjson'))
if keyboard in aliases:
keyboard = aliases[keyboard].get('target', keyboard)
keyboards = set(filter(lambda k: aliases[k].get('target', '') == keyboard, aliases.keys()))
keyboards.add(keyboard)
keyboards = list(sorted(keyboards))
return keyboards
def keyboard_folder_or_all(keyboard):
"""Returns the actual keyboard folder.


+ 85
- 45
lib/python/qmk/keymap.py View File

@ -12,7 +12,8 @@ from pygments.token import Token
from pygments import lex
import qmk.path
from qmk.keyboard import find_keyboard_from_dir, keyboard_folder
from qmk.constants import QMK_FIRMWARE, QMK_USERSPACE, HAS_QMK_USERSPACE
from qmk.keyboard import find_keyboard_from_dir, keyboard_folder, keyboard_aliases
from qmk.errors import CppError
from qmk.info import info_json
@ -194,29 +195,38 @@ def _strip_any(keycode):
def find_keymap_from_dir(*args):
"""Returns `(keymap_name, source)` for the directory provided (or cwd if not specified).
"""
relative_path = qmk.path.under_qmk_firmware(*args)
def _impl_find_keymap_from_dir(relative_path):
if relative_path and len(relative_path.parts) > 1:
# If we're in `qmk_firmware/keyboards` and `keymaps` is in our path, try to find the keyboard name.
if relative_path.parts[0] == 'keyboards' and 'keymaps' in relative_path.parts:
current_path = Path('/'.join(relative_path.parts[1:])) # Strip 'keyboards' from the front
if relative_path and len(relative_path.parts) > 1:
# If we're in `qmk_firmware/keyboards` and `keymaps` is in our path, try to find the keyboard name.
if relative_path.parts[0] == 'keyboards' and 'keymaps' in relative_path.parts:
current_path = Path('/'.join(relative_path.parts[1:])) # Strip 'keyboards' from the front
if 'keymaps' in current_path.parts and current_path.name != 'keymaps':
while current_path.parent.name != 'keymaps':
current_path = current_path.parent
if 'keymaps' in current_path.parts and current_path.name != 'keymaps':
while current_path.parent.name != 'keymaps':
current_path = current_path.parent
return current_path.name, 'keymap_directory'
return current_path.name, 'keymap_directory'
# If we're in `qmk_firmware/layouts` guess the name from the community keymap they're in
elif relative_path.parts[0] == 'layouts' and is_keymap_dir(relative_path):
return relative_path.name, 'layouts_directory'
# If we're in `qmk_firmware/layouts` guess the name from the community keymap they're in
elif relative_path.parts[0] == 'layouts' and is_keymap_dir(relative_path):
return relative_path.name, 'layouts_directory'
# If we're in `qmk_firmware/users` guess the name from the userspace they're in
elif relative_path.parts[0] == 'users':
# Guess the keymap name based on which userspace they're in
return relative_path.parts[1], 'users_directory'
return None, None
# If we're in `qmk_firmware/users` guess the name from the userspace they're in
elif relative_path.parts[0] == 'users':
# Guess the keymap name based on which userspace they're in
return relative_path.parts[1], 'users_directory'
if HAS_QMK_USERSPACE:
name, source = _impl_find_keymap_from_dir(qmk.path.under_qmk_userspace(*args))
if name and source:
return name, source
return None, None
name, source = _impl_find_keymap_from_dir(qmk.path.under_qmk_firmware(*args))
if name and source:
return name, source
return (None, None)
def keymap_completer(prefix, action, parser, parsed_args):
@ -417,29 +427,45 @@ def locate_keymap(keyboard, keymap):
raise KeyError('Invalid keyboard: ' + repr(keyboard))
# Check the keyboard folder first, last match wins
checked_dirs = ''
keymap_path = ''
for dir in keyboard_folder(keyboard).split('/'):
if checked_dirs:
checked_dirs = '/'.join((checked_dirs, dir))
else:
checked_dirs = dir
search_dirs = [QMK_FIRMWARE]
keyboard_dirs = [keyboard_folder(keyboard)]
if HAS_QMK_USERSPACE:
# When we've got userspace, check there _last_ as we want them to override anything in the main repo.
search_dirs.append(QMK_USERSPACE)
# We also want to search for any aliases as QMK's folder structure may have changed, with an alias, but the user
# hasn't updated their keymap location yet.
keyboard_dirs.extend(keyboard_aliases(keyboard))
keyboard_dirs = list(set(keyboard_dirs))
for search_dir in search_dirs:
for keyboard_dir in keyboard_dirs:
checked_dirs = ''
for dir in keyboard_dir.split('/'):
if checked_dirs:
checked_dirs = '/'.join((checked_dirs, dir))
else:
checked_dirs = dir
keymap_dir = Path('keyboards') / checked_dirs / 'keymaps'
keymap_dir = Path(search_dir) / Path('keyboards') / checked_dirs / 'keymaps'
if (keymap_dir / keymap / 'keymap.c').exists():
keymap_path = keymap_dir / keymap / 'keymap.c'
if (keymap_dir / keymap / 'keymap.json').exists():
keymap_path = keymap_dir / keymap / 'keymap.json'
if (keymap_dir / keymap / 'keymap.c').exists():
keymap_path = keymap_dir / keymap / 'keymap.c'
if (keymap_dir / keymap / 'keymap.json').exists():
keymap_path = keymap_dir / keymap / 'keymap.json'
if keymap_path:
return keymap_path
if keymap_path:
return keymap_path
# Check community layouts as a fallback
info = info_json(keyboard)
for community_parent in Path('layouts').glob('*/'):
community_parents = list(Path('layouts').glob('*/'))
if HAS_QMK_USERSPACE and (Path(QMK_USERSPACE) / "layouts").exists():
community_parents.append(Path(QMK_USERSPACE) / "layouts")
for community_parent in community_parents:
for layout in info.get("community_layouts", []):
community_layout = community_parent / layout / keymap
if community_layout.exists():
@ -449,6 +475,16 @@ def locate_keymap(keyboard, keymap):
return community_layout / 'keymap.c'
def is_keymap_target(keyboard, keymap):
if keymap == 'all':
return True
if locate_keymap(keyboard, keymap):
return True
return False
def list_keymaps(keyboard, c=True, json=True, additional_files=None, fullpath=False):
"""List the available keymaps for a keyboard.
@ -473,26 +509,30 @@ def list_keymaps(keyboard, c=True, json=True, additional_files=None, fullpath=Fa
"""
names = set()
keyboards_dir = Path('keyboards')
kb_path = keyboards_dir / keyboard
# walk up the directory tree until keyboards_dir
# and collect all directories' name with keymap.c file in it
while kb_path != keyboards_dir:
keymaps_dir = kb_path / "keymaps"
if keymaps_dir.is_dir():
for keymap in keymaps_dir.iterdir():
if is_keymap_dir(keymap, c, json, additional_files):
keymap = keymap if fullpath else keymap.name
names.add(keymap)
for search_dir in [QMK_FIRMWARE, QMK_USERSPACE] if HAS_QMK_USERSPACE else [QMK_FIRMWARE]:
keyboards_dir = search_dir / Path('keyboards')
kb_path = keyboards_dir / keyboard
while kb_path != keyboards_dir:
keymaps_dir = kb_path / "keymaps"
if keymaps_dir.is_dir():
for keymap in keymaps_dir.iterdir():
if is_keymap_dir(keymap, c, json, additional_files):
keymap = keymap if fullpath else keymap.name
names.add(keymap)
kb_path = kb_path.parent
kb_path = kb_path.parent
# Check community layouts as a fallback
info = info_json(keyboard)
for community_parent in Path('layouts').glob('*/'):
community_parents = list(Path('layouts').glob('*/'))
if HAS_QMK_USERSPACE and (Path(QMK_USERSPACE) / "layouts").exists():
community_parents.append(Path(QMK_USERSPACE) / "layouts")
for community_parent in community_parents:
for layout in info.get("community_layouts", []):
cl_path = community_parent / layout
if cl_path.is_dir():


+ 55
- 4
lib/python/qmk/path.py View File

@ -5,7 +5,7 @@ import os
import argparse
from pathlib import Path
from qmk.constants import MAX_KEYBOARD_SUBFOLDERS, QMK_FIRMWARE
from qmk.constants import MAX_KEYBOARD_SUBFOLDERS, QMK_FIRMWARE, QMK_USERSPACE, HAS_QMK_USERSPACE
from qmk.errors import NoSuchKeyboardError
@ -28,6 +28,40 @@ def under_qmk_firmware(path=Path(os.environ['ORIG_CWD'])):
return None
def under_qmk_userspace(path=Path(os.environ['ORIG_CWD'])):
"""Returns a Path object representing the relative path under $QMK_USERSPACE, or None.
"""
try:
if HAS_QMK_USERSPACE:
return path.relative_to(QMK_USERSPACE)
except ValueError:
pass
return None
def is_under_qmk_firmware(path=Path(os.environ['ORIG_CWD'])):
"""Returns a boolean if the input path is a child under qmk_firmware.
"""
if path is None:
return False
try:
return Path(os.path.commonpath([Path(path), QMK_FIRMWARE])) == QMK_FIRMWARE
except ValueError:
return False
def is_under_qmk_userspace(path=Path(os.environ['ORIG_CWD'])):
"""Returns a boolean if the input path is a child under $QMK_USERSPACE.
"""
if path is None:
return False
try:
if HAS_QMK_USERSPACE:
return Path(os.path.commonpath([Path(path), QMK_USERSPACE])) == QMK_USERSPACE
except ValueError:
return False
def keyboard(keyboard_name):
"""Returns the path to a keyboard's directory relative to the qmk root.
"""
@ -45,11 +79,28 @@ def keymaps(keyboard_name):
keyboard_folder = keyboard(keyboard_name)
found_dirs = []
if HAS_QMK_USERSPACE:
this_keyboard_folder = Path(QMK_USERSPACE) / keyboard_folder
for _ in range(MAX_KEYBOARD_SUBFOLDERS):
if (this_keyboard_folder / 'keymaps').exists():
found_dirs.append((this_keyboard_folder / 'keymaps').resolve())
this_keyboard_folder = this_keyboard_folder.parent
if this_keyboard_folder.resolve() == QMK_USERSPACE.resolve():
break
# We don't have any relevant keymap directories in userspace, so we'll use the fully-qualified path instead.
if len(found_dirs) == 0:
found_dirs.append((QMK_USERSPACE / keyboard_folder / 'keymaps').resolve())
this_keyboard_folder = QMK_FIRMWARE / keyboard_folder
for _ in range(MAX_KEYBOARD_SUBFOLDERS):
if (keyboard_folder / 'keymaps').exists():
found_dirs.append((keyboard_folder / 'keymaps').resolve())
if (this_keyboard_folder / 'keymaps').exists():
found_dirs.append((this_keyboard_folder / 'keymaps').resolve())
keyboard_folder = keyboard_folder.parent
this_keyboard_folder = this_keyboard_folder.parent
if this_keyboard_folder.resolve() == QMK_FIRMWARE.resolve():
break
if len(found_dirs) > 0:
return found_dirs


+ 185
- 0
lib/python/qmk/userspace.py View File

@ -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}'

Loading…
Cancel
Save