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

1"""Generate the GeoInfoDok schema modules from the official model files.""" 

2 

3import re 

4import os 

5import json 

6import textwrap 

7import sys 

8import html 

9import sqlalchemy as sa 

10 

11 

12def main(version, *paths): 

13 """Generate ``gid<version>.py`` in this directory. 

14 

15 Args: 

16 version: GeoInfoDok version, ``6`` or ``7``. 

17 *paths: Model files, ``.cat`` files for version 6, a ``.qea`` file for version 7. 

18 

19 Raises: 

20 ``ValueError``: If the version is not supported. 

21 """ 

22 

23 if version == '6': 

24 nodes = Parser6().parse(paths) 

25 

26 elif version == '7': 

27 nodes = Parser7().parse(paths) 

28 

29 else: 

30 raise ValueError('invalid version') 

31 

32 # dumps(f'{CDIR}/db{version}.json', db) 

33 

34 py = PythonGenerator(nodes, version).build() 

35 

36 with open(f'{CDIR}/gid{version}.py', 'w') as fp: 

37 fp.write(py) 

38 

39 

40## 

41 

42CDIR = os.path.dirname(__file__) 

43TAB = ' ' * 4 

44TAB2 = TAB * 2 

45Q3 = '"""' 

46WRAP_WIDTH = 110 

47 

48CATEGORY_ROOTS = { 

49 'AFIS-ALKIS-ATKIS Fachschema': 'fs', 

50 'AAA Basisschema': 'bs', 

51 'AAA_Objektartenkatalog': 'ak', 

52} 

53 

54T_CLASS = 'class' 

55T_CATEGORY = 'category' 

56T_ENUM = 'enum' 

57T_UNION = 'union' 

58 

59PY_HEAD = '''\ 

60"""GeoInfoDok <VERSION> schema. 

61 

62(c) 2023 Arbeitsgemeinschaft der Vermessungsverwaltungen der Länder der Bundesrepublik Deutschland 

63 

64https://www.adv-online.de/GeoInfoDok/ 

65 

66This code is automatically generated from .CAT/.QEA source files. 

67""" 

68 

69from typing import Any, Literal, Optional, TypeAlias, Union 

70from datetime import date, datetime 

71 

72 

73# gws:nospec 

74 

75class Object: 

76 pass 

77 

78 

79class Category: 

80 pass 

81 

82 

83class Enumeration: 

84 pass 

85 

86 

87def object__getattr__(self, item): 

88 if item.startswith('_'): 

89 raise AttributeError() 

90 return None 

91 

92 

93setattr(Object, '__getattr__', object__getattr__) 

94setattr(Category, '__getattr__', object__getattr__) 

95setattr(Enumeration, '__getattr__', object__getattr__) 

96 

97''' 

98 

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} 

119 

120 

121## 

122 

123class Node: 

124 """A schema element: category, class, union, enumeration or attribute. 

125 

126 Keyword arguments passed to the constructor become attributes. 

127 Missing attributes read as ``None``. 

128 """ 

129 

130 def __init__(self, **kwargs): 

131 """Create a node. 

132 

133 Args: 

134 **kwargs: Attribute values. 

135 """ 

136 

137 vars(self).update(kwargs) 

138 

139 def __getattr__(self, item): 

140 """Return ``None`` for missing attributes.""" 

141 

142 return None 

143 

144 

145## 

146 

147class Parser: 

148 """Base class for model file parsers. 

149 

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 """ 

153 

154 nodes: list[Node] = [] 

155 """Parsed nodes.""" 

156 

157 def finalize(self): 

158 """Resolve keys, category roots, super classes and flags of the parsed nodes. 

159 

160 Returns: 

161 The nodes that have a node type. 

162 """ 

163 

164 for node in self.nodes: 

165 self.make_key(node) 

166 

167 self.filter_category_roots() 

168 self.resolve_supers() 

169 

170 for node in self.nodes: 

171 self.check_flag(node, 'is_aa', 'AA_Objekt') 

172 self.check_flag(node, 'is_reo', 'AA_REO') 

173 

174 return [node for node in self.nodes if node.T] 

175 

