schema.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. #!/usr/bin/env python3
  2. #
  3. # schema.py
  4. #
  5. # Used by signature.py via common-dependencies.py to generate a schema file during the PlatformIO build
  6. # when CONFIG_EXPORT is defined in the configuration.
  7. #
  8. # This script can also be run standalone from within the Marlin repo to generate JSON and YAML schema files.
  9. #
  10. # This script is a companion to abm/js/schema.js in the MarlinFirmware/AutoBuildMarlin project, which has
  11. # been extended to evaluate conditions and can determine what options are actually enabled, not just which
  12. # options are uncommented. That will be migrated to this script for standalone migration.
  13. #
  14. import re, json
  15. from pathlib import Path
  16. def extend_dict(d:dict, k:tuple):
  17. if len(k) >= 1 and k[0] not in d:
  18. d[k[0]] = {}
  19. if len(k) >= 2 and k[1] not in d[k[0]]:
  20. d[k[0]][k[1]] = {}
  21. if len(k) >= 3 and k[2] not in d[k[0]][k[1]]:
  22. d[k[0]][k[1]][k[2]] = {}
  23. grouping_patterns = [
  24. re.compile(r'^([XYZIJKUVW]|[XYZ]2|Z[34]|E[0-7])$'),
  25. re.compile(r'^AXIS\d$'),
  26. re.compile(r'^(MIN|MAX)$'),
  27. re.compile(r'^[0-8]$'),
  28. re.compile(r'^HOTEND[0-7]$'),
  29. re.compile(r'^(HOTENDS|BED|PROBE|COOLER)$'),
  30. re.compile(r'^[XYZIJKUVW]M(IN|AX)$')
  31. ]
  32. # If the indexed part of the option name matches a pattern
  33. # then add it to the dictionary.
  34. def find_grouping(gdict, filekey, sectkey, optkey, pindex):
  35. optparts = optkey.split('_')
  36. if 1 < len(optparts) > pindex:
  37. for patt in grouping_patterns:
  38. if patt.match(optparts[pindex]):
  39. subkey = optparts[pindex]
  40. modkey = '_'.join(optparts)
  41. optparts[pindex] = '*'
  42. wildkey = '_'.join(optparts)
  43. kkey = f'{filekey}|{sectkey}|{wildkey}'
  44. if kkey not in gdict: gdict[kkey] = []
  45. gdict[kkey].append((subkey, modkey))
  46. # Build a list of potential groups. Only those with multiple items will be grouped.
  47. def group_options(schema):
  48. for pindex in range(10, -1, -1):
  49. found_groups = {}
  50. for filekey, f in schema.items():
  51. for sectkey, s in f.items():
  52. for optkey in s:
  53. find_grouping(found_groups, filekey, sectkey, optkey, pindex)
  54. fkeys = [ k for k in found_groups.keys() ]
  55. for kkey in fkeys:
  56. items = found_groups[kkey]
  57. if len(items) > 1:
  58. f, s, w = kkey.split('|')
  59. extend_dict(schema, (f, s, w)) # Add wildcard group to schema
  60. for subkey, optkey in items: # Add all items to wildcard group
  61. schema[f][s][w][subkey] = schema[f][s][optkey] # Move non-wildcard item to wildcard group
  62. del schema[f][s][optkey]
  63. del found_groups[kkey]
  64. # Extract all board names from boards.h
  65. def load_boards():
  66. bpath = Path("Marlin/src/core/boards.h")
  67. if bpath.is_file():
  68. with bpath.open() as bfile:
  69. boards = []
  70. for line in bfile:
  71. if line.startswith("#define BOARD_"):
  72. bname = line.split()[1]
  73. if bname != "BOARD_UNKNOWN": boards.append(bname)
  74. return "['" + "','".join(boards) + "']"
  75. return ''
  76. #
  77. # Extract the specified configuration files in the form of a structured schema.
  78. # Contains the full schema for the configuration files, not just the enabled options,
  79. # Contains the current values of the options, not just data structure, so "schema" is a slight misnomer.
  80. #
  81. # The returned object is a nested dictionary with the following indexing:
  82. #
  83. # - schema[filekey][section][define_name] = define_info
  84. #
  85. # Where the define_info contains the following keyed fields:
  86. # - section = The @section the define is in
  87. # - name = The name of the define
  88. # - enabled = True if the define is enabled (not commented out)
  89. # - line = The line number of the define
  90. # - sid = A serial ID for the define
  91. # - value = The value of the define, if it has one
  92. # - type = The type of the define, if it has one
  93. # - requires = The conditions that must be met for the define to be enabled
  94. # - comment = The comment for the define, if it has one
  95. # - units = The units for the define, if it has one
  96. # - options = The options for the define, if it has any
  97. #
  98. def extract_files(filekey):
  99. # Load board names from boards.h
  100. boards = load_boards()
  101. # Parsing states
  102. class Parse:
  103. NORMAL = 0 # No condition yet
  104. BLOCK_COMMENT = 1 # Looking for the end of the block comment
  105. EOL_COMMENT = 2 # EOL comment started, maybe add the next comment?
  106. SLASH_COMMENT = 3 # Block-like comment, starting with aligned //
  107. GET_SENSORS = 4 # Gathering temperature sensor options
  108. ERROR = 9 # Syntax error
  109. # A JSON object to store the data
  110. sch_out = { key:{} for key in filekey.values() }
  111. # Regex for #define NAME [VALUE] [COMMENT] with sanitized line
  112. defgrep = re.compile(r'^(//)?\s*(#define)\s+([A-Za-z0-9_]+)\s*(.*?)\s*(//.+)?$')
  113. # Pattern to match a float value
  114. flt = r'[-+]?\s*(\d+\.|\d*\.\d+)([eE][-+]?\d+)?[fF]?'
  115. # Start with unknown state
  116. state = Parse.NORMAL
  117. # Serial ID
  118. sid = 0
  119. # Loop through files and parse them line by line
  120. for fn, fk in filekey.items():
  121. with Path("Marlin", fn).open(encoding='utf-8') as fileobj:
  122. section = 'none' # Current Settings section
  123. line_number = 0 # Counter for the line number of the file
  124. conditions = [] # Create a condition stack for the current file
  125. comment_buff = [] # A temporary buffer for comments
  126. prev_comment = '' # Copy before reset for an EOL comment
  127. options_json = '' # A buffer for the most recent options JSON found
  128. eol_options = False # The options came from end of line, so only apply once
  129. join_line = False # A flag that the line should be joined with the previous one
  130. line = '' # A line buffer to handle \ continuation
  131. last_added_ref = {} # Reference to the last added item
  132. # Loop through the lines in the file
  133. for the_line in fileobj.readlines():
  134. line_number += 1
  135. # Clean the line for easier parsing
  136. the_line = the_line.strip()
  137. if join_line: # A previous line is being made longer
  138. line += (' ' if line else '') + the_line
  139. else: # Otherwise, start the line anew
  140. line, line_start = the_line, line_number
  141. # If the resulting line ends with a \, don't process now.
  142. # Strip the end off. The next line will be joined with it.
  143. join_line = line.endswith("\\")
  144. if join_line:
  145. line = line[:-1].strip()
  146. continue
  147. else:
  148. line_end = line_number
  149. defmatch = defgrep.match(line)
  150. # Special handling for EOL comments after a #define.
  151. # At this point the #define is already digested and inserted,
  152. # so we have to extend it
  153. if state == Parse.EOL_COMMENT:
  154. # If the line is not a comment, we're done with the EOL comment
  155. if not defmatch and the_line.startswith('//'):
  156. comment_buff.append(the_line[2:].strip())
  157. else:
  158. state = Parse.NORMAL
  159. cline = ' '.join(comment_buff)
  160. comment_buff = []
  161. if cline != '':
  162. # A (block or slash) comment was already added
  163. cfield = 'notes' if 'comment' in last_added_ref else 'comment'
  164. last_added_ref[cfield] = cline
  165. #
  166. # Add the given comment line to the comment buffer, unless:
  167. # - The line starts with ':' and JSON values to assign to 'opt'.
  168. # - The line starts with '@section' so a new section needs to be returned.
  169. # - The line starts with '======' so just skip it.
  170. #
  171. def use_comment(c, opt, sec, bufref):
  172. '''
  173. c - The comment line to parse
  174. opt - Options JSON string to return (if not updated)
  175. sec - Section to return (if not updated)
  176. bufref - The comment buffer to add to
  177. '''
  178. sc = c.strip() # Strip for special patterns
  179. if sc.startswith(':'): # If the comment starts with : then it has magic JSON
  180. d = sc[1:].strip() # Strip the leading : and spaces
  181. # Look for a JSON container
  182. cbr = sc.rindex('}') if d.startswith('{') else sc.rindex(']') if d.startswith('[') else 0
  183. if cbr:
  184. opt, cmt = sc[1:cbr+1].strip(), sc[cbr+1:].strip()
  185. if cmt != '': bufref.append(cmt)
  186. else:
  187. opt = sc[1:].strip() # Some literal value not in a JSON container?
  188. else:
  189. m = re.match(r'@section\s*(.+)', sc) # Start a new section?
  190. if m:
  191. sec = m[1]
  192. elif not sc.startswith('========'):
  193. bufref.append(c) # Anything else is part of the comment
  194. return opt, sec
  195. # For slash comments, capture consecutive slash comments.
  196. # The comment will be applied to the next #define.
  197. if state == Parse.SLASH_COMMENT:
  198. if not defmatch and the_line.startswith('//'):
  199. options_json, section = use_comment(the_line[2:].strip(), options_json, section, comment_buff)
  200. continue
  201. else:
  202. state = Parse.NORMAL
  203. # In a block comment, capture lines up to the end of the comment.
  204. # Assume nothing follows the comment closure.
  205. if state in (Parse.BLOCK_COMMENT, Parse.GET_SENSORS):
  206. endpos = line.find('*/')
  207. if endpos < 0:
  208. cline = line
  209. else:
  210. cline, line = line[:endpos].strip(), line[endpos+2:].strip()
  211. # Temperature sensors are done
  212. if state == Parse.GET_SENSORS:
  213. options_json = f'[ {options_json[:-2]} ]'
  214. state = Parse.NORMAL
  215. # Strip the leading '* ' from block comments
  216. cline = re.sub(r'^\* ?', '', cline)
  217. # Collect temperature sensors
  218. if state == Parse.GET_SENSORS:
  219. sens = re.match(r'^\s*(-?\d+)\s*:\s*(.+)$', cline)
  220. if sens:
  221. s2 = sens[2].replace("'", "''")
  222. options_json += f"{sens[1]}:'{sens[1]} - {s2}', "
  223. elif state == Parse.BLOCK_COMMENT:
  224. # Look for temperature sensors
  225. if re.match(r'temperature sensors.*:', cline, re.IGNORECASE):
  226. state, cline = Parse.GET_SENSORS, "Temperature Sensors"
  227. options_json, section = use_comment(cline, options_json, section, comment_buff)
  228. # For the normal state we're looking for any non-blank line
  229. elif state == Parse.NORMAL:
  230. # Skip a commented define when evaluating comment opening
  231. st = 2 if re.match(r'^//\s*#define', line) else 0
  232. cpos1 = line.find('/*') # Start a block comment on the line?
  233. cpos2 = line.find('//', st) # Start an end of line comment on the line?
  234. # Only the first comment starter gets evaluated
  235. cpos = -1
  236. if cpos1 != -1 and (cpos1 < cpos2 or cpos2 == -1):
  237. cpos = cpos1
  238. comment_buff = []
  239. state = Parse.BLOCK_COMMENT
  240. eol_options = False
  241. elif cpos2 != -1 and (cpos2 < cpos1 or cpos1 == -1):
  242. cpos = cpos2
  243. # Comment after a define may be continued on the following lines
  244. if defmatch is not None and cpos > 10:
  245. state = Parse.EOL_COMMENT
  246. prev_comment = '\n'.join(comment_buff)
  247. comment_buff = []
  248. else:
  249. state = Parse.SLASH_COMMENT
  250. # Process the start of a new comment
  251. if cpos != -1:
  252. comment_buff = []
  253. cline, line = line[cpos+2:].strip(), line[:cpos].strip()
  254. if state == Parse.BLOCK_COMMENT:
  255. # Strip leading '*' from block comments
  256. cline = re.sub(r'^\* ?', '', cline)
  257. else:
  258. # Expire end-of-line options after first use
  259. if cline.startswith(':'): eol_options = True
  260. # Buffer a non-empty comment start
  261. if cline != '':
  262. options_json, section = use_comment(cline, options_json, section, comment_buff)
  263. # If the line has nothing before the comment, go to the next line
  264. if line == '':
  265. options_json = ''
  266. continue
  267. # Parenthesize the given expression if needed
  268. def atomize(s):
  269. if s == '' \
  270. or re.match(r'^[A-Za-z0-9_]*(\([^)]+\))?$', s) \
  271. or re.match(r'^[A-Za-z0-9_]+ == \d+?$', s):
  272. return s
  273. return f'({s})'
  274. #
  275. # The conditions stack is an array containing condition-arrays.
  276. # Each condition-array lists the conditions for the current block.
  277. # IF/N/DEF adds a new condition-array to the stack.
  278. # ELSE/ELIF/ENDIF pop the condition-array.
  279. # ELSE/ELIF negate the last item in the popped condition-array.
  280. # ELIF adds a new condition to the end of the array.
  281. # ELSE/ELIF re-push the condition-array.
  282. #
  283. cparts = line.split()
  284. iselif, iselse = cparts[0] == '#elif', cparts[0] == '#else'
  285. if iselif or iselse or cparts[0] == '#endif':
  286. if len(conditions) == 0:
  287. raise Exception(f'no #if block at line {line_number}')
  288. # Pop the last condition-array from the stack
  289. prev = conditions.pop()
  290. if iselif or iselse:
  291. prev[-1] = '!' + prev[-1] # Invert the last condition
  292. if iselif: prev.append(atomize(line[5:].strip()))
  293. conditions.append(prev)
  294. elif cparts[0] == '#if':
  295. conditions.append([ atomize(line[3:].strip()) ])
  296. elif cparts[0] == '#ifdef':
  297. conditions.append([ f'defined({line[6:].strip()})' ])
  298. elif cparts[0] == '#ifndef':
  299. conditions.append([ f'!defined({line[7:].strip()})' ])
  300. # Handle a complete #define line
  301. elif defmatch is not None:
  302. # Get the match groups into vars
  303. enabled, define_name, val = defmatch[1] is None, defmatch[3], defmatch[4]
  304. # Increment the serial ID
  305. sid += 1
  306. # Create a new dictionary for the current #define
  307. define_info = {
  308. 'section': section,
  309. 'name': define_name,
  310. 'enabled': enabled,
  311. 'line': line_start,
  312. 'sid': sid
  313. }
  314. # Type is based on the value
  315. value_type = \
  316. 'switch' if val == '' \
  317. else 'int' if re.match(r'^[-+]?\s*\d+$', val) \
  318. else 'ints' if re.match(r'^([-+]?\s*\d+)(\s*,\s*[-+]?\s*\d+)+$', val) \
  319. else 'floats' if re.match(rf'({flt}(\s*,\s*{flt})+)', val) \
  320. else 'float' if re.match(f'^({flt})$', val) \
  321. else 'string' if val[0] == '"' \
  322. else 'char' if val[0] == "'" \
  323. else 'bool' if val in ('true', 'false') \
  324. else 'state' if val in ('HIGH', 'LOW') \
  325. else 'enum' if re.match(r'^[A-Za-z0-9_]{3,}$', val) \
  326. else 'int[]' if re.match(r'^{\s*[-+]?\s*\d+(\s*,\s*[-+]?\s*\d+)*\s*}$', val) \
  327. else 'float[]' if re.match(r'^{{\s*{flt}(\s*,\s*{flt})*\s*}}$', val) \
  328. else 'array' if val[0] == '{' \
  329. else ''
  330. val = (val == 'true') if value_type == 'bool' \
  331. else int(val) if value_type == 'int' \
  332. else val.replace('f','') if value_type == 'floats' \
  333. else float(val.replace('f','')) if value_type == 'float' \
  334. else val
  335. if val != '': define_info['value'] = val
  336. if value_type != '': define_info['type'] = value_type
  337. # Join up accumulated conditions with &&
  338. if conditions: define_info['requires'] = '(' + ') && ('.join(sum(conditions, [])) + ')'
  339. # If the comment_buff is not empty, add the comment to the info
  340. if comment_buff:
  341. full_comment = '\n'.join(comment_buff).strip()
  342. # An EOL comment will be added later
  343. # The handling could go here instead of above
  344. if state == Parse.EOL_COMMENT:
  345. define_info['comment'] = ''
  346. else:
  347. define_info['comment'] = full_comment
  348. comment_buff = []
  349. # If the comment specifies units, add that to the info
  350. units = re.match(r'^\(([^)]+)\)', full_comment)
  351. if units:
  352. units = units[1]
  353. if units in ('s', 'sec'): units = 'seconds'
  354. define_info['units'] = units
  355. if 'comment' not in define_info or define_info['comment'] == '':
  356. if prev_comment:
  357. define_info['comment'] = prev_comment
  358. prev_comment = ''
  359. if 'comment' in define_info and define_info['comment'] == '':
  360. del define_info['comment']
  361. # Set the options for the current #define
  362. if define_name == "MOTHERBOARD" and boards != '':
  363. define_info['options'] = boards
  364. elif options_json != '':
  365. define_info['options'] = options_json
  366. if eol_options: options_json = ''
  367. # Create section dict if it doesn't exist yet
  368. if section not in sch_out[fk]: sch_out[fk][section] = {}
  369. # If define has already been seen...
  370. if define_name in sch_out[fk][section]:
  371. info = sch_out[fk][section][define_name]
  372. if isinstance(info, dict): info = [ info ] # Convert a single dict into a list
  373. info.append(define_info) # Add to the list
  374. else:
  375. # Add the define dict with name as key
  376. sch_out[fk][section][define_name] = define_info
  377. if state == Parse.EOL_COMMENT:
  378. last_added_ref = define_info
  379. return sch_out
  380. #
  381. # Extract the current configuration files in the form of a structured schema.
  382. #
  383. def extract():
  384. # List of files to process, with shorthand
  385. return extract_files({ 'Configuration.h':'basic', 'Configuration_adv.h':'advanced' })
  386. def dump_json(schema:dict, jpath:Path):
  387. with jpath.open('w', encoding='utf-8') as jfile:
  388. json.dump(schema, jfile, ensure_ascii=False, indent=2)
  389. def dump_yaml(schema:dict, ypath:Path):
  390. import yaml
  391. # Custom representer for all multi-line strings
  392. def str_literal_representer(dumper, data):
  393. if '\n' in data: # Check for multi-line strings
  394. # Add a newline to trigger '|+'
  395. if not data.endswith('\n'): data += '\n'
  396. return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
  397. return dumper.represent_scalar('tag:yaml.org,2002:str', data)
  398. yaml.add_representer(str, str_literal_representer)
  399. with ypath.open('w', encoding='utf-8') as yfile:
  400. yaml.dump(schema, yfile, default_flow_style=False, width=120, indent=2)
  401. def main():
  402. try:
  403. schema = extract()
  404. except Exception as exc:
  405. print("Error: " + str(exc))
  406. schema = None
  407. if schema:
  408. # Get the command line arguments after the script name
  409. import sys
  410. args = sys.argv[1:]
  411. if len(args) == 0: args = ['some']
  412. # Does the given array intersect at all with args?
  413. def inargs(c): return len(set(args) & set(c)) > 0
  414. # Help / Unknown option
  415. unk = not inargs(['some','json','jsons','group','yml','yaml'])
  416. if (unk): print(f"Unknown option: '{args[0]}'")
  417. if inargs(['-h', '--help']) or unk:
  418. print("Usage: schema.py [some|json|jsons|group|yml|yaml]...")
  419. print(" some = json + yml")
  420. print(" jsons = json + group")
  421. return
  422. # JSON schema
  423. if inargs(['some', 'json', 'jsons']):
  424. print("Generating JSON ...")
  425. dump_json(schema, Path('schema.json'))
  426. # JSON schema (wildcard names)
  427. if inargs(['group', 'jsons']):
  428. group_options(schema)
  429. dump_json(schema, Path('schema_grouped.json'))
  430. # YAML
  431. if inargs(['some', 'yml', 'yaml']):
  432. try:
  433. import yaml
  434. except ImportError:
  435. print("Installing YAML module ...")
  436. import subprocess
  437. try:
  438. subprocess.run(['python3', '-m', 'pip', 'install', 'pyyaml'])
  439. import yaml
  440. except:
  441. print("Failed to install YAML module")
  442. return
  443. print("Generating YML ...")
  444. dump_yaml(schema, Path('schema.yml'))
  445. if __name__ == '__main__':
  446. main()