userspace.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  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. 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[current_dir] = True
  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'])
  22. if current_dir.is_dir():
  23. test_dirs[current_dir] = True
  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)
  27. if current_dir.is_dir():
  28. test_dirs[current_dir] = True
  29. return list(test_dirs.keys())
  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. try:
  63. validate(json, 'qmk.user_repo.v1')
  64. self.__load_v1(json)
  65. success = True
  66. except jsonschema.ValidationError as err:
  67. exception.add('qmk.user_repo.v1', err)
  68. if not success:
  69. raise exception
  70. def save(self):
  71. target_json = {
  72. "userspace_version": "1.0", # Needs to match latest version
  73. "build_targets": []
  74. }
  75. for e in self.build_targets:
  76. if isinstance(e, dict):
  77. target_json['build_targets'].append([e['keyboard'], e['keymap']])
  78. elif isinstance(e, Path):
  79. target_json['build_targets'].append(str(e.relative_to(self.path.parent)))
  80. try:
  81. # Ensure what we're writing validates against the latest version of the schema
  82. validate(target_json, 'qmk.user_repo.v1')
  83. except jsonschema.ValidationError as err:
  84. cli.log.error(f'Could not save userspace file: {err}')
  85. return False
  86. # Only actually write out data if it changed
  87. old_data = json.dumps(json.loads(self.path.read_text()), cls=UserspaceJSONEncoder, sort_keys=True)
  88. new_data = json.dumps(target_json, cls=UserspaceJSONEncoder, sort_keys=True)
  89. if old_data != new_data:
  90. self.path.write_text(new_data)
  91. cli.log.info(f'Saved userspace file to {self.path}.')
  92. return True
  93. def add_target(self, keyboard=None, keymap=None, json_path=None, do_print=True):
  94. if json_path is not None:
  95. # Assume we're adding a json filename/path
  96. json_path = Path(json_path)
  97. if json_path not in self.build_targets:
  98. self.build_targets.append(json_path)
  99. if do_print:
  100. cli.log.info(f'Added {json_path} to userspace build targets.')
  101. else:
  102. cli.log.info(f'{json_path} is already a userspace build target.')
  103. elif keyboard is not None and keymap is not None:
  104. # Both keyboard/keymap specified
  105. e = {"keyboard": keyboard, "keymap": keymap}
  106. if e not in self.build_targets:
  107. self.build_targets.append(e)
  108. if do_print:
  109. cli.log.info(f'Added {keyboard}:{keymap} to userspace build targets.')
  110. else:
  111. if do_print:
  112. cli.log.info(f'{keyboard}:{keymap} is already a userspace build target.')
  113. def remove_target(self, keyboard=None, keymap=None, json_path=None, do_print=True):
  114. if json_path is not None:
  115. # Assume we're removing a json filename/path
  116. json_path = Path(json_path)
  117. if json_path in self.build_targets:
  118. self.build_targets.remove(json_path)
  119. if do_print:
  120. cli.log.info(f'Removed {json_path} from userspace build targets.')
  121. else:
  122. cli.log.info(f'{json_path} is not a userspace build target.')
  123. elif keyboard is not None and keymap is not None:
  124. # Both keyboard/keymap specified
  125. e = {"keyboard": keyboard, "keymap": keymap}
  126. if e in self.build_targets:
  127. self.build_targets.remove(e)
  128. if do_print:
  129. cli.log.info(f'Removed {keyboard}:{keymap} from userspace build targets.')
  130. else:
  131. if do_print:
  132. cli.log.info(f'{keyboard}:{keymap} is not a userspace build target.')
  133. def __load_v1(self, json):
  134. for e in json['build_targets']:
  135. if isinstance(e, list) and len(e) == 2:
  136. self.add_target(keyboard=e[0], keymap=e[1], do_print=False)
  137. if isinstance(e, str):
  138. p = self.path.parent / e
  139. if p.exists() and p.suffix == '.json':
  140. self.add_target(json_path=p, do_print=False)
  141. class UserspaceValidationError(Exception):
  142. def __init__(self, *args, **kwargs):
  143. super().__init__(*args, **kwargs)
  144. self.__exceptions = []
  145. def __str__(self):
  146. return self.message
  147. @property
  148. def exceptions(self):
  149. return self.__exceptions
  150. def add(self, schema, exception):
  151. self.__exceptions.append((schema, exception))
  152. errorlist = "\n\n".join([f"{schema}: {exception}" for schema, exception in self.__exceptions])
  153. self.message = f'Could not validate against any version of the userspace schema. Errors:\n\n{errorlist}'