176 def make_key(self, node): 

177 """Compute the key of a node, a path of lower-case names of its parents. 

178 

179 Args: 

180 node: Schema node. 

181 

182 Returns: 

183 The key. 

184 """ 

185 

186 if node.key: 

187 return node.key 

188 

189 parent_key = '' 

190 parent = popattr(node, 'pParent') 

191 if parent: 

192 parent_key = self.make_key(parent) 

193 

194 node.key = parent_key + '/' + node.name.lower() 

195 return node.key 

196 

197 def filter_category_roots(self): 

198 """Keep only nodes under the known category roots and shorten their keys.""" 

199 

200 new_nodes = [] 

201 roots = {'/' + to_name(k).lower(): '/' + v for k, v in CATEGORY_ROOTS.items()} 

202 

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) 

209 

210 self.nodes = new_nodes 

211 

212 def resolve_supers(self): 

213 """Replace the super class names of class nodes with the super class nodes.""" 

214 

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) 

220 

221 def check_flag(self, node, prop, root): 

222 """Compute a flag that is set for a root class and all of its subclasses. 

223 

224 The flag is stored on the node. 

225 

226 Args: 

227 node: Schema node. 

228 prop: Flag attribute name. 

229 root: Name of the root class. 

230 

231 Returns: 

232 The flag value. 

233 """ 

234 

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 

246 

247 def find_node(self, name): 

248 """Find a node by name. 

249 

250 Args: 

251 name: Node name. 

252 

253 Returns: 

254 The first node with this name, or ``None``. 

255 """ 

256 

257 for node in self.nodes: 

258 if node.name == name: 

259 return node 

260 

261 def get_doc(self, rec): 

262 """Return the documentation text of a model record. 

263 

264 Args: 

265 rec: Model record. 

266 

267 Returns: 

268 The unescaped text without a leading ``[X]`` marker. 

269 """ 

270 

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 

279 

280 def get_hname(self, node): 

281 """Return the human-readable name of a node, taken from its documentation. 

282 

283 The first quoted word or sentence of the documentation is used if it 

284 matches the node name. 

285 

286 Args: 

287 node: Schema node. 

288 

289 Returns: 

290 The name, or ``None`` if not found. 

291 """ 

292 

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'... 

298 

299 if not node.doc: 

300 return 

301 

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() 

305 

306 patterns = [ 

307 r"^\'(.+?)\'", 

308 r"^\"(.+?)\"", 

309 r"^(.+?)\.$", 

310 r"^(\w+)", 

311 ] 

312 

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 

320 

321 if node.name == 'funktion': 

322 # fix a spelling mistake in some docstrings 

323 return 'Funktion' 

324 

325 def add_enum_value(self, node, k, v): 

326 """Add a value to an enumeration node. 

327 

328 Args: 

329 node: Schema node. 

330 k: Value code. If ``None``, the next sequence number is used. 

331 v: Value text. 

332 """ 

333 

334 if k is None: 

335 k = len(node.values) + 1 

336 node.values[k] = v 

337 

338 def set_type_from_record(self, node, rec): 

339 """Set the type of an attribute node from a model record. 

340 

341 Args: 

342 node: Schema node. 

343 rec: Model record. 

344 """ 

345 

346 self.set_type_from_string(node, rec.get('type', '') or rec.get('Type', '')) 

347 

348 def set_type_from_string(self, node, s): 

349 """Set the type of an attribute node from a type string. 

350 

351 ``Sequence<X>`` and ``Set<X>`` set the type ``X`` and the list flag. 

352 

353 Args: 

354 node: Schema node. 

355 s: Type string. 

356 """ 

357 

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 

364 

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. 

367 

368 Args: 

369 node: Schema node. 

370 s: Cardinality, e.g. ``0..1`` or ``0..*``. 

371 """ 

372 

373 if not s: 

374 return 

375 elif s == '0..1': 

376 node.optional = True 

377 elif '..' in s: 

378 node.list = True 

379 

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. 

382 

383 Args: 

384 node: Schema node. 

385 rec: Model record with ``LowerBound`` and ``UpperBound``. 

386 """ 

387 

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 

