Coverage for gws-app/gws/plugin/alkis/data/geo_info_dok/generator.py: 0%
503 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-17 01:37 +0200
1"""Schema generator.
3Generate python APIs and object databases from GeoInfoDok sources.
5For version 6 use RR cat files Basisschema.cat and Fachschema.cat
6For version 7 use the QEA (sqlite) file AAA-7.1.2.qea
8Usage::
10 generator.py 6 /path/to/Basisschema.cat /path/to/Fachschema.cat
11 generator.py 7 /path/to/AAA-7.1.2.qea
13"""
15import re
16import os
17import json
18import textwrap
19import sys
20import html
21import sqlalchemy as sa
24def main(version, *paths):
25 if version == '6':
26 nodes = Parser6().parse(paths)
28 elif version == '7':
29 nodes = Parser7().parse(paths)
31 else:
32 raise ValueError('invalid version')
34 # dumps(f'{CDIR}/db{version}.json', db)
36 py = PythonGenerator(nodes, version).build()
38 with open(f'{CDIR}/gid{version}.py', 'w') as fp:
39 fp.write(py)
42##
44CDIR = os.path.dirname(__file__)
45TAB = ' ' * 4
46TAB2 = TAB * 2
47Q3 = '"""'
48WRAP_WIDTH = 110
50CATEGORY_ROOTS = {
51 'AFIS-ALKIS-ATKIS Fachschema': 'fs',
52 'AAA Basisschema': 'bs',
53 'AAA_Objektartenkatalog': 'ak',
54}
56T_CLASS = 'class'
57T_CATEGORY = 'category'
58T_ENUM = 'enum'
59T_UNION = 'union'
61PY_HEAD = '''\
62"""GeoInfoDok <VERSION> schema.
64(c) 2023 Arbeitsgemeinschaft der Vermessungsverwaltungen der Länder der Bundesrepublik Deutschland
66https://www.adv-online.de/GeoInfoDok/
68This code is automatically generated from .CAT/.QEA source files.
69"""
71from typing import Any, Literal, Optional, TypeAlias, Union
72from datetime import date, datetime
75# gws:nospec
77class Object:
78 pass
81class Category:
82 pass
85class Enumeration:
86 pass
89def object__getattr__(self, item):
90 if item.startswith('_'):
91 raise AttributeError()
92 return None
95setattr(Object, '__getattr__', object__getattr__)
96setattr(Category, '__getattr__', object__getattr__)
97setattr(Enumeration, '__getattr__', object__getattr__)
99'''
101STD_TYPES = {
102 'Angle': 'float',
103 'Area': 'float',
104 'Boolean': 'bool',
105 'CharacterString': 'str',
106 'Date': 'date',
107 'DateTime': 'datetime',
108 'Distance': 'float',
109 'GenericName': 'str',
110 'Integer': 'int',
111 'Length': 'int',
112 'LocalName': 'str',
113 'Measure': 'str',
114 'Query': 'str',
115 'Real': 'float',
116 'SC_CRS': 'str',
117 'URI': 'str',
118 'URL': 'str',
119 'Volume': 'float',
120}
123##
125class Node:
126 def __init__(self, **kwargs):
127 vars(self).update(kwargs)
129 def __getattr__(self, item):
130 return None
133##
135class Parser:
136 nodes: list[Node] = []
138 def finalize(self):
139 for node in self.nodes:
140 self.make_key(node)
142 self.filter_category_roots()
143 self.resolve_supers()
145 for node in self.nodes:
146 self.check_flag(node, 'is_aa', 'AA_Objekt')
147 self.check_flag(node, 'is_reo', 'AA_REO')
149 return [node for node in self.nodes if node.T]
151 def make_key(self, node):
152 if node.key:
153 return node.key
155 parent_key = ''
156 parent = popattr(node, 'pParent')
157 if parent:
158 parent_key = self.make_key(parent)
160 node.key = parent_key + '/' + node.name.lower()
161 return node.key
163 def filter_category_roots(self):
164 new_nodes = []
165 roots = {'/' + to_name(k).lower(): '/' + v for k, v in CATEGORY_ROOTS.items()}
167 for node in self.nodes:
168 for k, v in roots.items():
169 if k in node.key:
170 _, _, rest = node.key.partition(k)
171 node.key = v + rest
172 new_nodes.append(node)
174 self.nodes = new_nodes
176 def resolve_supers(self):
177 for node in self.nodes:
178 for sup_name in popattr(node, 'pSuperNames', []):
179 sup = self.find_node(sup_name)
180 if sup and sup != node:
181 node.supers.append(sup)
183 def check_flag(self, node, prop, root):
184 a = getattr(node, prop)
185 if a is not None:
186 return a
187 if node.name == root:
188 v = True
189 elif node.supers:
190 v = any(self.check_flag(super_node, prop, root) for super_node in node.supers)
191 else:
192 v = False
193 setattr(node, prop, v)
194 return v
196 def find_node(self, name):
197 for node in self.nodes:
198 if node.name == name:
199 return node
201 def get_doc(self, rec):
202 s = rec.get('documentation') or rec.get('Note') or rec.get('Notes') or ''
203 s = s.strip()
204 s = html.unescape(s)
205 # remove the [X] prefix, as in
206 # "[E] 'Person' ist eine natürliche...
207 if s.startswith('['):
208 return s.partition(']')[-1].strip()
209 return s
211 def get_hname(self, node):
212 # sometimes, the first quoted word in the name of the object, as in
213 # sonstigeEigenschaft: "'Sonstige Eigenschaft' sind Informationen zum Grenzpunkt...
214 #
215 # however, this should not be extracted
216 # weistAuf: "'Flurstück' weist auf 'Lagebezeichnung mit Hausnummer'...
218 if not node.doc:
219 return
221 cmp_name = to_name(node.name)
222 cmp_name = re.sub(r'[A-Z]+_(.+)', r'\1', cmp_name)
223 cmp_name = cmp_name.replace('_', '').lower()
225 patterns = [
226 r"^\'(.+?)\'",
227 r"^\"(.+?)\"",
228 r"^(.+?)\.$",
229 r"^(\w+)",
230 ]
232 for pat in patterns:
233 m = re.match(pat, node.doc)
234 if m:
235 s = m.group(1)
236 c = to_name(s).replace('_', '').lower()
237 if c == cmp_name:
238 return s
240 if node.name == 'funktion':
241 # fix a spelling mistake in some docstrings
242 return 'Funktion'
244 def add_enum_value(self, node, k, v):
245 if k is None:
246 k = len(node.values) + 1
247 node.values[k] = v
249 def set_type_from_record(self, node, rec):
250 self.set_type_from_string(node, rec.get('type', '') or rec.get('Type', ''))
252 def set_type_from_string(self, node, s):
253 m = re.match(r'(Sequence|Set)<(.+?)>', s)
254 if m:
255 node.type = m.group(2)
256 node.list = True
257 else:
258 node.type = s
260 def set_cardinality_from_string(self, node, s=None):
261 if not s:
262 return
263 elif s == '0..1':
264 node.optional = True
265 elif '..' in s:
266 node.list = True
268 def set_cardinality_from_record(self, node, rec):
269 lb = str(rec['LowerBound'])
270 ub = str(rec['UpperBound'])
271 if lb == '0' and ub == '1':
272 node.optional = True
273 elif lb != ub:
274 node.list = True
277class Parser6(Parser):
278 def parse(self, paths):
279 for path in paths:
280 cat = CatParser().parse(path)
281 self.parse_object(cat[1], None)
282 self.parse_associations(cat[1])
283 return self.finalize()
285 def parse_object(self, rec, parent):
286 node = Node(name=rec['NAME'], pParent=parent, doc=self.get_doc(rec))
287 self.nodes.append(node)
289 # e.g. zugriffsartProduktkennungBenutzung [0..*]
290 m = re.match(r'^(\S+)\s*\[(.+?)]$', node.name)
291 if m:
292 node.name = m.group(1)
293 self.set_cardinality_from_string(node, m.group(2))
295 node.hname = self.get_hname(node)
296 node.name = to_name(node.name)
298 for a in rec.get('attributes', []):
299 if a['name'] == 'Kennung':
300 node.uid = a['value']
302 stereo = rec.get('stereotype', '').lower()
304 if rec['TYPE'] == 'Class_Category':
305 node.T = T_CATEGORY
306 for r2 in rec.get('logical_models', []):
307 self.parse_object(r2, node)
309 elif rec['TYPE'] == 'Class' and stereo in {'codelist', 'enumeration'}:
310 node.T = T_ENUM
311 node.values = {}
312 for r2 in rec.get('class_attributes', []):
313 self.add_enum_value(node, r2.get('initv'), r2['NAME'])
315 elif rec['TYPE'] == 'Class' and stereo == 'union':
316 node.T = T_UNION
317 node.attributes = [
318 self.parse_object(r2, node)
319 for r2 in rec.get('class_attributes', [])
320 ]
322 elif rec['TYPE'] == 'Class':
323 node.T = T_CLASS
324 node.attributes = [
325 self.parse_object(r2, node)
326 for r2 in rec.get('class_attributes', [])
327 ]
328 node.supers = []
329 node.pSuperNames = [
330 r2['supplier'].split(':')[-1]
331 for r2 in rec.get('superclasses', [])
332 ]
334 elif rec['TYPE'] == 'ClassAttribute':
335 self.set_type_from_record(node, rec)
337 elif rec['TYPE'] == 'Role':
338 self.set_type_from_string(node, rec['supplier'].split(':')[-1])
339 self.set_cardinality_from_string(node, rec.get('client_cardinality'))
341 return node
343 def parse_associations(self, rec):
344 if rec['TYPE'] != 'Association':
345 for o2 in rec.get('logical_models', []):
346 self.parse_associations(o2)
347 return
349 # This is what an association looks like in a parsed .cat:
350 #
351 # {
352 # "NAME": "$UNNAMED$38",
353 # "TYPE": "Association",
354 # "quid": "40FED632018C",
355 # "roles": [
356 # {
357 # "NAME": "weistZum",
358 # "TYPE": "Role",
359 # "client_cardinality": "0..1",
360 # "documentation": "Eine 'Lagebezeichnung mit Hausnummer' weist zum 'Turm'.",
361 # "supplier": "...:AX_Turm"
362 # },
363 # {
364 # "NAME": "zeigtAuf",
365 # "TYPE": "Role",
366 # "client_cardinality": "0..*",
367 # "documentation": "'Turm' zeigt auf eine 'Lagebezeichnung mit Hausnummer'.",
368 # "supplier": "...:AX_LagebezeichnungMitHausnummer"
369 # }
370 # ]
371 # }
373 role1 = rec['roles'][0]
374 role2 = rec['roles'][1]
376 type1 = role1['supplier'].split(':')[-1]
377 type2 = role2['supplier'].split(':')[-1]
379 cls1 = self.find_node(type1)
380 cls2 = self.find_node(type2)
382 if cls2 and not role1['NAME'].startswith('$') and cls2.attributes is not None:
383 cls2.attributes.append(self.parse_object(role1, cls2))
384 if cls1 and not role2['NAME'].startswith('$') and cls1.attributes is not None:
385 cls1.attributes.append(self.parse_object(role2, cls1))
388##
390class Parser7(Parser):
391 engine: sa.Engine
393 def parse(self, paths):
394 for path in paths:
395 self.engine = sa.create_engine(f'sqlite:///' + path)
396 self.build_from_sqlite()
397 return self.finalize()
399 def select(self, table):
400 with self.engine.begin() as conn:
401 sel = sa.text(f'SELECT * FROM {table}')
402 return list(conn.execute(sel).mappings().all())
404 def build_from_sqlite(self):
406 nodes_by_uid = {}
407 nodes_by_gid = {}
409 for rec in self.select('t_object'):
410 if rec['Alias']:
411 continue
413 node = Node(name=rec['Name'], doc=self.get_doc(rec))
414 self.nodes.append(node)
416 node.hname = self.get_hname(node)
417 node.name = to_name(node.name)
419 node.Package_ID = rec['Package_ID']
421 nodes_by_uid[rec['Object_ID']] = node
422 nodes_by_gid[rec['ea_guid']] = node
424 if rec['Object_Type'] == 'Package':
425 node.T = T_CATEGORY
426 continue
428 stereo = (rec['Stereotype'] or '').lower()
430 if rec['Object_Type'] == 'Enumeration' or stereo in {'enumeration', 'codelist'}:
431 node.T = T_ENUM
432 node.values = {}
433 continue
435 if rec['Object_Type'] == 'Class' and stereo == 'union':
436 node.T = T_UNION
437 node.attributes = []
438 continue
440 if rec['Object_Type'] == 'Class':
441 node.T = T_CLASS
442 node.attributes = []
443 node.supers = []
444 node.pSuperNames = []
445 continue
447 for rec in self.select('t_objectproperties'):
448 if rec['Property'] == 'AAA:Kennung' and rec['Value']:
449 node = nodes_by_uid.get(rec['Object_ID'])
450 if node:
451 node.uid = rec['Value']
453 package_uid_to_gid = {}
455 for rec in self.select('t_package'):
456 package_uid_to_gid[rec['Package_ID']] = rec['ea_guid']
458 for node in self.nodes:
459 pkg_gid = package_uid_to_gid.get(popattr(node, 'Package_ID'))
460 if pkg_gid:
461 pkg_node = nodes_by_gid.get(pkg_gid)
462 if pkg_node:
463 node.pParent = pkg_node
465 for rec in self.select('t_attribute'):
466 node = nodes_by_uid.get(rec['Object_ID'])
467 if node:
468 if node.T in {T_CLASS, T_UNION}:
469 a = Node(name=rec['Name'], doc=self.get_doc(rec), pParent=node)
470 self.set_type_from_record(a, rec)
471 self.set_cardinality_from_record(a, rec)
472 node.attributes.append(a)
473 self.nodes.append(a)
474 if node.T == T_ENUM:
475 self.add_enum_value(node, rec['Default'], rec['Name'])
477 for rec in self.select('t_connector'):
478 so = nodes_by_uid.get(rec['Start_Object_ID'])
479 eo = nodes_by_uid.get(rec['End_Object_ID'])
481 if so and eo and so.T == eo.T == T_CLASS:
482 if rec['Connector_Type'] == 'Generalization':
483 so.pSuperNames.append(eo.name)
484 continue
486 if rec['Connector_Type'] == 'Association':
487 """
488 "SourceCard": "0..*",
489 "SourceRole": "zeigtAuf",
490 "SourceRoleNote": "'Turm' zeigt auf eine 'Lagebezeichnung mit Hausnummer'.",
491 "DestRole": "weistZum",
492 "DestRoleNote": "Eine 'Lagebezeichnung mit Hausnummer' weist zum 'Turm'.",
493 "Start_Object_ID": 3678,
494 "End_Object_ID": 3511,
495 """
496 if rec['SourceRole']:
497 a = Node(name=rec['SourceRole'], doc=rec['SourceRoleNote'], type=so.name, pParent=eo)
498 self.set_cardinality_from_string(a, rec['SourceCard'])
499 eo.attributes.append(a)
500 self.nodes.append(a)
502 if rec['DestRole']:
503 b = Node(name=rec['DestRole'], doc=rec['DestRoleNote'], type=eo.name, pParent=so)
504 so.attributes.append(b)
505 self.nodes.append(b)
508class CatParser:
509 """Parser for RR cat files."""
511 def parse(self, path):
512 with open(path, 'rb') as fp:
513 text = fp.read().decode('latin-1')
514 self.tokenize(text)
515 return self.parse_sequence()
517 re_token = r'''(?x)
518 ( [()] )
519 |
520 ( [_a-zA-Z] \w* )
521 |
522 (
523 " (?: \\. | [^"] )* "
524 |
525 [^()\s]+
526 )
527 '''
529 tokens = []
530 token_pos = 0
532 def tokenize(self, text):
533 docstring_buf = []
534 self.tokens = []
536 for n, ln in enumerate(text.split('\n'), 1):
537 ln = ln.strip()
538 if not ln:
539 continue
540 if ln.startswith('|'):
541 docstring_buf.append(ln[1:])
542 continue
543 if docstring_buf:
544 s = '\n'.join(p for p in docstring_buf if p).strip() or ' '
545 self.tokens.append(('', '', s))
546 docstring_buf = []
547 for br, name, val in re.findall(self.re_token, ln):
548 if val.startswith('"'):
549 # decode a string, don't allow empty strings
550 val = val[1:-1].replace('\\', '') or ' '
551 self.tokens.append((br, name, val))
553 def tok(self):
554 return self.tokens[self.token_pos]
556 def pop(self):
557 self.token_pos += 1
559 def eof(self):
560 return self.token_pos >= len(self.tokens)
562 ##
564 def parse_sequence(self):
565 items = []
566 while not self.eof():
567 br, name, val = self.tok()
568 if br == ')':
569 self.pop()
570 break
571 items.append(self.parse_item())
572 return items
574 def parse_item(self):
575 br, name, val = self.tok()
576 if val:
577 self.pop()
578 return val
580 if name in {'TRUE', 'FALSE'}:
581 self.pop()
582 return name == 'TRUE'
584 if br == '(':
585 self.pop()
586 br, name, val = self.tok()
587 if name == 'list':
588 # (list ...
589 return self.parse_list()
590 if name == 'object':
591 # (object ...
592 return self.parse_object()
593 if name == 'value':
594 # (value ...
595 return self.parse_value()
597 # (val val...)
598 return self.parse_sequence()
600 raise SyntaxError(f'invalid token {br=} {name=} {val=}')
602 def parse_list(self):
603 # e.g. (list Attribute_Set (object... (object...
605 self.pop() # list
606 self.pop() # type
608 return self.parse_sequence()
610 def parse_object(self):
611 # e.g. (object ClassAttribute "Sonstiges" attr val attr val
612 # e.g. (object Attribute
614 rec = {}
616 self.pop() # object
618 br, name, val = self.tok()
619 rec['TYPE'] = name
620 self.pop()
622 br, name, val = self.tok()
623 if val:
624 rec['NAME'] = val
625 self.pop()
627 # evtl. more strings after name, ignore them
628 while not self.eof():
629 br, name, val = self.tok()
630 if not val:
631 break
632 self.pop()
634 while not self.eof():
635 br, name, val = self.tok()
636 if br == ')':
637 self.pop()
638 break
639 self.pop()
640 rec[name] = self.parse_item()
642 return rec
644 def parse_value(self):
645 # e.g. (value Text "30000")
647 self.pop() # value
648 self.pop() # type
650 val = self.parse_item()
651 self.pop() # )
653 return val
656class PythonGenerator:
657 unknownTypes = set()
658 knownTypes = set()
659 nameToNode = {}
660 keyToNode = {}
661 seen = set()
662 metadata = {}
663 py = []
665 def __init__(self, nodes, version: str):
666 self.nodes = nodes
667 self.version = version
669 def build(self):
670 self.knownTypes = set(
671 node.name
672 for node in self.nodes
673 if node.T in {T_CLASS, T_ENUM, T_UNION}
674 )
676 self.nameToNode = {node.name: node for node in self.nodes}
677 self.keyToNode = {node.key: node for node in self.nodes}
679 nodes = sorted(self.nodes, key=lambda n: n.name)
680 self.make_nodes(nodes)
682 py = nl(self.py)
684 py = re.sub(r'(\n\w+: TypeAlias)', '\n\n\\1', py)
685 py = re.sub(r'(\nclass )', '\n\n\\1', py)
687 py = nl([
688 PY_HEAD,
689 *[f'{k}: TypeAlias = {v}' for k, v in sorted(STD_TYPES.items())],
690 '',
691 '',
692 *[f'class {k}: ...' for k in sorted(self.unknownTypes)],
693 '',
694 '',
695 py,
696 '',
697 '',
698 'METADATA = {',
699 json_dict_body(self.metadata, TAB),
700 '}',
701 '',
703 ])
705 return py.replace('<VERSION>', self.version)
707 def make_nodes(self, nodes):
708 for ts in T_UNION, T_CATEGORY, T_ENUM, T_CLASS:
709 for node in nodes:
710 if node.T == ts:
711 self.make_node(node)
713 def make_node(self, node):
714 if node.name not in self.seen:
715 self.seen.add(node.name)
716 fn = getattr(self, 'make_' + node.T)
717 fn(node)
718 self.make_metadata(node)
720 def make_union(self, node):
721 items = sorted(set(self.get_type(a.type) for a in node.attributes))
722 typ = items[0] if len(items) == 1 else 'Union[' + comma(items) + ']'
724 self.py.append(f'{node.name}: TypeAlias = {typ}')
725 self.py.append(self.get_docstring(node, '', False))
727 def make_category(self, node):
728 self.py.append(f'class {node.name}(Category):')
729 self.py.append(self.get_docstring(node, TAB, True))
731 def make_enum(self, node):
732 self.py.append(f'class {node.name}(Enumeration):')
733 self.py.append(self.get_docstring(node, TAB, True))
734 self.py.append('')
735 self.py.append(f'{TAB}VALUES = {{')
736 self.py.append(json_dict_body(node.values, TAB2))
737 self.py.append(f'{TAB}}}')
739 def make_class(self, node):
740 node.attributes = node.attributes or []
742 super_types = []
744 for super_node in (node.supers or []):
745 self.make_node(super_node)
746 super_types.append(self.get_type(super_node.name, quoted=False))
748 cls = f'class {node.name}'
749 if super_types:
750 cls += '(' + comma(super_types) + ')'
751 else:
752 cls += '(Object)'
754 self.py.append(cls + ':')
755 self.py.append(self.get_docstring(node, TAB, True))
757 if node.name == 'AA_REO':
758 self.py.append('')
759 self.py.append(f'{TAB}geom: str')
761 for a in sorted(node.attributes, key=lambda a: a.name):
762 typ = self.get_type(a.type)
763 if a.list:
764 typ = f'list[{typ}]'
765 if a.optional:
766 typ = f'Optional[{typ}]'
767 self.py.append('')
768 self.py.append(f"{TAB}{to_name(a.name)}: {typ}")
769 self.py.append(self.get_docstring(a, TAB, False))
771 def make_metadata(self, node):
772 d = {
773 'kind': node.T,
774 'name': node.name,
775 'uid': node.uid or '',
776 'key': node.key or '',
777 'title': node.hname or '',
778 }
780 if node.T == T_CLASS:
781 d.update(self.make_class_metadata(node))
783 self.metadata[node.name] = d
785 def make_class_metadata(self, node):
786 d = {}
788 d['kind'] = 'object' if node.is_aa else 'struct'
789 d['geom'] = 1 if node.is_reo else 0
790 d['attributes'] = []
792 d['supers'] = [sup.name for sup in node.supers]
794 for a in node.attributes:
795 d['attributes'].append({
796 'name': a.name,
797 'title': a.hname or '',
798 'type': a.type,
799 'list': 1 if a.list else 0,
800 })
802 return d
804 def get_type(self, typ, quoted=True):
805 if not typ:
806 return 'Any'
808 if hasattr(__builtins__, typ):
809 return typ
811 if typ in STD_TYPES or typ in self.knownTypes:
812 return quote(typ) if quoted else typ
814 self.unknownTypes.add(typ)
815 return quote(typ) if quoted else typ
817 def get_docstring(self, node, indent, prepend_name):
818 name = node.hname or node.name or ' '
820 if node.doc:
821 s = node.doc
822 if prepend_name and name:
823 s = name + '\n\n' + s
824 else:
825 s = name
827 if s.endswith('"'):
828 s += ' '
829 return wrap_indent(Q3 + s + Q3, indent)
832##
835def popattr(obj, attr, default=None):
836 return obj.__dict__.pop(attr, default)
839def wrap_indent(s, indent):
840 return nl(
841 nl(indent + ln for ln in textwrap.wrap(p.strip(), WRAP_WIDTH))
842 for p in s.split('\n')
843 )
846def quote(s):
847 return "'" + (s or '') + "'"
850_UID_DE_TRANS = {
851 ord('ä'): 'ae',
852 ord('ö'): 'oe',
853 ord('ü'): 'ue',
854 ord('ß'): 'ss',
855 ord('Ä'): 'Ae',
856 ord('Ö'): 'Oe',
857 ord('Ü'): 'Ue',
858}
861def to_name(s):
862 if not s:
863 return ''
864 s = str(s)
865 if re.match(r'^[A-Za-z_][A-Za-z_0-9]*$', s):
866 return s
867 s = s.strip().translate(_UID_DE_TRANS)
868 s = re.sub(r'\W+', '_', s).strip('_')
869 if not s:
870 return '_'
871 if s[0].isdigit():
872 s = '_' + s
873 return s
876def json_dict_body(d, indent):
877 js = json.dumps(d, indent=len(TAB), ensure_ascii=False).split('\n')[1:-1]
878 ind = ' ' * (len(indent) - len(TAB))
879 return nl(ind + p for p in js)
882comma = ', '.join
883nl = '\n'.join
885##
887if __name__ == '__main__':
888 main(sys.argv[1], *sys.argv[2:])