Coverage for gws-app/gws/plugin/alkis/data/geo_info_dok/generator.py: 0%
514 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 GeoInfoDok schema modules from the official model files."""
3import re
4import os
5import json
6import textwrap
7import sys
8import html
9import sqlalchemy as sa
12def main(version, *paths):
13 """Generate ``gid<version>.py`` in this directory.
15 Args:
16 version: GeoInfoDok version, ``6`` or ``7``.
17 *paths: Model files, ``.cat`` files for version 6, a ``.qea`` file for version 7.
19 Raises:
20 ``ValueError``: If the version is not supported.
21 """
23 if version == '6':
24 nodes = Parser6().parse(paths)
26 elif version == '7':
27 nodes = Parser7().parse(paths)
29 else:
30 raise ValueError('invalid version')
32 # dumps(f'{CDIR}/db{version}.json', db)
34 py = PythonGenerator(nodes, version).build()
36 with open(f'{CDIR}/gid{version}.py', 'w') as fp:
37 fp.write(py)
40##
42CDIR = os.path.dirname(__file__)
43TAB = ' ' * 4
44TAB2 = TAB * 2
45Q3 = '"""'
46WRAP_WIDTH = 110
48CATEGORY_ROOTS = {
49 'AFIS-ALKIS-ATKIS Fachschema': 'fs',
50 'AAA Basisschema': 'bs',
51 'AAA_Objektartenkatalog': 'ak',
52}
54T_CLASS = 'class'
55T_CATEGORY = 'category'
56T_ENUM = 'enum'
57T_UNION = 'union'
59PY_HEAD = '''\
60"""GeoInfoDok <VERSION> schema.
62(c) 2023 Arbeitsgemeinschaft der Vermessungsverwaltungen der Länder der Bundesrepublik Deutschland
64https://www.adv-online.de/GeoInfoDok/
66This code is automatically generated from .CAT/.QEA source files.
67"""
69from typing import Any, Literal, Optional, TypeAlias, Union
70from datetime import date, datetime
73# gws:nospec
75class Object:
76 pass
79class Category:
80 pass
83class Enumeration:
84 pass
87def object__getattr__(self, item):
88 if item.startswith('_'):
89 raise AttributeError()
90 return None
93setattr(Object, '__getattr__', object__getattr__)
94setattr(Category, '__getattr__', object__getattr__)
95setattr(Enumeration, '__getattr__', object__getattr__)
97'''
99STD_TYPES = {
100 'Angle': 'float',
101 'Area': 'float',
102 'Boolean': 'bool',
103 'CharacterString': 'str',
104 'Date': 'date',
105 'DateTime': 'datetime',
106 'Distance': 'float',
107 'GenericName': 'str',
108 'Integer': 'int',
109 'Length': 'int',
110 'LocalName': 'str',
111 'Measure': 'str',
112 'Query': 'str',
113 'Real': 'float',
114 'SC_CRS': 'str',
115 'URI': 'str',
116 'URL': 'str',
117 'Volume': 'float',
118}
121##
123class Node:
124 """A schema element: category, class, union, enumeration or attribute.
126 Keyword arguments passed to the constructor become attributes.
127 Missing attributes read as ``None``.
128 """
130 def __init__(self, **kwargs):
131 """Create a node.
133 Args:
134 **kwargs: Attribute values.
135 """
137 vars(self).update(kwargs)
139 def __getattr__(self, item):
140 """Return ``None`` for missing attributes."""
142 return None
145##
147class Parser:
148 """Base class for model file parsers.
150 A parser creates a flat list of nodes from the model files and then
151 resolves keys, super classes and the ``is_aa`` and ``is_reo`` flags.
152 """
154 nodes: list[Node] = []
155 """Parsed nodes."""
157 def finalize(self):
158 """Resolve keys, category roots, super classes and flags of the parsed nodes.
160 Returns:
161 The nodes that have a node type.
162 """
164 for node in self.nodes:
165 self.make_key(node)
167 self.filter_category_roots()
168 self.resolve_supers()
170 for node in self.nodes:
171 self.check_flag(node, 'is_aa', 'AA_Objekt')
172 self.check_flag(node, 'is_reo', 'AA_REO')
174 return [node for node in self.nodes if node.T]
176 def make_key(self, node):
177 """Compute the key of a node, a path of lower-case names of its parents.
179 Args:
180 node: Schema node.
182 Returns:
183 The key.
184 """
186 if node.key:
187 return node.key
189 parent_key = ''
190 parent = popattr(node, 'pParent')
191 if parent:
192 parent_key = self.make_key(parent)
194 node.key = parent_key + '/' + node.name.lower()
195 return node.key
197 def filter_category_roots(self):
198 """Keep only nodes under the known category roots and shorten their keys."""
200 new_nodes = []
201 roots = {'/' + to_name(k).lower(): '/' + v for k, v in CATEGORY_ROOTS.items()}
203 for node in self.nodes:
204 for k, v in roots.items():
205 if k in node.key:
206 _, _, rest = node.key.partition(k)
207 node.key = v + rest
208 new_nodes.append(node)
210 self.nodes = new_nodes
212 def resolve_supers(self):
213 """Replace the super class names of class nodes with the super class nodes."""
215 for node in self.nodes:
216 for sup_name in popattr(node, 'pSuperNames', []):
217 sup = self.find_node(sup_name)
218 if sup and sup != node:
219 node.supers.append(sup)
221 def check_flag(self, node, prop, root):
222 """Compute a flag that is set for a root class and all of its subclasses.
224 The flag is stored on the node.
226 Args:
227 node: Schema node.
228 prop: Flag attribute name.
229 root: Name of the root class.
231 Returns:
232 The flag value.
233 """
235 a = getattr(node, prop)
236 if a is not None:
237 return a
238 if node.name == root:
239 v = True
240 elif node.supers:
241 v = any(self.check_flag(super_node, prop, root) for super_node in node.supers)
242 else:
243 v = False
244 setattr(node, prop, v)
245 return v
247 def find_node(self, name):
248 """Find a node by name.
250 Args:
251 name: Node name.
253 Returns:
254 The first node with this name, or ``None``.
255 """
257 for node in self.nodes:
258 if node.name == name:
259 return node
261 def get_doc(self, rec):
262 """Return the documentation text of a model record.
264 Args:
265 rec: Model record.
267 Returns:
268 The unescaped text without a leading ``[X]`` marker.
269 """
271 s = rec.get('documentation') or rec.get('Note') or rec.get('Notes') or ''
272 s = s.strip()
273 s = html.unescape(s)
274 # remove the [X] prefix, as in
275 # "[E] 'Person' ist eine natürliche...
276 if s.startswith('['):
277 return s.partition(']')[-1].strip()
278 return s
280 def get_hname(self, node):
281 """Return the human-readable name of a node, taken from its documentation.
283 The first quoted word or sentence of the documentation is used if it
284 matches the node name.
286 Args:
287 node: Schema node.
289 Returns:
290 The name, or ``None`` if not found.
291 """
293 # sometimes, the first quoted word in the name of the object, as in
294 # sonstigeEigenschaft: "'Sonstige Eigenschaft' sind Informationen zum Grenzpunkt...
295 #
296 # however, this should not be extracted
297 # weistAuf: "'Flurstück' weist auf 'Lagebezeichnung mit Hausnummer'...
299 if not node.doc:
300 return
302 cmp_name = to_name(node.name)
303 cmp_name = re.sub(r'[A-Z]+_(.+)', r'\1', cmp_name)
304 cmp_name = cmp_name.replace('_', '').lower()
306 patterns = [
307 r"^\'(.+?)\'",
308 r"^\"(.+?)\"",
309 r"^(.+?)\.$",
310 r"^(\w+)",
311 ]
313 for pat in patterns:
314 m = re.match(pat, node.doc)
315 if m:
316 s = m.group(1)
317 c = to_name(s).replace('_', '').lower()
318 if c == cmp_name:
319 return s
321 if node.name == 'funktion':
322 # fix a spelling mistake in some docstrings
323 return 'Funktion'
325 def add_enum_value(self, node, k, v):
326 """Add a value to an enumeration node.
328 Args:
329 node: Schema node.
330 k: Value code. If ``None``, the next sequence number is used.
331 v: Value text.
332 """
334 if k is None:
335 k = len(node.values) + 1
336 node.values[k] = v
338 def set_type_from_record(self, node, rec):
339 """Set the type of an attribute node from a model record.
341 Args:
342 node: Schema node.
343 rec: Model record.
344 """
346 self.set_type_from_string(node, rec.get('type', '') or rec.get('Type', ''))
348 def set_type_from_string(self, node, s):
349 """Set the type of an attribute node from a type string.
351 ``Sequence<X>`` and ``Set<X>`` set the type ``X`` and the list flag.
353 Args:
354 node: Schema node.
355 s: Type string.
356 """
358 m = re.match(r'(Sequence|Set)<(.+?)>', s)
359 if m:
360 node.type = m.group(2)
361 node.list = True
362 else:
363 node.type = s
365 def set_cardinality_from_string(self, node, s=None):
366 """Set the optional or list flag of an attribute node from a cardinality string.
368 Args:
369 node: Schema node.
370 s: Cardinality, e.g. ``0..1`` or ``0..*``.
371 """
373 if not s:
374 return
375 elif s == '0..1':
376 node.optional = True
377 elif '..' in s:
378 node.list = True
380 def set_cardinality_from_record(self, node, rec):
381 """Set the optional or list flag of an attribute node from the bounds in a model record.
383 Args:
384 node: Schema node.
385 rec: Model record with ``LowerBound`` and ``UpperBound``.
386 """
388 lb = str(rec['LowerBound'])
389 ub = str(rec['UpperBound'])
390 if lb == '0' and ub == '1':
391 node.optional = True
392 elif lb != ub:
393 node.list = True
396class Parser6(Parser):
397 """Parser for GeoInfoDok 6 Rational Rose ``.cat`` files."""
399 def parse(self, paths):
400 """Parse model files.
402 Args:
403 paths: Paths to ``.cat`` files.
405 Returns:
406 A list of nodes.
407 """
409 for path in paths:
410 cat = CatParser().parse(path)
411 self.parse_object(cat[1], None)
412 self.parse_associations(cat[1])
413 return self.finalize()
415 def parse_object(self, rec, parent):
416 """Create a node and its child nodes from a ``.cat`` record.
418 Args:
419 rec: Parsed ``.cat`` record.
420 parent: Parent node.
422 Returns:
423 The new node.
424 """
426 node = Node(name=rec['NAME'], pParent=parent, doc=self.get_doc(rec))
427 self.nodes.append(node)
429 # e.g. zugriffsartProduktkennungBenutzung [0..*]
430 m = re.match(r'^(\S+)\s*\[(.+?)]$', node.name)
431 if m:
432 node.name = m.group(1)
433 self.set_cardinality_from_string(node, m.group(2))
435 node.hname = self.get_hname(node)
436 node.name = to_name(node.name)
438 for a in rec.get('attributes', []):
439 if a['name'] == 'Kennung':
440 node.uid = a['value']
442 stereo = rec.get('stereotype', '').lower()
444 if rec['TYPE'] == 'Class_Category':
445 node.T = T_CATEGORY
446 for r2 in rec.get('logical_models', []):
447 self.parse_object(r2, node)
449 elif rec['TYPE'] == 'Class' and stereo in {'codelist', 'enumeration'}:
450 node.T = T_ENUM
451 node.values = {}
452 for r2 in rec.get('class_attributes', []):
453 self.add_enum_value(node, r2.get('initv'), r2['NAME'])
455 elif rec['TYPE'] == 'Class' and stereo == 'union':
456 node.T = T_UNION
457 node.attributes = [
458 self.parse_object(r2, node)
459 for r2 in rec.get('class_attributes', [])
460 ]
462 elif rec['TYPE'] == 'Class':
463 node.T = T_CLASS
464 node.attributes = [
465 self.parse_object(r2, node)
466 for r2 in rec.get('class_attributes', [])
467 ]
468 node.supers = []
469 node.pSuperNames = [
470 r2['supplier'].split(':')[-1]
471 for r2 in rec.get('superclasses', [])
472 ]
474 elif rec['TYPE'] == 'ClassAttribute':
475 self.set_type_from_record(node, rec)
477 elif rec['TYPE'] == 'Role':
478 self.set_type_from_string(node, rec['supplier'].split(':')[-1])
479 self.set_cardinality_from_string(node, rec.get('client_cardinality'))
481 return node
483 def parse_associations(self, rec):
484 """Add association roles as attributes to the classes they connect.
486 Args:
487 rec: Parsed ``.cat`` record, searched recursively for associations.
488 """
490 if rec['TYPE'] != 'Association':
491 for o2 in rec.get('logical_models', []):
492 self.parse_associations(o2)
493 return
495 # This is what an association looks like in a parsed .cat:
496 #
497 # {
498 # "NAME": "$UNNAMED$38",
499 # "TYPE": "Association",
500 # "quid": "40FED632018C",
501 # "roles": [
502 # {
503 # "NAME": "weistZum",
504 # "TYPE": "Role",
505 # "client_cardinality": "0..1",
506 # "documentation": "Eine 'Lagebezeichnung mit Hausnummer' weist zum 'Turm'.",
507 # "supplier": "...:AX_Turm"
508 # },
509 # {
510 # "NAME": "zeigtAuf",
511 # "TYPE": "Role",
512 # "client_cardinality": "0..*",
513 # "documentation": "'Turm' zeigt auf eine 'Lagebezeichnung mit Hausnummer'.",
514 # "supplier": "...:AX_LagebezeichnungMitHausnummer"
515 # }
516 # ]
517 # }
519 role1 = rec['roles'][0]
520 role2 = rec['roles'][1]
522 type1 = role1['supplier'].split(':')[-1]
523 type2 = role2['supplier'].split(':')[-1]
525 cls1 = self.find_node(type1)
526 cls2 = self.find_node(type2)
528 if cls2 and not role1['NAME'].startswith('$') and cls2.attributes is not None:
529 cls2.attributes.append(self.parse_object(role1, cls2))
530 if cls1 and not role2['NAME'].startswith('$') and cls1.attributes is not None:
531 cls1.attributes.append(self.parse_object(role2, cls1))
534##
536class Parser7(Parser):
537 """Parser for GeoInfoDok 7 Enterprise Architect ``.qea`` (sqlite) files."""
539 engine: sa.Engine
540 """Engine for the current file."""
542 def parse(self, paths):
543 """Parse model files.
545 Args:
546 paths: Paths to ``.qea`` files.
548 Returns:
549 A list of nodes.
550 """
552 for path in paths:
553 self.engine = sa.create_engine(f'sqlite:///' + path)
554 self.build_from_sqlite()
555 return self.finalize()
557 def select(self, table):
558 """Read all rows of a table.
560 Args:
561 table: Table name.
563 Returns:
564 A list of row mappings.
565 """
567 with self.engine.begin() as conn:
568 sel = sa.text(f'SELECT * FROM {table}')
569 return list(conn.execute(sel).mappings().all())
571 def build_from_sqlite(self):
572 """Create nodes from the objects, attributes and connectors in the current file."""
575 nodes_by_uid = {}
576 nodes_by_gid = {}
578 for rec in self.select('t_object'):
579 if rec['Alias']:
580 continue
582 node = Node(name=rec['Name'], doc=self.get_doc(rec))
583 self.nodes.append(node)
585 node.hname = self.get_hname(node)
586 node.name = to_name(node.name)
588 node.Package_ID = rec['Package_ID']
590 nodes_by_uid[rec['Object_ID']] = node
591 nodes_by_gid[rec['ea_guid']] = node
593 if rec['Object_Type'] == 'Package':
594 node.T = T_CATEGORY
595 continue
597 stereo = (rec['Stereotype'] or '').lower()
599 if rec['Object_Type'] == 'Enumeration' or stereo in {'enumeration', 'codelist'}:
600 node.T = T_ENUM
601 node.values = {}
602 continue
604 if rec['Object_Type'] == 'Class' and stereo == 'union':
605 node.T = T_UNION
606 node.attributes = []
607 continue
609 if rec['Object_Type'] == 'Class':
610 node.T = T_CLASS
611 node.attributes = []
612 node.supers = []
613 node.pSuperNames = []
614 continue
616 for rec in self.select('t_objectproperties'):
617 if rec['Property'] == 'AAA:Kennung' and rec['Value']:
618 node = nodes_by_uid.get(rec['Object_ID'])
619 if node:
620 node.uid = rec['Value']
622 package_uid_to_gid = {}
624 for rec in self.select('t_package'):
625 package_uid_to_gid[rec['Package_ID']] = rec['ea_guid']
627 for node in self.nodes:
628 pkg_gid = package_uid_to_gid.get(popattr(node, 'Package_ID'))
629 if pkg_gid:
630 pkg_node = nodes_by_gid.get(pkg_gid)
631 if pkg_node:
632 node.pParent = pkg_node
634 for rec in self.select('t_attribute'):
635 node = nodes_by_uid.get(rec['Object_ID'])
636 if node:
637 if node.T in {T_CLASS, T_UNION}:
638 a = Node(name=rec['Name'], doc=self.get_doc(rec), pParent=node)
639 self.set_type_from_record(a, rec)
640 self.set_cardinality_from_record(a, rec)
641 node.attributes.append(a)
642 self.nodes.append(a)
643 if node.T == T_ENUM:
644 self.add_enum_value(node, rec['Default'], rec['Name'])
646 for rec in self.select('t_connector'):
647 so = nodes_by_uid.get(rec['Start_Object_ID'])
648 eo = nodes_by_uid.get(rec['End_Object_ID'])
650 if so and eo and so.T == eo.T == T_CLASS:
651 if rec['Connector_Type'] == 'Generalization':
652 so.pSuperNames.append(eo.name)
653 continue
655 if rec['Connector_Type'] == 'Association':
656 """
657 "SourceCard": "0..*",
658 "SourceRole": "zeigtAuf",
659 "SourceRoleNote": "'Turm' zeigt auf eine 'Lagebezeichnung mit Hausnummer'.",
660 "DestRole": "weistZum",
661 "DestRoleNote": "Eine 'Lagebezeichnung mit Hausnummer' weist zum 'Turm'.",
662 "Start_Object_ID": 3678,
663 "End_Object_ID": 3511,
664 """
665 if rec['SourceRole']:
666 a = Node(name=rec['SourceRole'], doc=rec['SourceRoleNote'], type=so.name, pParent=eo)
667 self.set_cardinality_from_string(a, rec['SourceCard'])
668 eo.attributes.append(a)
669 self.nodes.append(a)
671 if rec['DestRole']:
672 b = Node(name=rec['DestRole'], doc=rec['DestRoleNote'], type=eo.name, pParent=so)
673 so.attributes.append(b)
674 self.nodes.append(b)
677class CatParser:
678 """Parser for Rational Rose ``.cat`` files.
680 A ``.cat`` file is a nested structure of lists, objects and values in
681 parentheses. Objects become dicts with ``TYPE`` and ``NAME`` keys and
682 their properties, lists become Python lists.
683 """
685 def parse(self, path):
686 """Parse a ``.cat`` file.
688 Args:
689 path: File path.
691 Returns:
692 A list of top-level items.
693 """
695 with open(path, 'rb') as fp:
696 text = fp.read().decode('latin-1')
697 self.tokenize(text)
698 return self.parse_sequence()
700 re_token = r'''(?x)
701 ( [()] )
702 |
703 ( [_a-zA-Z] \w* )
704 |
705 (
706 " (?: \\. | [^"] )* "
707 |
708 [^()\s]+
709 )
710 '''
711 """Token pattern: bracket, name or value."""
713 tokens = []
714 """Tokens as ``(bracket, name, value)`` tuples."""
715 token_pos = 0
716 """Position of the current token."""
718 def tokenize(self, text):
719 """Split a ``.cat`` file into tokens.
721 Lines starting with ``|`` are collected into a single string value.
723 Args:
724 text: File content.
725 """
727 docstring_buf = []
728 self.tokens = []
730 for n, ln in enumerate(text.split('\n'), 1):
731 ln = ln.strip()
732 if not ln:
733 continue
734 if ln.startswith('|'):
735 docstring_buf.append(ln[1:])
736 continue
737 if docstring_buf:
738 s = '\n'.join(p for p in docstring_buf if p).strip() or ' '
739 self.tokens.append(('', '', s))
740 docstring_buf = []
741 for br, name, val in re.findall(self.re_token, ln):
742 if val.startswith('"'):
743 # decode a string, don't allow empty strings
744 val = val[1:-1].replace('\\', '') or ' '
745 self.tokens.append((br, name, val))
747 def tok(self):
748 """Return the current token.
750 Returns:
751 A ``(bracket, name, value)`` tuple.
752 """
754 return self.tokens[self.token_pos]
756 def pop(self):
757 """Advance to the next token."""
759 self.token_pos += 1
761 def eof(self):
762 """Check whether all tokens are consumed.
764 Returns:
765 ``True`` at the end of the token list.
766 """
768 return self.token_pos >= len(self.tokens)
770 ##
772 def parse_sequence(self):
773 """Parse items up to the closing bracket or the end of input.
775 Returns:
776 A list of items.
777 """
779 items = []
780 while not self.eof():
781 br, name, val = self.tok()
782 if br == ')':
783 self.pop()
784 break
785 items.append(self.parse_item())
786 return items
788 def parse_item(self):
789 """Parse a single item: a value, a boolean, a list, an object or a sequence.
791 Returns:
792 The parsed item.
794 Raises:
795 ``SyntaxError``: If the token cannot start an item.
796 """
798 br, name, val = self.tok()
799 if val:
800 self.pop()
801 return val
803 if name in {'TRUE', 'FALSE'}:
804 self.pop()
805 return name == 'TRUE'
807 if br == '(':
808 self.pop()
809 br, name, val = self.tok()
810 if name == 'list':
811 # (list ...
812 return self.parse_list()
813 if name == 'object':
814 # (object ...
815 return self.parse_object()
816 if name == 'value':
817 # (value ...
818 return self.parse_value()
820 # (val val...)
821 return self.parse_sequence()
823 raise SyntaxError(f'invalid token {br=} {name=} {val=}')
825 def parse_list(self):
826 """Parse a ``(list <type> ...)`` item.
828 Returns:
829 A list of items.
830 """
832 # e.g. (list Attribute_Set (object... (object...
834 self.pop() # list
835 self.pop() # type
837 return self.parse_sequence()
839 def parse_object(self):
840 """Parse an ``(object <type> <name> ...)`` item.
842 Returns:
843 A dict with ``TYPE``, ``NAME`` and the object properties.
844 """
846 # e.g. (object ClassAttribute "Sonstiges" attr val attr val
847 # e.g. (object Attribute
849 rec = {}
851 self.pop() # object
853 br, name, val = self.tok()
854 rec['TYPE'] = name
855 self.pop()
857 br, name, val = self.tok()
858 if val:
859 rec['NAME'] = val
860 self.pop()
862 # evtl. more strings after name, ignore them
863 while not self.eof():
864 br, name, val = self.tok()
865 if not val:
866 break
867 self.pop()
869 while not self.eof():
870 br, name, val = self.tok()
871 if br == ')':
872 self.pop()
873 break
874 self.pop()
875 rec[name] = self.parse_item()
877 return rec
879 def parse_value(self):
880 """Parse a ``(value <type> <value>)`` item.
882 Returns:
883 The value.
884 """
886 # e.g. (value Text "30000")
888 self.pop() # value
889 self.pop() # type
891 val = self.parse_item()
892 self.pop() # )
894 return val
897class PythonGenerator:
898 """Generator for the Python schema module."""
900 unknownTypes = set()
901 """Referenced types that are not defined in the model, emitted as empty classes."""
902 knownTypes = set()
903 """Names of the classes, enumerations and unions in the model."""
904 nameToNode = {}
905 """Nodes by name."""
906 keyToNode = {}
907 """Nodes by key."""
908 seen = set()
909 """Names of the nodes already generated."""
910 metadata = {}
911 """Metadata by type name, written as ``METADATA``."""
912 py = []
913 """Generated source lines."""
915 def __init__(self, nodes, version: str):
916 """Create a generator.
918 Args:
919 nodes: Parsed nodes.
920 version: GeoInfoDok version.
921 """
923 self.nodes = nodes
924 self.version = version
926 def build(self):
927 """Generate the module source.
929 Returns:
930 The Python source code.
931 """
933 self.knownTypes = set(
934 node.name
935 for node in self.nodes
936 if node.T in {T_CLASS, T_ENUM, T_UNION}
937 )
939 self.nameToNode = {node.name: node for node in self.nodes}
940 self.keyToNode = {node.key: node for node in self.nodes}
942 nodes = sorted(self.nodes, key=lambda n: n.name)
943 self.make_nodes(nodes)
945 py = nl(self.py)
947 py = re.sub(r'(\n\w+: TypeAlias)', '\n\n\\1', py)
948 py = re.sub(r'(\nclass )', '\n\n\\1', py)
950 py = nl([
951 PY_HEAD,
952 *[f'{k}: TypeAlias = {v}' for k, v in sorted(STD_TYPES.items())],
953 '',
954 '',
955 *[f'class {k}: ...' for k in sorted(self.unknownTypes)],
956 '',
957 '',
958 py,
959 '',
960 '',
961 'METADATA = {',
962 json_dict_body(self.metadata, TAB),
963 '}',
964 '',
966 ])
968 return py.replace('<VERSION>', self.version)
970 def make_nodes(self, nodes):
971 """Generate unions, categories, enumerations and classes, in this order.
973 Args:
974 nodes: Nodes, sorted by name.
975 """
977 for ts in T_UNION, T_CATEGORY, T_ENUM, T_CLASS:
978 for node in nodes:
979 if node.T == ts:
980 self.make_node(node)
982 def make_node(self, node):
983 """Generate a node and its metadata, unless it is already generated.
985 Args:
986 node: Schema node.
987 """
989 if node.name not in self.seen:
990 self.seen.add(node.name)
991 fn = getattr(self, 'make_' + node.T)
992 fn(node)
993 self.make_metadata(node)
995 def make_union(self, node):
996 """Generate a type alias for a union node.
998 Args:
999 node: Schema node.
1000 """
1002 items = sorted(set(self.get_type(a.type) for a in node.attributes))
1003 typ = items[0] if len(items) == 1 else 'Union[' + comma(items) + ']'
1005 self.py.append(f'{node.name}: TypeAlias = {typ}')
1006 self.py.append(self.get_docstring(node, '', False))
1008 def make_category(self, node):
1009 """Generate a class for a category node.
1011 Args:
1012 node: Schema node.
1013 """
1015 self.py.append(f'class {node.name}(Category):')
1016 self.py.append(self.get_docstring(node, TAB, True))
1018 def make_enum(self, node):
1019 """Generate a class with a ``VALUES`` dict for an enumeration node.
1021 Args:
1022 node: Schema node.
1023 """
1025 self.py.append(f'class {node.name}(Enumeration):')
1026 self.py.append(self.get_docstring(node, TAB, True))
1027 self.py.append('')
1028 self.py.append(f'{TAB}VALUES = {{')
1029 self.py.append(json_dict_body(node.values, TAB2))
1030 self.py.append(f'{TAB}}}')
1032 def make_class(self, node):
1033 """Generate a class with attribute annotations for a class node.
1035 Super classes are generated first.
1037 Args:
1038 node: Schema node.
1039 """
1041 node.attributes = node.attributes or []
1043 super_types = []
1045 for super_node in (node.supers or []):
1046 self.make_node(super_node)
1047 super_types.append(self.get_type(super_node.name, quoted=False))
1049 cls = f'class {node.name}'
1050 if super_types:
1051 cls += '(' + comma(super_types) + ')'
1052 else:
1053 cls += '(Object)'
1055 self.py.append(cls + ':')
1056 self.py.append(self.get_docstring(node, TAB, True))
1058 if node.name == 'AA_REO':
1059 self.py.append('')
1060 self.py.append(f'{TAB}geom: str')
1062 for a in sorted(node.attributes, key=lambda a: a.name):
1063 typ = self.get_type(a.type)
1064 if a.list:
1065 typ = f'list[{typ}]'
1066 if a.optional:
1067 typ = f'Optional[{typ}]'
1068 self.py.append('')
1069 self.py.append(f"{TAB}{to_name(a.name)}: {typ}")
1070 self.py.append(self.get_docstring(a, TAB, False))
1072 def make_metadata(self, node):
1073 """Add the metadata of a node to ``metadata``.
1075 Args:
1076 node: Schema node.
1077 """
1079 d = {
1080 'kind': node.T,
1081 'name': node.name,
1082 'uid': node.uid or '',
1083 'key': node.key or '',
1084 'title': node.hname or '',
1085 }
1087 if node.T == T_CLASS:
1088 d.update(self.make_class_metadata(node))
1090 self.metadata[node.name] = d
1092 def make_class_metadata(self, node):
1093 """Return the class-specific metadata of a class node.
1095 Args:
1096 node: Schema node.
1098 Returns:
1099 A dict with ``kind``, ``geom``, ``attributes`` and ``supers``.
1100 """
1102 d = {}
1104 d['kind'] = 'object' if node.is_aa else 'struct'
1105 d['geom'] = 1 if node.is_reo else 0
1106 d['attributes'] = []
1108 d['supers'] = [sup.name for sup in node.supers]
1110 for a in node.attributes:
1111 d['attributes'].append({
1112 'name': a.name,
1113 'title': a.hname or '',
1114 'type': a.type,
1115 'list': 1 if a.list else 0,
1116 })
1118 return d
1120 def get_type(self, typ, quoted=True):
1121 """Return the annotation for a type name.
1123 Types not defined in the model are added to ``unknownTypes``.
1125 Args:
1126 typ: Type name.
1127 quoted: Whether to quote model type names.
1129 Returns:
1130 The annotation, ``Any`` for an empty type.
1131 """
1133 if not typ:
1134 return 'Any'
1136 if hasattr(__builtins__, typ):
1137 return typ
1139 if typ in STD_TYPES or typ in self.knownTypes:
1140 return quote(typ) if quoted else typ
1142 self.unknownTypes.add(typ)
1143 return quote(typ) if quoted else typ
1145 def get_docstring(self, node, indent, prepend_name):
1146 """Return the docstring source of a node.
1148 Args:
1149 node: Schema node.
1150 indent: Indentation.
1151 prepend_name: Whether to put the human-readable name before the documentation.
1153 Returns:
1154 The wrapped and indented docstring.
1155 """
1157 name = node.hname or node.name or ' '
1159 if node.doc:
1160 s = node.doc
1161 if prepend_name and name:
1162 s = name + '\n\n' + s
1163 else:
1164 s = name
1166 if s.endswith('"'):
1167 s += ' '
1168 return wrap_indent(Q3 + s + Q3, indent)
1171##
1174def popattr(obj, attr, default=None):
1175 """Remove an attribute from an object and return its value.
1177 Args:
1178 obj: Object.
1179 attr: Attribute name.
1180 default: Value to return if the attribute is not set.
1182 Returns:
1183 The attribute value or the default.
1184 """
1186 return obj.__dict__.pop(attr, default)
1189def wrap_indent(s, indent):
1190 """Wrap each line of a string and indent it.
1192 Args:
1193 s: Text.
1194 indent: Indentation.
1196 Returns:
1197 The wrapped text.
1198 """
1200 return nl(
1201 nl(indent + ln for ln in textwrap.wrap(p.strip(), WRAP_WIDTH))
1202 for p in s.split('\n')
1203 )
1206def quote(s):
1207 """Wrap a string in single quotes.
1209 Args:
1210 s: String.
1212 Returns:
1213 The quoted string.
1214 """
1216 return "'" + (s or '') + "'"
1219_UID_DE_TRANS = {
1220 ord('ä'): 'ae',
1221 ord('ö'): 'oe',
1222 ord('ü'): 'ue',
1223 ord('ß'): 'ss',
1224 ord('Ä'): 'Ae',
1225 ord('Ö'): 'Oe',
1226 ord('Ü'): 'Ue',
1227}
1230def to_name(s):
1231 """Convert a string to a Python identifier.
1233 Umlauts are transliterated and non-word characters are replaced by ``_``.
1235 Args:
1236 s: String.
1238 Returns:
1239 The identifier, an empty string for an empty input.
1240 """
1242 if not s:
1243 return ''
1244 s = str(s)
1245 if re.match(r'^[A-Za-z_][A-Za-z_0-9]*$', s):
1246 return s
1247 s = s.strip().translate(_UID_DE_TRANS)
1248 s = re.sub(r'\W+', '_', s).strip('_')
1249 if not s:
1250 return '_'
1251 if s[0].isdigit():
1252 s = '_' + s
1253 return s
1256def json_dict_body(d, indent):
1257 """Format a dict as indented JSON, without the enclosing braces.
1259 Args:
1260 d: Dict.
1261 indent: Indentation of the entries.
1263 Returns:
1264 The formatted entries.
1265 """
1267 js = json.dumps(d, indent=len(TAB), ensure_ascii=False).split('\n')[1:-1]
1268 ind = ' ' * (len(indent) - len(TAB))
1269 return nl(ind + p for p in js)
1272comma = ', '.join
1273nl = '\n'.join
1275##
1277if __name__ == '__main__':
1278 main(sys.argv[1], *sys.argv[2:])