394 

395 

396class Parser6(Parser): 

397 """Parser for GeoInfoDok 6 Rational Rose ``.cat`` files.""" 

398 

399 def parse(self, paths): 

400 """Parse model files. 

401 

402 Args: 

403 paths: Paths to ``.cat`` files. 

404 

405 Returns: 

406 A list of nodes. 

407 """ 

408 

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() 

414 

415 def parse_object(self, rec, parent): 

416 """Create a node and its child nodes from a ``.cat`` record. 

417 

418 Args: 

419 rec: Parsed ``.cat`` record. 

420 parent: Parent node. 

421 

422 Returns: 

423 The new node. 

424 """ 

425 

426 node = Node(name=rec['NAME'], pParent=parent, doc=self.get_doc(rec)) 

427 self.nodes.append(node) 

428 

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)) 

434 

435 node.hname = self.get_hname(node) 

436 node.name = to_name(node.name) 

437 

438 for a in rec.get('attributes', []): 

439 if a['name'] == 'Kennung': 

440 node.uid = a['value'] 

441 

442 stereo = rec.get('stereotype', '').lower() 

443 

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) 

448 

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']) 

454 

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 ] 

461 

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 ] 

473 

474 elif rec['TYPE'] == 'ClassAttribute': 

475 self.set_type_from_record(node, rec) 

476 

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')) 

480 

481 return node 

482 

483 def parse_associations(self, rec): 

484 """Add association roles as attributes to the classes they connect. 

485 

486 Args: 

487 rec: Parsed ``.cat`` record, searched recursively for associations. 

488 """ 

489 

490 if rec['TYPE'] != 'Association': 

491 for o2 in rec.get('logical_models', []): 

492 self.parse_associations(o2) 

493 return 

494 

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 # } 

518 

519 role1 = rec['roles'][0] 

520 role2 = rec['roles'][1] 

521 

522 type1 = role1['supplier'].split(':')[-1] 

523 type2 = role2['supplier'].split(':')[-1] 

524 

525 cls1 = self.find_node(type1) 

526 cls2 = self.find_node(type2) 

527 

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)) 

532 

533 

534## 

535 

536class Parser7(Parser): 

537 """Parser for GeoInfoDok 7 Enterprise Architect ``.qea`` (sqlite) files.""" 

538 

539 engine: sa.Engine 

540 """Engine for the current file.""" 

541 

542 def parse(self, paths): 

543 """Parse model files. 

544 

545 Args: 

546 paths: Paths to ``.qea`` files. 

547 

548 Returns: 

549 A list of nodes. 

550 """ 

551 

552 for path in paths: 

553 self.engine = sa.create_engine(f'sqlite:///' + path) 

554 self.build_from_sqlite() 

555 return self.finalize() 

556 

557 def select(self, table): 

558 """Read all rows of a table. 

559 

560 Args: 

561 table: Table name. 

562 

563 Returns: 

564 A list of row mappings. 

565 """ 

566 

567 with self.engine.begin() as conn: 

568 sel = sa.text(f'SELECT * FROM {table}') 

569 return list(conn.execute(sel).mappings().all()) 

570 

571 def build_from_sqlite(self): 

572 """Create nodes from the objects, attributes and connectors in the current file.""" 

573 

574 

575 nodes_by_uid = {} 

576 nodes_by_gid = {} 

577 

578 for rec in self.select('t_object'): 

579 if rec['Alias']: 

580 continue 

581 

582 node = Node(name=rec['Name'], doc=self.get_doc(rec)) 

583 self.nodes.append(node) 

584 

585 node.hname = self.get_hname(node) 

586 node.name = to_name(node.name) 

587 

588 node.Package_ID = rec['Package_ID'] 

589 

590 nodes_by_uid[rec['Object_ID']] = node 

591 nodes_by_gid[rec['ea_guid']] = node 

592 

593 if rec['Object_Type'] == 'Package': 

594 node.T = T_CATEGORY 

595 continue 

596 

597 stereo = (rec['Stereotype'] or '').lower() 

598 

599 if rec['Object_Type'] == 'Enumeration' or stereo in {'enumeration', 'codelist'}: 

