# Copyright 2023-2024 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 = set() # If we're already in a directory with a qmk.json and a keyboards or layouts directory, interpret it as userspace if environ.get('ORIG_CWD') is not None: current_dir = Path(environ['ORIG_CWD']) while len(current_dir.parts) > 1: if (current_dir / 'qmk.json').is_file(): test_dirs.add(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['QMK_USERSPACE']).expanduser() if current_dir.is_dir(): test_dirs.add(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).expanduser().resolve() if current_dir.is_dir(): test_dirs.add(current_dir) return list(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 schema_versions = [ ('qmk.user_repo.v1_1', self.__load_v1_1), # ('qmk.user_repo.v1', self.__load_v1) # ] for v in schema_versions: schema = v[0] loader = v[1] try: validate(json, schema) loader(json) success = True break except jsonschema.ValidationError as err: exception.add(schema, err) if not success: raise exception def save(self): target_json = { "userspace_version": "1.1", # Needs to match latest version "build_targets": [] } for e in self.build_targets: if isinstance(e, dict): entry = [e['keyboard'], e['keymap']] if 'env' in e: entry.append(e['env']) target_json['build_targets'].append(entry) 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_1') 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, build_env=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 build_env is not None: e['env'] = build_env 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, build_env=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 build_env is not None: e['env'] = build_env 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']: self.__load_v1_target(e) def __load_v1_1(self, json): for e in json['build_targets']: self.__load_v1_1_target(e) def __load_v1_target(self, e): 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) def __load_v1_1_target(self, e): # v1.1 adds support for a third item in the build target tuple; kvp's for environment if isinstance(e, list) and len(e) == 3: self.add_target(keyboard=e[0], keymap=e[1], build_env=e[2], do_print=False) else: self.__load_v1_target(e) 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}'