qmk_firmware

QMK firmware for my keyboards (Corne, Sweep Ferris) and trackball (Ploopy Adept)
Log | Files | Refs | Submodules | LICENSE

userspace.py (8170B)


      1 # Copyright 2023-2024 Nick Brassel (@tzarc)
      2 # SPDX-License-Identifier: GPL-2.0-or-later
      3 from os import environ
      4 from pathlib import Path
      5 import json
      6 import jsonschema
      7 
      8 from milc import cli
      9 
     10 from qmk.json_schema import validate, json_load
     11 from qmk.json_encoders import UserspaceJSONEncoder
     12 
     13 
     14 def 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 
     41 def 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 
     51 def 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 
     65 class 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 
    202 class 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}'