Coverage for gws-app/gws/spec/generator/configref.py: 92%

175 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Generate the configuration reference in Markdown.""" 

2 

3import re 

4import json 

5 

6from . import base 

7 

8STRINGS = {} 

9 

10STRINGS['en'] = { 

11 'head_property': 'property', 

12 'head_variant': 'one of the following objects:', 

13 'head_type': 'type', 

14 'head_default': 'default', 

15 'head_value': 'value', 

16 'head_member': 'class', 

17 'category_variant': 'variant', 

18 'category_object': 'obj', 

19 'category_enum': 'enum', 

20 'category_type': 'type', 

21 'label_added': 'added', 

22 'label_deprecated': 'deprecated', 

23 'label_changed': 'changed', 

24} 

25 

26STRINGS['de'] = { 

27 'head_property': 'Eigenschaft', 

28 'head_variant': 'Eines der folgenden Objekte:', 

29 'head_type': 'Typ', 

30 'head_default': 'Default', 

31 'head_value': 'Wert', 

32 'head_member': 'Objekt', 

33 'category_variant': 'variant', 

34 'category_object': 'obj', 

35 'category_enum': 'enum', 

36 'category_type': 'type', 

37 'label_added': 'neu', 

38 'label_deprecated': 'veraltet', 

39 'label_changed': 'geändert', 

40} 

41 

42LIST_FORMAT = '<nobr>{}**[ ]**</nobr>' 

43DEFAULT_FORMAT = ' _{}:_ {}.' 

44 

45LABELS = 'added|deprecated|changed' 

46 

47 

48def create(gen: base.Generator, lang: str): 

49 """Create the configuration reference. 

50 

51 The reference starts with the application ``Config`` and contains a 

52 section for each reachable class, type alias, enum and variant. 

53 

54 Args: 

55 gen: Generator state, with strings already collected. 

56 lang: Language code, ``en`` or ``de``. 

57 

58 Returns: 

59 The reference as Markdown text. 

60 """ 

61 

62 return _Creator(gen, lang).run() 

63 

64 

65## 

66 

67 

68class _Creator: 

69 """Builds the reference by walking the types from the application config.""" 

70 

71 start_tid = 'gws.base.application.core.Config' 

72 exclude_props = ['uid', 'access', 'type'] 

73 

74 def __init__(self, gen: base.Generator, lang: str): 

75 self.gen = gen 

76 self.lang = lang 

77 self.strings = STRINGS[lang] 

78 self.queue = [] 

79 self.blocks = [] 

80 

81 def run(self): 

82 """Create the reference. 

83 

84 Returns: 

85 The Markdown text, with sections sorted by kind and uid. 

86 """ 

87 

88 self.queue = [self.start_tid] 

89 self.blocks = [] 

90 done = set() 

91 

92 while self.queue: 

93 tid = self.queue.pop(0) 

94 if tid in done: 

95 continue 

96 done.add(tid) 

97 self.process(tid) 

98 

99 return nl(b[-1] for b in sorted(self.blocks)) 

100 

101 def process(self, tid): 

102 """Create the section for a type and enqueue the types it refers to. 

103 

104 Args: 

105 tid: Type uid. 

106 """ 

107 

108 typ = self.gen.require_type(tid) 

109 

110 if typ.c == base.c.CLASS: 

111 key = 0 if tid == self.start_tid else 1 

112 self.blocks.append([key, tid.lower(), nl(self.process_class(tid))]) 

113 

114 if typ.c == base.c.TYPE: 

115 self.blocks.append([2, tid.lower(), nl(self.process_type(tid))]) 

116 

117 if typ.c == base.c.ENUM: 

118 self.blocks.append([3, tid.lower(), nl(self.process_enum(tid))]) 

119 

120 if typ.c == base.c.VARIANT: 

121 self.blocks.append([4, tid.lower(), nl(self.process_variant(tid))]) 

122 

123 if typ.c == base.c.LIST: 

124 self.queue.append(typ.tItem) 

125 

126 def process_class(self, tid): 

127 """Create the section for a class, with a table of its properties. 