600 node.T = T_ENUM 

601 node.values = {} 

602 continue 

603 

604 if rec['Object_Type'] == 'Class' and stereo == 'union': 

605 node.T = T_UNION 

606 node.attributes = [] 

607 continue 

608 

609 if rec['Object_Type'] == 'Class': 

610 node.T = T_CLASS 

611 node.attributes = [] 

612 node.supers = [] 

613 node.pSuperNames = [] 

614 continue 

615 

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'] 

621 

622 package_uid_to_gid = {} 

623 

624 for rec in self.select('t_package'): 

625 package_uid_to_gid[rec['Package_ID']] = rec['ea_guid'] 

626 

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 

633 

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']) 

645 

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']) 

649 

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 

654 

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) 

670 

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) 

675 

676 

677class CatParser: 

678 """Parser for Rational Rose ``.cat`` files. 

679 

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 """ 

684 

685 def parse(self, path): 

686 """Parse a ``.cat`` file. 

687 

688 Args: 

689 path: File path. 

690 

691 Returns: 

692 A list of top-level items. 

693 """ 

694 

695 with open(path, 'rb') as fp: 

696 text = fp.read().decode('latin-1') 

697 self.tokenize(text) 

698 return self.parse_sequence() 

699 

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.""" 

712 

713 tokens = [] 

714 """Tokens as ``(bracket, name, value)`` tuples.""" 

715 token_pos = 0 

716 """Position of the current token.""" 

717 

718 def tokenize(self, text): 

719 """Split a ``.cat`` file into tokens. 

720 

721 Lines starting with ``|`` are collected into a single string value. 

722 

723 Args: 

724 text: File content. 

725 """ 

726 

727 docstring_buf = [] 

728 self.tokens = [] 

729 

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)) 

746 

747 def tok(self): 

748 """Return the current token. 

749 

750 Returns: 

751 A ``(bracket, name, value)`` tuple. 

752 """ 

753 

754 return self.tokens[self.token_pos] 

755 

756 def pop(self): 

757 """Advance to the next token.""" 

758 

759 self.token_pos += 1 

760 

761 def eof(self): 

762 """Check whether all tokens are consumed. 

763 

764 Returns: 

765 ``True`` at the end of the token list. 

766 """ 

767 

768 return self.token_pos >= len(self.tokens) 

769 

770 ## 

771 

772 def parse_sequence(self): 

773 """Parse items up to the closing bracket or the end of input. 

774 

775 Returns: 

776 A list of items. 

777 """ 

778 

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 

787 

788 def parse_item(self): 

789 """Parse a single item: a value, a boolean, a list, an object or a sequence. 

790 

791 Returns: 

792 The parsed item. 

793 

794 Raises: 

795 ``SyntaxError``: If the token cannot start an item. 

796 """ 

797 

798 br, name, val = self.tok() 

799 if val: 

800 self.pop() 

801 return val 

802 

803 if name in {'TRUE', 'FALSE'}: 

804 self.pop() 

805 return name == 'TRUE' 

806 

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() 

819 

820 # (val val...) 

821 return self.parse_sequence() 

822 

823 raise SyntaxError(f'invalid token {br=} {name=} {val=}') 

824 

825 def parse_list(self): 

826 """Parse a ``(list <type> ...)`` item. 

827 

828 Returns: 

829 A list of items. 

830 """ 

831 

832 # e.g. (list Attribute_Set (object... (object... 

833 

834 self.pop() # list 

835 self.pop() # type 

836 

837 return self.parse_sequence() 

838 

839 def parse_object(self): 

840 """Parse an ``(object <type> <name> ...)`` item. 

841 

842 Returns: 

843 A dict with ``TYPE``, ``NAME`` and the object properties. 

844 """ 

845 

846 # e.g. (object ClassAttribute "Sonstiges" attr val attr val 

847 # e.g. (object Attribute 

848 

849 rec = {} 

850 

851 self.pop() # object 

852 

853 br, name, val = self.tok() 

854 rec['TYPE'] = name 

855 self.pop() 

856 

857 br, name, val = self.tok() 

858 if val: 

859 rec['NAME'] = val 

860 self.pop() 

861 

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() 

868 

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() 

876 

877 return rec 

878 

879 def parse_value(self): 

880 """Parse a ``(value <type> <value>)`` item. 

