userspace.py 7.9 KB

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