128 

129 Args: 

130 tid: Type uid. 

131 

132 Yields: 

133 Markdown blocks. 

134 """ 

135 

136 typ = self.gen.require_type(tid) 

137 

138 yield header('object', tid) 

139 yield subhead(self.strings['category_object'], self.docstring_as_header(tid)) 

140 

141 rows = {False: [], True: []} 

142 

143 for prop_name, prop_tid in sorted(typ.tProperties.items()): 

144 if prop_name in self.exclude_props: 

145 continue 

146 prop_typ = self.gen.require_type(prop_tid) 

147 self.queue.append(prop_typ.tValue) 

148 rows[prop_typ.hasDefault].append( 

149 [ 

150 as_propname(prop_name) if prop_typ.hasDefault else as_required(prop_name), 

151 self.type_string(prop_typ.tValue), 

152 self.docstring_as_cell(prop_tid), 

153 ] 

154 ) 

155 

156 yield table( 

157 [ 

158 self.strings['head_property'], 

159 self.strings['head_type'], 

160 '', 

161 ], 

162 rows[False] + rows[True], 

163 ) 

164 

165 def process_enum(self, tid): 

166 """Create the section for an enum, with a table of its values. 

167 

168 Args: 

169 tid: Type uid. 

170 

171 Yields: 

172 Markdown blocks. 

173 """ 

174 

175 typ = self.gen.require_type(tid) 

176 

177 yield header('enum', tid) 

178 yield subhead(self.strings['category_enum'], self.docstring_as_header(tid)) 

179 yield table( 

180 [ 

181 self.strings['head_value'], 

182 '', 

183 ], 

184 [[as_literal(key), self.docstring_as_cell(tid, key)] for key in typ.enumValues], 

185 ) 

186 

187 def process_variant(self, tid): 

188 """Create the section for a variant, with a table of its members. 

189 

190 Args: 

191 tid: Type uid. 

192 

193 Yields: 

194 Markdown blocks. 

195 """ 

196 

197 typ = self.gen.require_type(tid) 

198 

199 yield header('variant', tid) 

200 yield subhead(self.strings['category_variant'], self.strings['head_variant']) 

201 

202 rows = [] 

203 for member_name, member_tid in sorted(typ.tMembers.items()): 

204 self.queue.append(member_tid) 

205 rows.append([as_literal(member_name), self.type_string(member_tid)]) 

206 

207 yield table( 

208 [ 

209 self.strings['head_type'], 

210 '', 

211 ], 

212 rows, 

213 ) 

214 

215 def process_type(self, tid): 

216 """Create the section for a type alias. 

217 

218 Args: 

219 tid: Type uid. 

220 

221 Yields: 

222 Markdown blocks. 

223 """ 

224 

225 yield header('type', tid) 

226 yield subhead(self.strings['category_type'], self.docstring_as_header(tid)) 

227 

228 def type_string(self, tid): 

229 """Format a type for a table cell. 

230 

231 Args: 

232 tid: Type uid. 

233 

234 Returns: 

235 A link for named types, a formatted name for other types. 

236 """ 

237 

238 typ = self.gen.require_type(tid) 

239 

240 if typ.c in {base.c.CLASS, base.c.TYPE, base.c.ENUM, base.c.VARIANT}: 

241 return link(tid, as_typename(tid)) 

242 

243 if typ.c == base.c.DICT: 

244 return as_code('dict') 

245 

246 if typ.c == base.c.LIST: 

247 return LIST_FORMAT.format(self.type_string(typ.tItem)) 

248 

249 if typ.c == base.c.ATOM: 

250 return as_typename(tid) 

251 

252 if typ.c == base.c.LITERAL: 

253 return r' | '.join(as_literal(s) for s in typ.literalValues) 

254 

255 return typ.c 

256 

257 def default_string(self, tid): 

258 """Format the default value of a property. 

259 

260 Args: 

261 tid: Property type uid. 

262 

263 Returns: 

264 The formatted default, or an empty string if there is no default, 

265 it is empty, or the property type is a literal. 

