at master 8.2 kB view raw
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}'