881 

882 Returns: 

883 The value. 

884 """ 

885 

886 # e.g. (value Text "30000") 

887 

888 self.pop() # value 

889 self.pop() # type 

890 

891 val = self.parse_item() 

892 self.pop() # ) 

893 

894 return val 

895 

896 

897class PythonGenerator: 

898 """Generator for the Python schema module.""" 

899 

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.""" 

914 

915 def __init__(self, nodes, version: str): 

916 """Create a generator. 

917 

918 Args: 

919 nodes: Parsed nodes. 

920 version: GeoInfoDok version. 

921 """ 

922 

923 self.nodes = nodes 

924 self.version = version 

925 

926 def build(self): 

927 """Generate the module source. 

928 

929 Returns: 

930 The Python source code. 

931 """ 

932 

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 ) 

938 

939 self.nameToNode = {node.name: node for node in self.nodes} 

940 self.keyToNode = {node.key: node for node in self.nodes} 

941 

942 nodes = sorted(self.nodes, key=lambda n: n.name) 

943 self.make_nodes(nodes) 

944 

945 py = nl(self.py) 

946 

947 py = re.sub(r'(\n\w+: TypeAlias)', '\n\n\\1', py) 

948 py = re.sub(r'(\nclass )', '\n\n\\1', py) 

949 

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 '', 

965 

966 ]) 

967 

968 return py.replace('<VERSION>', self.version) 

969 

970 def make_nodes(self, nodes): 

971 """Generate unions, categories, enumerations and classes, in this order. 

972 

973 Args: 

974 nodes: Nodes, sorted by name. 

975 """ 

976 

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) 

981 

982 def make_node(self, node): 

983 """Generate a node and its metadata, unless it is already generated. 

984 

985 Args: 

986 node: Schema node. 

987 """ 

988 

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) 

994 

995 def make_union(self, node): 

996 """Generate a type alias for a union node. 

997 

998 Args: 

999 node: Schema node. 

1000 """ 

1001 

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) + ']' 

1004 

1005 self.py.append(f'{node.name}: TypeAlias = {typ}') 

1006 self.py.append(self.get_docstring(node, '', False)) 

1007 

1008 def make_category(self, node): 

1009 """Generate a class for a category node. 

1010 

1011 Args: 

1012 node: Schema node. 

1013 """ 

1014 

1015 self.py.append(f'class {node.name}(Category):') 

1016 self.py.append(self.get_docstring(node, TAB, True)) 

1017 

1018 def make_enum(self, node): 

1019 """Generate a class with a ``VALUES`` dict for an enumeration node. 

1020 

1021 Args: 

1022 node: Schema node. 

1023 """ 

1024 

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}}}') 

1031 

1032 def make_class(self, node): 

1033 """Generate a class with attribute annotations for a class node. 

1034 

1035 Super classes are generated first. 

1036 

1037 Args: 

1038 node: Schema node. 

1039 """ 

1040 

1041 node.attributes = node.attributes or [] 

1042 

1043 super_types = [] 

1044 

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)) 

1048 

1049 cls = f'class {node.name}' 

1050 if super_types: 

1051 cls += '(' + comma(super_types) + ')' 

1052 else: 

1053 cls += '(Object)' 

1054 

1055 self.py.append(cls + ':') 

1056 self.py.append(self.get_docstring(node, TAB, True)) 

1057 

1058 if node.name == 'AA_REO': 

1059 self.py.append('') 

1060 self.py.append(f'{TAB}geom: str') 

1061 

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)) 

1071 

1072 def make_metadata(self, node): 

1073 """Add the metadata of a node to ``metadata``. 

1074 

1075 Args: 

1076 node: Schema node. 

1077 """ 

1078 

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 } 

1086 

1087 if node.T == T_CLASS: 

1088 d.update(self.make_class_metadata(node)) 

1089 

1090 self.metadata[node.name] = d 

1091 

1092 def make_class_metadata(self, node): 