266 """ 

267 

268 typ = self.gen.require_type(tid) 

269 val = typ.tValue 

270 

271 if val in self.gen.typeDict and self.gen.typeDict[val].c == base.c.LITERAL: 

272 return '' 

273 if not typ.hasDefault: 

274 return '' 

275 v = typ.defaultValue 

276 if v is None or v == '': 

277 return '' 

278 return as_literal(v) 

279 

280 def docstring_as_header(self, tid, enum_value=None): 

281 """Format a docstring for a section header, keeping line breaks. 

282 

283 Args: 

284 tid: Type uid. 

285 enum_value: Enum member name, to format the docstring of the member. 

286 

287 Returns: 

288 The formatted docstring. 

289 """ 

290 

291 text, label, dev_label = self.docstring_elements(tid, enum_value) 

292 lines = text.split('\n') 

293 lines[0] += label + dev_label 

294 return '\n\n'.join(lines) 

295 

296 def docstring_as_cell(self, tid, enum_value=None): 

297 """Format a docstring for a table cell, on a single line. 

298 

299 Args: 

300 tid: Type uid. 

301 enum_value: Enum member name, to format the docstring of the member. 

302 

303 Returns: 

304 The formatted docstring. 

305 """ 

306 

307 text, label, dev_label = self.docstring_elements(tid, enum_value) 

308 return re.sub(r'\s+', ' ', text) + label + dev_label 

309 

310 def docstring_elements(self, tid, enum_value=None): 

311 """Get the parts of a docstring in the reference language. 

312 

313 Uses the translated string if present, otherwise the English one, 

314 marked as a missing translation. The default value is appended to the text. 

315 

316 Args: 

317 tid: Type uid. 

318 enum_value: Enum member name, to get the docstring of the member. 

319 

320 Returns: 

321 A list ``[text, label, dev_label]``. 

322 """ 

323 

324 # get the original (spec) docstring 

325 typ = self.gen.require_type(tid) 

326 en_text = typ.enumDocs.get(enum_value) if enum_value else typ.doc 

327 

328 # try the translated (from strings) docstring 

329 key = tid 

330 if enum_value: 

331 key += '.' + enum_value 

332 local_text = self.gen.strings[self.lang].get(key) 

333 

334 dev_label = '' 

335 

336 if en_text and not local_text and self.lang != 'en': 

337 # translation missing: use the english docstring and warn 

338 base.log.debug(f'missing {self.lang} translation for {key!r}') 

339 dev_label = f'`??? {key}`{{.configref_dev_missing_translation}}' 

340 local_text = self.gen.strings['en'].get(key) 

341 else: 

342 dev_label = f'`{key}`{{.configref_dev_uid}}' 

343 

344 local_text = local_text or en_text 

345 

346 # process a label, like "foobar" 

347 # it might be missing in a translation, but present in the original (spec) docstring 

348 text, label = self.extract_label(local_text) 

349 if not label and en_text != local_text: 

350 _, label = self.extract_label(en_text) 

351 

352 dflt = self.default_string(tid) 

353 if dflt: 

354 text += DEFAULT_FORMAT.format(self.strings['head_default'], dflt) 

355 

356 return [text, label, dev_label] 

357 

358 def extract_label(self, text): 

359 """Extract a version label like ``(added in 8.1)`` from the end of a docstring. 

360 

361 Args: 

362 text: Docstring. 

363 

364 Returns: 

365 A tuple ``(text without the label, formatted label)``; the label is empty if there is none. 

366 """ 

367 

368 m = re.match(rf'(.+?)\(({LABELS}) in (\d[\d.]+)\)$', text) 

369 if not m: 

370 return text, '' 

371 kind = m.group(2).strip() 

372 name = self.strings[f'label_{kind}'] 

373 version = m.group(3) 

374 label = f'`{name}: {version}`{{.configref_label_{kind}}}' 

375 return m.group(1).strip(), label 

376 

377 

378def as_literal(s): 

379 """Format a value as a literal. 

380 

381 Args: 

382 s: Value, formatted as JSON. 

383 

384 Returns: 

