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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Generate the configuration reference in Markdown."""
3import re
4import json
6from . import base
8STRINGS = {}
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}
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}
42LIST_FORMAT = '<nobr>{}**[ ]**</nobr>'
43DEFAULT_FORMAT = ' _{}:_ {}.'
45LABELS = 'added|deprecated|changed'
48def create(gen: base.Generator, lang: str):
49 """Create the configuration reference.
51 The reference starts with the application ``Config`` and contains a
52 section for each reachable class, type alias, enum and variant.
54 Args:
55 gen: Generator state, with strings already collected.
56 lang: Language code, ``en`` or ``de``.
58 Returns:
59 The reference as Markdown text.
60 """
62 return _Creator(gen, lang).run()
65##
68class _Creator:
69 """Builds the reference by walking the types from the application config."""
71 start_tid = 'gws.base.application.core.Config'
72 exclude_props = ['uid', 'access', 'type']
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 = []
81 def run(self):
82 """Create the reference.
84 Returns:
85 The Markdown text, with sections sorted by kind and uid.
86 """
88 self.queue = [self.start_tid]
89 self.blocks = []
90 done = set()
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)
99 return nl(b[-1] for b in sorted(self.blocks))
101 def process(self, tid):
102 """Create the section for a type and enqueue the types it refers to.
104 Args:
105 tid: Type uid.
106 """
108 typ = self.gen.require_type(tid)
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))])
114 if typ.c == base.c.TYPE:
115 self.blocks.append([2, tid.lower(), nl(self.process_type(tid))])
117 if typ.c == base.c.ENUM:
118 self.blocks.append([3, tid.lower(), nl(self.process_enum(tid))])
120 if typ.c == base.c.VARIANT:
121 self.blocks.append([4, tid.lower(), nl(self.process_variant(tid))])
123 if typ.c == base.c.LIST:
124 self.queue.append(typ.tItem)
126 def process_class(self, tid):
127 """Create the section for a class, with a table of its properties.
129 Args:
130 tid: Type uid.
132 Yields:
133 Markdown blocks.
134 """
136 typ = self.gen.require_type(tid)
138 yield header('object', tid)
139 yield subhead(self.strings['category_object'], self.docstring_as_header(tid))
141 rows = {False: [], True: []}
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 )
156 yield table(
157 [
158 self.strings['head_property'],
159 self.strings['head_type'],
160 '',
161 ],
162 rows[False] + rows[True],
163 )
165 def process_enum(self, tid):
166 """Create the section for an enum, with a table of its values.
168 Args:
169 tid: Type uid.
171 Yields:
172 Markdown blocks.
173 """
175 typ = self.gen.require_type(tid)
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 )
187 def process_variant(self, tid):
188 """Create the section for a variant, with a table of its members.
190 Args:
191 tid: Type uid.
193 Yields:
194 Markdown blocks.
195 """
197 typ = self.gen.require_type(tid)
199 yield header('variant', tid)
200 yield subhead(self.strings['category_variant'], self.strings['head_variant'])
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)])
207 yield table(
208 [
209 self.strings['head_type'],
210 '',
211 ],
212 rows,
213 )
215 def process_type(self, tid):
216 """Create the section for a type alias.
218 Args:
219 tid: Type uid.
221 Yields:
222 Markdown blocks.
223 """
225 yield header('type', tid)
226 yield subhead(self.strings['category_type'], self.docstring_as_header(tid))
228 def type_string(self, tid):
229 """Format a type for a table cell.
231 Args:
232 tid: Type uid.
234 Returns:
235 A link for named types, a formatted name for other types.
236 """
238 typ = self.gen.require_type(tid)
240 if typ.c in {base.c.CLASS, base.c.TYPE, base.c.ENUM, base.c.VARIANT}:
241 return link(tid, as_typename(tid))
243 if typ.c == base.c.DICT:
244 return as_code('dict')
246 if typ.c == base.c.LIST:
247 return LIST_FORMAT.format(self.type_string(typ.tItem))
249 if typ.c == base.c.ATOM:
250 return as_typename(tid)
252 if typ.c == base.c.LITERAL:
253 return r' | '.join(as_literal(s) for s in typ.literalValues)
255 return typ.c
257 def default_string(self, tid):
258 """Format the default value of a property.
260 Args:
261 tid: Property type uid.
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 """
268 typ = self.gen.require_type(tid)
269 val = typ.tValue
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)
280 def docstring_as_header(self, tid, enum_value=None):
281 """Format a docstring for a section header, keeping line breaks.
283 Args:
284 tid: Type uid.
285 enum_value: Enum member name, to format the docstring of the member.
287 Returns:
288 The formatted docstring.
289 """
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)
296 def docstring_as_cell(self, tid, enum_value=None):
297 """Format a docstring for a table cell, on a single line.
299 Args:
300 tid: Type uid.
301 enum_value: Enum member name, to format the docstring of the member.
303 Returns:
304 The formatted docstring.
305 """
307 text, label, dev_label = self.docstring_elements(tid, enum_value)
308 return re.sub(r'\s+', ' ', text) + label + dev_label
310 def docstring_elements(self, tid, enum_value=None):
311 """Get the parts of a docstring in the reference language.
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.
316 Args:
317 tid: Type uid.
318 enum_value: Enum member name, to get the docstring of the member.
320 Returns:
321 A list ``[text, label, dev_label]``.
322 """
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
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)
334 dev_label = ''
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}}'
344 local_text = local_text or en_text
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)
352 dflt = self.default_string(tid)
353 if dflt:
354 text += DEFAULT_FORMAT.format(self.strings['head_default'], dflt)
356 return [text, label, dev_label]
358 def extract_label(self, text):
359 """Extract a version label like ``(added in 8.1)`` from the end of a docstring.
361 Args:
362 text: Docstring.
364 Returns:
365 A tuple ``(text without the label, formatted label)``; the label is empty if there is none.
366 """
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
378def as_literal(s):
379 """Format a value as a literal.
381 Args:
382 s: Value, formatted as JSON.
384 Returns:
385 Markdown text.
386 """
388 v = json.dumps(s, ensure_ascii=False)
389 return f'`{v}`{{.configref_literal}}'
392def as_typename(s):
393 """Format a type name.
395 Args:
396 s: Type name.
398 Returns:
399 Markdown text.
400 """
402 return f'`{s}`{{.configref_typename}}'
405def as_category(s):
406 """Format a category name.
408 Args:
409 s: Category name.
411 Returns:
412 Markdown text.
413 """
415 return f'`{s}`{{.configref_category}}'
418def as_propname(s):
419 """Format the name of an optional property.
421 Args:
422 s: Property name.
424 Returns:
425 Markdown text.
426 """
428 return f'`{s}`{{.configref_propname}}'
431def as_required(s):
432 """Format the name of a required property.
434 Args:
435 s: Property name.
437 Returns:
438 Markdown text.
439 """
441 return f'`{s}`{{.configref_required}}'
444def as_code(s):
445 """Format text as inline code.
447 Args:
448 s: Text.
450 Returns:
451 Markdown text.
452 """
454 return f'`{s}`'
457def header(cat, tid):
458 """Format a section header.
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.
464 Returns:
465 Markdown text.
466 """
468 return f'\n## <span class="configref_category_{cat}"></span>{tid} :{tid}\n'
471def subhead(category, text):
472 """Format the text below a section header.
474 Args:
475 category: Category name (not used).
476 text: Text.
478 Returns:
479 Markdown text.
480 """
482 # return as_category(category) + ' ' + text + '\n'
483 return text + '\n'
486def link(target, text):
487 """Format a link to the section of a type.
489 Args:
490 target: Type uid.
491 text: Link text.
493 Returns:
494 Markdown text.
495 """
497 return f'[{text}](../{target})'
500def first_line(s):
501 """Get the first line of a text.
503 Args:
504 s: Text or ``None``.
506 Returns:
507 The first line, stripped.
508 """
510 return (s or '').strip().split('\n')[0].strip()
513def table(heads, rows):
514 """Format a Markdown table with padded columns.
516 Args:
517 heads: Column headers.
518 rows: Table rows, lists of cell values.
520 Returns:
521 Markdown text.
522 """
524 widths = [len(h) for h in heads]
526 for r in rows:
527 widths = [max(a, b) for a, b in zip(widths, [len(str(s)) for s in r])]
529 def field(n, v):
530 return str(v).ljust(widths[n])
532 def row(r):
533 return ' | '.join(field(n, v) for n, v in enumerate(r))
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'
540def escape(s, quote=True):
541 """Escape HTML special characters.
543 Args:
544 s: Text.
545 quote: If True, escape double quotes as well.
547 Returns:
548 The escaped text.
549 """
551 s = s.replace('&', '&')
552 s = s.replace('<', '<')
553 s = s.replace('>', '>')
554 if quote:
555 s = s.replace('"', '"')
556 return s
559nl = '\n'.join