1093 """Return the class-specific metadata of a class node. 

1094 

1095 Args: 

1096 node: Schema node. 

1097 

1098 Returns: 

1099 A dict with ``kind``, ``geom``, ``attributes`` and ``supers``. 

1100 """ 

1101 

1102 d = {} 

1103 

1104 d['kind'] = 'object' if node.is_aa else 'struct' 

1105 d['geom'] = 1 if node.is_reo else 0 

1106 d['attributes'] = [] 

1107 

1108 d['supers'] = [sup.name for sup in node.supers] 

1109 

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 }) 

1117 

1118 return d 

1119 

1120 def get_type(self, typ, quoted=True): 

1121 """Return the annotation for a type name. 

1122 

1123 Types not defined in the model are added to ``unknownTypes``. 

1124 

1125 Args: 

1126 typ: Type name. 

1127 quoted: Whether to quote model type names. 

1128 

1129 Returns: 

1130 The annotation, ``Any`` for an empty type. 

1131 """ 

1132 

1133 if not typ: 

1134 return 'Any' 

1135 

1136 if hasattr(__builtins__, typ): 

1137 return typ 

1138 

1139 if typ in STD_TYPES or typ in self.knownTypes: 

1140 return quote(typ) if quoted else typ 

1141 

1142 self.unknownTypes.add(typ) 

1143 return quote(typ) if quoted else typ 

1144 

1145 def get_docstring(self, node, indent, prepend_name): 

1146 """Return the docstring source of a node. 

1147 

1148 Args: 

1149 node: Schema node. 

1150 indent: Indentation. 

1151 prepend_name: Whether to put the human-readable name before the documentation. 

1152 

1153 Returns: 

1154 The wrapped and indented docstring. 

1155 """ 

1156 

1157 name = node.hname or node.name or ' ' 

1158 

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 

1165 

1166 if s.endswith('"'): 

1167 s += ' ' 

1168 return wrap_indent(Q3 + s + Q3, indent) 

1169 

1170 

1171## 

1172 

1173 

1174def popattr(obj, attr, default=None): 

1175 """Remove an attribute from an object and return its value. 

1176 

1177 Args: 

1178 obj: Object. 

1179 attr: Attribute name. 

1180 default: Value to return if the attribute is not set. 

1181 

1182 Returns: 

1183 The attribute value or the default. 

1184 """ 

1185 

1186 return obj.__dict__.pop(attr, default) 

1187 

1188 

1189def wrap_indent(s, indent): 

1190 """Wrap each line of a string and indent it. 

1191 

1192 Args: 

1193 s: Text. 

1194 indent: Indentation. 

1195 

1196 Returns: 

1197 The wrapped text. 

1198 """ 

1199 

1200 return nl( 

1201 nl(indent + ln for ln in textwrap.wrap(p.strip(), WRAP_WIDTH)) 

1202 for p in s.split('\n') 

1203 ) 

1204 

1205 

1206def quote(s): 

1207 """Wrap a string in single quotes. 

1208 

1209 Args: 

1210 s: String. 

1211 

1212 Returns: 

1213 The quoted string. 

1214 """ 

1215 

1216 return "'" + (s or '') + "'" 

1217 

1218 

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} 

1228 

1229 

1230def to_name(s): 

1231 """Convert a string to a Python identifier. 

1232 

1233 Umlauts are transliterated and non-word characters are replaced by ``_``. 

1234 

1235 Args: 

1236 s: String. 

1237 

1238 Returns: 

1239 The identifier, an empty string for an empty input. 

1240 """ 

1241 

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 

1254 

1255 

1256def json_dict_body(d, indent): 

1257 """Format a dict as indented JSON, without the enclosing braces. 

1258 

1259 Args: 

1260 d: Dict. 

1261 indent: Indentation of the entries. 

1262 

1263 Returns: 

1264 The formatted entries. 

1265 """ 

1266 

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) 

1270 

1271 

1272comma = ', '.join 

1273nl = '\n'.join 

1274 

1275## 

1276 

1277if __name__ == '__main__': 

1278 main(sys.argv[1], *sys.argv[2:])