385 Markdown text. 

386 """ 

387 

388 v = json.dumps(s, ensure_ascii=False) 

389 return f'`{v}`{{.configref_literal}}' 

390 

391 

392def as_typename(s): 

393 """Format a type name. 

394 

395 Args: 

396 s: Type name. 

397 

398 Returns: 

399 Markdown text. 

400 """ 

401 

402 return f'`{s}`{{.configref_typename}}' 

403 

404 

405def as_category(s): 

406 """Format a category name. 

407 

408 Args: 

409 s: Category name. 

410 

411 Returns: 

412 Markdown text. 

413 """ 

414 

415 return f'`{s}`{{.configref_category}}' 

416 

417 

418def as_propname(s): 

419 """Format the name of an optional property. 

420 

421 Args: 

422 s: Property name. 

423 

424 Returns: 

425 Markdown text. 

426 """ 

427 

428 return f'`{s}`{{.configref_propname}}' 

429 

430 

431def as_required(s): 

432 """Format the name of a required property. 

433 

434 Args: 

435 s: Property name. 

436 

437 Returns: 

438 Markdown text. 

439 """ 

440 

441 return f'`{s}`{{.configref_required}}' 

442 

443 

444def as_code(s): 

445 """Format text as inline code. 

446 

447 Args: 

448 s: Text. 

449 

450 Returns: 

451 Markdown text. 

452 """ 

453 

454 return f'`{s}`' 

455 

456 

457def header(cat, tid): 

458 """Format a section header. 

459 

460 Args: 

461 cat: Category, used in the CSS class of the header. 

462 tid: Type uid, used as the header text and the anchor. 

463 

464 Returns: 

465 Markdown text. 

466 """ 

467 

468 return f'\n## <span class="configref_category_{cat}"></span>{tid} :{tid}\n' 

469 

470 

471def subhead(category, text): 

472 """Format the text below a section header. 

473 

474 Args: 

475 category: Category name (not used). 

476 text: Text. 

477 

478 Returns: 

479 Markdown text. 

480 """ 

481 

482 # return as_category(category) + ' ' + text + '\n' 

483 return text + '\n' 

484 

485 

486def link(target, text): 

487 """Format a link to the section of a type. 

488 

489 Args: 

490 target: Type uid. 

491 text: Link text. 

492 

493 Returns: 

494 Markdown text. 

495 """ 

496 

497 return f'[{text}](../{target})' 

498 

499 

500def first_line(s): 

501 """Get the first line of a text. 

502 

503 Args: 

504 s: Text or ``None``. 

505 

506 Returns: 

507 The first line, stripped. 

508 """ 

509 

510 return (s or '').strip().split('\n')[0].strip() 

511 

512 

513def table(heads, rows): 

514 """Format a Markdown table with padded columns. 

515 

516 Args: 

517 heads: Column headers. 

518 rows: Table rows, lists of cell values. 

519 

520 Returns: 

521 Markdown text. 

522 """ 

523 

524 widths = [len(h) for h in heads] 

525 

526 for r in rows: 

527 widths = [max(a, b) for a, b in zip(widths, [len(str(s)) for s in r])] 

528 

529 def field(n, v): 

530 return str(v).ljust(widths[n]) 

531 

532 def row(r): 

533 return ' | '.join(field(n, v) for n, v in enumerate(r)) 

534 

535 out = [row(heads), '', *[row(r) for r in rows]] 

536 out[1] = '-' * len(out[0]) 

537 return '\n'.join(f'| {s} |' for s in out) + '\n' 

538 

539 

540def escape(s, quote=True): 

541 """Escape HTML special characters. 

542 

543 Args: 

544 s: Text. 

545 quote: If True, escape double quotes as well. 

546 

547 Returns: 

548 The escaped text. 

549 """ 

550 

551 s = s.replace('&', '&amp;') 

552 s = s.replace('<', '&lt;') 

553 s = s.replace('>', '&gt;') 

554 if quote: 

555 s = s.replace('"', '&quot;') 

556 return s 

557 

558 

559nl = '\n'.join