keyboard stuff
1# Copyright 2023-2024 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3from os import environ
4from pathlib import Path
5import json
6import jsonschema
7
8from milc import cli
9
10from qmk.json_schema import validate, json_load
11from qmk.json_encoders import UserspaceJSONEncoder
12
13
14def qmk_userspace_paths():
15 test_dirs = []
16
17 # If we're already in a directory with a qmk.json and a keyboards or layouts directory, interpret it as userspace
18 if environ.get('ORIG_CWD') is not None:
19 current_dir = Path(environ['ORIG_CWD'])
20 while len(current_dir.parts) > 1:
21 if (current_dir / 'qmk.json').is_file():
22 test_dirs.append(current_dir)
23 current_dir = current_dir.parent
24
25 # If we have a QMK_USERSPACE environment variable, use that
26 if environ.get('QMK_USERSPACE') is not None:
27 current_dir = Path(environ['QMK_USERSPACE']).expanduser()
28 if current_dir.is_dir():
29 test_dirs.append(current_dir)
30
31 # If someone has configured a directory, use that
32 if cli.config.user.overlay_dir is not None:
33 current_dir = Path(cli.config.user.overlay_dir).expanduser().resolve()
34 if current_dir.is_dir():
35 test_dirs.append(current_dir)
36
37 # remove duplicates while maintaining the current order
38 return list(dict.fromkeys(test_dirs))
39
40
41def qmk_userspace_validate(path):
42 # Construct a UserspaceDefs object to ensure it validates correctly
43 if (path / 'qmk.json').is_file():
44 UserspaceDefs(path / 'qmk.json')
45 return
46
47 # No qmk.json file found
48 raise FileNotFoundError('No qmk.json file found.')
49
50
51def detect_qmk_userspace():
52 # Iterate through all the detected userspace paths and return the first one that validates correctly
53 test_dirs = qmk_userspace_paths()
54 for test_dir in test_dirs:
55 try:
56 qmk_userspace_validate(test_dir)
57 return test_dir
58 except FileNotFoundError:
59 continue
60 except UserspaceValidationError:
61 continue
62 return None
63
64
65class UserspaceDefs:
66 def __init__(self, userspace_json: Path):
67 self.path = userspace_json
68 self.build_targets = []
69 json = json_load(userspace_json)
70
71 exception = UserspaceValidationError()
72 success = False
73
74 try:
75 validate(json, 'qmk.user_repo.v0') # `qmk.json` must have a userspace_version at minimum
76 except jsonschema.ValidationError as err:
77 exception.add('qmk.user_repo.v0', err)
78 raise exception
79
80 # Iterate through each version of the schema, starting with the latest and decreasing to v1
81 schema_versions = [
82 ('qmk.user_repo.v1_1', self.__load_v1_1), #
83 ('qmk.user_repo.v1', self.__load_v1) #
84 ]
85
86 for v in schema_versions:
87 schema = v[0]
88 loader = v[1]
89 try:
90 validate(json, schema)
91 loader(json)
92 success = True
93 break
94 except jsonschema.ValidationError as err:
95 exception.add(schema, err)
96
97 if not success:
98 raise exception
99
100 def save(self):
101 target_json = {
102 "userspace_version": "1.1", # Needs to match latest version
103 "build_targets": []
104 }
105
106 for e in self.build_targets:
107 if isinstance(e, dict):
108 entry = [e['keyboard'], e['keymap']]
109 if 'env' in e:
110 entry.append(e['env'])
111 target_json['build_targets'].append(entry)
112 elif isinstance(e, Path):
113 target_json['build_targets'].append(str(e.relative_to(self.path.parent)))
114
115 try:
116 # Ensure what we're writing validates against the latest version of the schema
117 validate(target_json, 'qmk.user_repo.v1_1')
118 except jsonschema.ValidationError as err:
119 cli.log.error(f'Could not save userspace file: {err}')
120 return False
121
122 # Only actually write out data if it changed
123 old_data = json.dumps(json.loads(self.path.read_text()), cls=UserspaceJSONEncoder, sort_keys=True)
124 new_data = json.dumps(target_json, cls=UserspaceJSONEncoder, sort_keys=True)
125 if old_data != new_data:
126 self.path.write_text(new_data)
127 cli.log.info(f'Saved userspace file to {self.path}.')
128 return True
129
130 def add_target(self, keyboard=None, keymap=None, build_env=None, json_path=None, do_print=True):
131 if json_path is not None:
132 # Assume we're adding a json filename/path
133 json_path = Path(json_path)
134 if json_path not in self.build_targets:
135 self.build_targets.append(json_path)
136 if do_print:
137 cli.log.info(f'Added {json_path} to userspace build targets.')
138 else:
139 cli.log.info(f'{json_path} is already a userspace build target.')
140
141 elif keyboard is not None and keymap is not None:
142 # Both keyboard/keymap specified
143 e = {"keyboard": keyboard, "keymap": keymap}
144 if build_env is not None:
145 e['env'] = build_env
146 if e not in self.build_targets:
147 self.build_targets.append(e)
148 if do_print:
149 cli.log.info(f'Added {keyboard}:{keymap} to userspace build targets.')
150 else:
151 if do_print:
152 cli.log.info(f'{keyboard}:{keymap} is already a userspace build target.')
153
154 def remove_target(self, keyboard=None, keymap=None, build_env=None, json_path=None, do_print=True):
155 if json_path is not None:
156 # Assume we're removing a json filename/path
157 json_path = Path(json_path)
158 if json_path in self.build_targets:
159 self.build_targets.remove(json_path)
160 if do_print:
161 cli.log.info(f'Removed {json_path} from userspace build targets.')
162 else:
163 cli.log.info(f'{json_path} is not a userspace build target.')
164
165 elif keyboard is not None and keymap is not None:
166 # Both keyboard/keymap specified
167 e = {"keyboard": keyboard, "keymap": keymap}
168 if build_env is not None:
169 e['env'] = build_env
170 if e in self.build_targets:
171 self.build_targets.remove(e)
172 if do_print:
173 cli.log.info(f'Removed {keyboard}:{keymap} from userspace build targets.')
174 else:
175 if do_print:
176 cli.log.info(f'{keyboard}:{keymap} is not a userspace build target.')
177
178 def __load_v1(self, json):
179 for e in json['build_targets']:
180 self.__load_v1_target(e)
181
182 def __load_v1_1(self, json):
183 for e in json['build_targets']:
184 self.__load_v1_1_target(e)
185
186 def __load_v1_target(self, e):
187 if isinstance(e, list) and len(e) == 2:
188 self.add_target(keyboard=e[0], keymap=e[1], do_print=False)
189 if isinstance(e, str):
190 p = self.path.parent / e
191 if p.exists() and p.suffix == '.json':
192 self.add_target(json_path=p, do_print=False)
193
194 def __load_v1_1_target(self, e):
195 # v1.1 adds support for a third item in the build target tuple; kvp's for environment
196 if isinstance(e, list) and len(e) == 3:
197 self.add_target(keyboard=e[0], keymap=e[1], build_env=e[2], do_print=False)
198 else:
199 self.__load_v1_target(e)
200
201
202class UserspaceValidationError(Exception):
203 def __init__(self, *args, **kwargs):
204 super().__init__(*args, **kwargs)
205 self.__exceptions = []
206
207 def __str__(self):
208 return self.message
209
210 @property
211 def exceptions(self):
212 return self.__exceptions
213
214 def add(self, schema, exception):
215 self.__exceptions.append((schema, exception))
216 errorlist = "\n\n".join([f"{schema}: {exception}" for schema, exception in self.__exceptions])
217 self.message = f'Could not validate against any version of the userspace schema. Errors:\n\n{errorlist}'