userspace.py 6.8 KB

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