@dialecte/create 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -0
- package/dist/cli/index.js +281 -0
- package/package.json +43 -0
- package/python/generate/__init__.py +1 -0
- package/python/generate/__main__.py +103 -0
- package/python/generate/collector.py +128 -0
- package/python/generate/deriver.py +117 -0
- package/python/generate/emitters/__init__.py +1 -0
- package/python/generate/emitters/constants.py +69 -0
- package/python/generate/emitters/definition.py +49 -0
- package/python/generate/emitters/ts_helpers.py +283 -0
- package/python/generate/emitters/types.py +67 -0
- package/python/generate/extractors/__init__.py +1 -0
- package/python/generate/extractors/attributes.py +83 -0
- package/python/generate/extractors/children.py +175 -0
- package/python/generate/extractors/constraints.py +154 -0
- package/python/generate/extractors/docs.py +32 -0
- package/python/generate/extractors/facets.py +168 -0
- package/python/generate/extractors/namespace.py +59 -0
- package/python/generate/globals.py +99 -0
- package/python/generate/helpers.py +143 -0
- package/python/generate/ir.py +81 -0
- package/python/generate/orphans.py +69 -0
- package/python/generate/xpath_parser.py +167 -0
- package/python/generate/xsi_type.py +150 -0
- package/python/pyproject.toml +15 -0
- package/templates/dialecte/README.md +39 -0
- package/templates/dialecte/_gitignore +4 -0
- package/templates/dialecte/docs/.vitepress/config.ts +15 -0
- package/templates/dialecte/docs/index.md +18 -0
- package/templates/dialecte/env.d.ts +1 -0
- package/templates/dialecte/package.json +45 -0
- package/templates/dialecte/src/__version__/config/dialecte.config.ts +48 -0
- package/templates/dialecte/src/__version__/config/hydrated.types.ts +78 -0
- package/templates/dialecte/src/__version__/config/index.ts +2 -0
- package/templates/dialecte/src/__version__/config/namespaces.ts +6 -0
- package/templates/dialecte/src/__version__/definition/.gitkeep +2 -0
- package/templates/dialecte/src/__version__/definition/index.ts +4 -0
- package/templates/dialecte/src/__version__/dialecte.ts +30 -0
- package/templates/dialecte/src/__version__/extensions/index.ts +3 -0
- package/templates/dialecte/src/__version__/index.ts +2 -0
- package/templates/dialecte/src/__version__/test/hydrated-test.ts +53 -0
- package/templates/dialecte/src/__version__/test/index.ts +1 -0
- package/templates/dialecte/src/index.ts +1 -0
- package/templates/dialecte/tsconfig.build.json +24 -0
- package/templates/dialecte/tsconfig.json +8 -0
- package/templates/dialecte/tsconfig.node.json +13 -0
- package/templates/dialecte/tsconfig.vitest.json +10 -0
- package/templates/dialecte/vite.config.ts +36 -0
- package/templates/dialecte/vitest.config.ts +35 -0
- package/vendor/elementpath-5.1.1-py3-none-any.whl +0 -0
- package/vendor/xmlschema-4.3.1-py3-none-any.whl +0 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Shared utility functions for the generation pipeline."""
|
|
2
|
+
import re
|
|
3
|
+
import unicodedata
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# ---------------------------------------------------------------------------
|
|
8
|
+
# XSD pattern → JS regex conversion
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
# XSD Unicode block names → (start, end) code-point ranges.
|
|
12
|
+
# Covers all blocks that appear in IEC 61850 XSDs and common XML XSD patterns.
|
|
13
|
+
# Source: Unicode 15.1 block ranges.
|
|
14
|
+
_UNICODE_BLOCKS: dict[str, tuple[int, int]] = {
|
|
15
|
+
'IsBasicLatin': (0x0000, 0x007F),
|
|
16
|
+
'IsLatin-1Supplement': (0x0080, 0x00FF),
|
|
17
|
+
'IsLatinExtended-A': (0x0100, 0x017F),
|
|
18
|
+
'IsLatinExtended-B': (0x0180, 0x024F),
|
|
19
|
+
'IsIPAExtensions': (0x0250, 0x02AF),
|
|
20
|
+
'IsSpacingModifierLetters': (0x02B0, 0x02FF),
|
|
21
|
+
'IsCombiningDiacriticalMarks': (0x0300, 0x036F),
|
|
22
|
+
'IsGreek': (0x0370, 0x03FF),
|
|
23
|
+
'IsCyrillic': (0x0400, 0x04FF),
|
|
24
|
+
'IsArmenian': (0x0530, 0x058F),
|
|
25
|
+
'IsHebrew': (0x0590, 0x05FF),
|
|
26
|
+
'IsArabic': (0x0600, 0x06FF),
|
|
27
|
+
'IsThai': (0x0E00, 0x0E7F),
|
|
28
|
+
'IsHiragana': (0x3040, 0x309F),
|
|
29
|
+
'IsKatakana': (0x30A0, 0x30FF),
|
|
30
|
+
'IsCJKUnifiedIdeographs': (0x4E00, 0x9FFF),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _block_range_escape(start: int, end: int) -> str:
|
|
35
|
+
"""Emit a JS-safe character range string like ``\\x00-\\x7F``."""
|
|
36
|
+
def _esc(cp: int) -> str:
|
|
37
|
+
if cp <= 0xFF:
|
|
38
|
+
return f'\\x{cp:02x}'
|
|
39
|
+
if cp <= 0xFFFF:
|
|
40
|
+
return f'\\u{cp:04x}'
|
|
41
|
+
return f'\\u{{{cp:x}}}'
|
|
42
|
+
return f'{_esc(start)}-{_esc(end)}'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def xsd_pattern_to_js(pattern: str) -> str:
|
|
46
|
+
"""Convert an XSD regular expression to a JS-compatible regex string.
|
|
47
|
+
|
|
48
|
+
Handles:
|
|
49
|
+
- ``\\i`` / ``\\I`` (XML initial name char class)
|
|
50
|
+
- ``\\c`` / ``\\C`` (XML name char class)
|
|
51
|
+
- ``\\p{BlockName}`` → Unicode code-point range
|
|
52
|
+
- ``&#xHHHH;`` and ``&#DDD;`` XML character references
|
|
53
|
+
"""
|
|
54
|
+
result = pattern
|
|
55
|
+
|
|
56
|
+
# \i → XML initial name character (letter, underscore, colon)
|
|
57
|
+
result = result.replace(r'\i', '[A-Za-z_:]')
|
|
58
|
+
# \I → complement
|
|
59
|
+
result = result.replace(r'\I', '[^A-Za-z_:]')
|
|
60
|
+
|
|
61
|
+
# \c → XML name character (letter, digit, dot, dash, underscore, colon)
|
|
62
|
+
result = result.replace(r'\c', '[-.:0-9A-Z_a-z]')
|
|
63
|
+
# \C → complement
|
|
64
|
+
result = result.replace(r'\C', '[^-.:0-9A-Z_a-z]')
|
|
65
|
+
|
|
66
|
+
# \p{BlockName} → code-point range
|
|
67
|
+
def _replace_block(m: re.Match[str]) -> str:
|
|
68
|
+
name = m.group(1)
|
|
69
|
+
rng = _UNICODE_BLOCKS.get(name)
|
|
70
|
+
if rng:
|
|
71
|
+
return _block_range_escape(*rng)
|
|
72
|
+
# Unknown block — leave as-is (will fail in JS, intentionally loud)
|
|
73
|
+
return m.group(0)
|
|
74
|
+
|
|
75
|
+
result = re.sub(r'\\p\{([^}]+)\}', _replace_block, result)
|
|
76
|
+
|
|
77
|
+
# XML hex character reference &#xHHHH;
|
|
78
|
+
result = re.sub(
|
|
79
|
+
r'&#x([0-9A-Fa-f]+);',
|
|
80
|
+
lambda m: chr(int(m.group(1), 16)),
|
|
81
|
+
result,
|
|
82
|
+
)
|
|
83
|
+
# XML decimal character reference &#DDD;
|
|
84
|
+
result = re.sub(
|
|
85
|
+
r'&#(\d+);',
|
|
86
|
+
lambda m: chr(int(m.group(1), 10)),
|
|
87
|
+
result,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return result
|
|
91
|
+
def local_name(qname: str) -> str:
|
|
92
|
+
"""Extract local name from a Clark-notation QName like '{uri}local' or 'prefix:local'."""
|
|
93
|
+
if qname.startswith('{'):
|
|
94
|
+
return qname.split('}', 1)[-1]
|
|
95
|
+
if ':' in qname:
|
|
96
|
+
return qname.split(':', 1)[-1]
|
|
97
|
+
return qname
|
|
98
|
+
def namespace_uri(qname: str) -> str | None:
|
|
99
|
+
"""Extract namespace URI from Clark-notation QName '{uri}local'. Returns None if absent."""
|
|
100
|
+
if qname.startswith('{'):
|
|
101
|
+
return qname[1:].split('}', 1)[0]
|
|
102
|
+
return None
|
|
103
|
+
def tokenize_xpath(xpath: str) -> list[str]:
|
|
104
|
+
"""Split an XPath selector into its element path tokens.
|
|
105
|
+
|
|
106
|
+
Strips namespace prefixes and leading './'.
|
|
107
|
+
Examples:
|
|
108
|
+
'tNS:LNode' -> ['LNode']
|
|
109
|
+
'.//SubNetwork' -> ['SubNetwork']
|
|
110
|
+
'Bay/ConductingEquipment' -> ['Bay', 'ConductingEquipment']
|
|
111
|
+
"""
|
|
112
|
+
if not xpath:
|
|
113
|
+
return []
|
|
114
|
+
# Remove leading ./ or .//
|
|
115
|
+
cleaned = re.sub(r'^\.//?', '', xpath)
|
|
116
|
+
parts = cleaned.split('/')
|
|
117
|
+
tokens = []
|
|
118
|
+
for part in parts:
|
|
119
|
+
part = part.strip()
|
|
120
|
+
if not part or part == '.':
|
|
121
|
+
continue
|
|
122
|
+
# Strip namespace prefix
|
|
123
|
+
tokens.append(local_name(part))
|
|
124
|
+
return tokens
|
|
125
|
+
def get_facet_value(facet: Any) -> Any:
|
|
126
|
+
"""Extract a scalar value from an xmlschema facet object.
|
|
127
|
+
|
|
128
|
+
Tries multiple access patterns that xmlschema uses across versions.
|
|
129
|
+
"""
|
|
130
|
+
for attr in ('value', 'v', 'min_value', 'max_value'):
|
|
131
|
+
val = getattr(facet, attr, None)
|
|
132
|
+
if val is not None:
|
|
133
|
+
return val
|
|
134
|
+
# Fallback: try elem attribute
|
|
135
|
+
elem = getattr(facet, 'elem', None)
|
|
136
|
+
if elem is not None:
|
|
137
|
+
raw = elem.get('value')
|
|
138
|
+
if raw is not None:
|
|
139
|
+
try:
|
|
140
|
+
return int(raw)
|
|
141
|
+
except (ValueError, TypeError):
|
|
142
|
+
return raw
|
|
143
|
+
return None
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Internal Representation — Python dataclasses for XSD schema data.
|
|
2
|
+
|
|
3
|
+
Never serialized directly. Used as the in-memory model between
|
|
4
|
+
Parse → Collect → Derive → Emit phases.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from generate.xpath_parser import FieldPath, SelectorPath
|
|
13
|
+
@dataclass
|
|
14
|
+
class Namespace:
|
|
15
|
+
prefix: str
|
|
16
|
+
uri: str
|
|
17
|
+
@dataclass
|
|
18
|
+
class Facets:
|
|
19
|
+
enumeration: list[str] | None = None
|
|
20
|
+
pattern: list[str] | None = None
|
|
21
|
+
min_length: int | None = None
|
|
22
|
+
max_length: int | None = None
|
|
23
|
+
length: int | None = None
|
|
24
|
+
min_inclusive: int | str | None = None
|
|
25
|
+
max_inclusive: int | str | None = None
|
|
26
|
+
min_exclusive: int | str | None = None
|
|
27
|
+
max_exclusive: int | str | None = None
|
|
28
|
+
total_digits: int | None = None
|
|
29
|
+
fraction_digits: int | None = None
|
|
30
|
+
white_space: str | None = None # 'preserve' | 'replace' | 'collapse'
|
|
31
|
+
|
|
32
|
+
def is_empty(self) -> bool:
|
|
33
|
+
return all(v is None for v in vars(self).values())
|
|
34
|
+
@dataclass
|
|
35
|
+
class AttributeDef:
|
|
36
|
+
required: bool = False
|
|
37
|
+
default: str | None = None
|
|
38
|
+
fixed: str | None = None
|
|
39
|
+
namespace: Namespace | None = None
|
|
40
|
+
facets: Facets | None = None
|
|
41
|
+
@dataclass
|
|
42
|
+
class IdentityConstraint:
|
|
43
|
+
kind: str # 'unique' | 'key' | 'keyref'
|
|
44
|
+
name: str
|
|
45
|
+
selector: list[SelectorPath] # parsed XPath alternatives (union)
|
|
46
|
+
fields: list[FieldPath] # parsed field XPath expressions
|
|
47
|
+
deep: bool = False
|
|
48
|
+
refer: str | None = None # keyref only
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ChildDef:
|
|
52
|
+
required: bool = False
|
|
53
|
+
min_occurs: int = 0
|
|
54
|
+
max_occurs: int | None = None # None = unbounded
|
|
55
|
+
constraints: list[IdentityConstraint] | None = None
|
|
56
|
+
facets: Facets | None = None
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ChoiceGroup:
|
|
60
|
+
options: list[str]
|
|
61
|
+
min_occurs: int = 0
|
|
62
|
+
max_occurs: int | None = None
|
|
63
|
+
@dataclass
|
|
64
|
+
class TextContent:
|
|
65
|
+
facets: Facets | None = None
|
|
66
|
+
@dataclass
|
|
67
|
+
class ElementDef:
|
|
68
|
+
tag: str
|
|
69
|
+
namespace: Namespace
|
|
70
|
+
documentation: str | None = None
|
|
71
|
+
parents: list[str] = field(default_factory=list)
|
|
72
|
+
attr_sequence: list[str] = field(default_factory=list)
|
|
73
|
+
attr_any: bool = False
|
|
74
|
+
attributes: dict[str, AttributeDef] = field(default_factory=dict)
|
|
75
|
+
child_sequence: list[str] = field(default_factory=list)
|
|
76
|
+
child_any: bool = False
|
|
77
|
+
children: dict[str, ChildDef] = field(default_factory=dict)
|
|
78
|
+
choices: list[ChoiceGroup] = field(default_factory=list)
|
|
79
|
+
constraints: list[IdentityConstraint] = field(default_factory=list)
|
|
80
|
+
text_content: TextContent | None = None
|
|
81
|
+
identity_fields: list[str] = field(default_factory=list)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Orphan element detection and parent injection from a JSON sidecar mapping.
|
|
2
|
+
|
|
3
|
+
Inserted between Collect and Derive phases. Handles XSD global elements that
|
|
4
|
+
are children-via-xs:any wildcards (e.g. IEC 61850-6-100 extension elements)
|
|
5
|
+
whose parent-child relationships are domain knowledge, not XSD-declared.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from generate.ir import ChildDef, ElementDef
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_parent_mapping(entry_path: Path) -> dict[str, list[str]]:
|
|
14
|
+
"""Read parent-mapping.json from the same directory as the entry XSD.
|
|
15
|
+
|
|
16
|
+
Returns empty dict if the file does not exist.
|
|
17
|
+
"""
|
|
18
|
+
mapping_file = entry_path.parent / 'parent-mapping.json'
|
|
19
|
+
if not mapping_file.exists():
|
|
20
|
+
return {}
|
|
21
|
+
with open(mapping_file) as f:
|
|
22
|
+
return json.load(f)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_orphans(elements: dict[str, ElementDef], root_name: str) -> list[str]:
|
|
26
|
+
"""Return parentless element names, excluding the root element."""
|
|
27
|
+
return sorted(
|
|
28
|
+
name for name, e in elements.items()
|
|
29
|
+
if not e.parents and name != root_name
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def inject_orphan_parents(
|
|
34
|
+
elements: dict[str, ElementDef],
|
|
35
|
+
mapping: dict[str, list[str]],
|
|
36
|
+
root_name: str = '',
|
|
37
|
+
) -> list[str]:
|
|
38
|
+
"""Inject parent-child links from the sidecar mapping.
|
|
39
|
+
|
|
40
|
+
For each mapped orphan:
|
|
41
|
+
- Sets element.parents to the mapped parent list
|
|
42
|
+
- Adds element to each parent's children dict and child_sequence
|
|
43
|
+
|
|
44
|
+
*root_name* is excluded from orphan detection.
|
|
45
|
+
Returns names of orphan elements NOT covered by the mapping (for warnings).
|
|
46
|
+
"""
|
|
47
|
+
orphan_names = {name for name, e in elements.items() if not e.parents and name != root_name}
|
|
48
|
+
mapped_orphans = orphan_names & mapping.keys()
|
|
49
|
+
unmapped = sorted(orphan_names - mapping.keys())
|
|
50
|
+
|
|
51
|
+
for name in sorted(mapped_orphans):
|
|
52
|
+
parents = mapping[name]
|
|
53
|
+
|
|
54
|
+
elem = elements[name]
|
|
55
|
+
valid_parents = [p for p in parents if p in elements]
|
|
56
|
+
elem.parents = valid_parents
|
|
57
|
+
|
|
58
|
+
for parent_name in valid_parents:
|
|
59
|
+
parent = elements[parent_name]
|
|
60
|
+
if name not in parent.children:
|
|
61
|
+
parent.children[name] = ChildDef(
|
|
62
|
+
required=False,
|
|
63
|
+
min_occurs=0,
|
|
64
|
+
max_occurs=None,
|
|
65
|
+
)
|
|
66
|
+
if name not in parent.child_sequence:
|
|
67
|
+
parent.child_sequence.append(name)
|
|
68
|
+
|
|
69
|
+
return unmapped
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Parse XSD identity-constraint XPath selectors and fields (§3.11.6).
|
|
2
|
+
|
|
3
|
+
Implements the restricted grammar from XSD 1.1 Part 1, §3.11.6.2 (selectors)
|
|
4
|
+
and §3.11.6.3 (fields):
|
|
5
|
+
|
|
6
|
+
Selector ::= Path ( '|' Path )*
|
|
7
|
+
Path ::= ('.' '//')? Step ( '/' Step )*
|
|
8
|
+
Step ::= '.' | NameTest
|
|
9
|
+
NameTest ::= QName | '*' | NCName ':*'
|
|
10
|
+
|
|
11
|
+
Field variant: last step can also be ``@ NameTest``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from generate.helpers import local_name
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# IR dataclasses
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class XPathStep:
|
|
29
|
+
"""A single step in a selector/field path.
|
|
30
|
+
|
|
31
|
+
kind:
|
|
32
|
+
- 'self' → the literal '.'
|
|
33
|
+
- 'name' → local element name (ns stripped)
|
|
34
|
+
- 'wildcard' → '*'
|
|
35
|
+
- 'ns-wildcard' → 'NCName:*' (value = NCName)
|
|
36
|
+
"""
|
|
37
|
+
kind: Literal['self', 'name', 'wildcard', 'ns-wildcard']
|
|
38
|
+
value: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class SelectorPath:
|
|
43
|
+
"""One alternative of a selector (before/after '|')."""
|
|
44
|
+
deep: bool = False
|
|
45
|
+
steps: tuple[XPathStep, ...] = ()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class FieldTarget:
|
|
50
|
+
"""The terminal target of a field XPath — element or attribute.
|
|
51
|
+
|
|
52
|
+
kind:
|
|
53
|
+
- 'element' → child element name
|
|
54
|
+
- 'attribute' → @attrName
|
|
55
|
+
- 'wildcard' → * or @*
|
|
56
|
+
- 'ns-wildcard' → NCName:* or @NCName:*
|
|
57
|
+
"""
|
|
58
|
+
kind: Literal['element', 'attribute', 'wildcard', 'ns-wildcard']
|
|
59
|
+
value: str | None = None
|
|
60
|
+
is_attribute: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class FieldPath:
|
|
65
|
+
"""Parsed XPath field expression."""
|
|
66
|
+
deep: bool = False
|
|
67
|
+
steps: tuple[XPathStep, ...] = ()
|
|
68
|
+
target: FieldTarget = field(default_factory=lambda: FieldTarget(kind='wildcard'))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Parser
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
_PIPE_RE = re.compile(r'\s*\|\s*')
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_selector(xpath: str) -> list[SelectorPath]:
|
|
79
|
+
"""Parse a selector XPath into a list of SelectorPath alternatives."""
|
|
80
|
+
if not xpath or not xpath.strip():
|
|
81
|
+
return []
|
|
82
|
+
alternatives = _PIPE_RE.split(xpath.strip())
|
|
83
|
+
return [_parse_selector_path(alt.strip()) for alt in alternatives if alt.strip()]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_field(xpath: str) -> FieldPath:
|
|
87
|
+
"""Parse a field XPath into a FieldPath."""
|
|
88
|
+
if not xpath or not xpath.strip():
|
|
89
|
+
return FieldPath()
|
|
90
|
+
return _parse_field_path(xpath.strip())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Internal
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def _parse_selector_path(raw: str) -> SelectorPath:
|
|
98
|
+
"""Parse one selector alternative (no '|')."""
|
|
99
|
+
deep, remainder = _consume_deep_prefix(raw)
|
|
100
|
+
tokens = _split_steps(remainder)
|
|
101
|
+
steps = tuple(_parse_step(t) for t in tokens)
|
|
102
|
+
return SelectorPath(deep=deep, steps=steps)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_field_path(raw: str) -> FieldPath:
|
|
106
|
+
"""Parse a field XPath expression."""
|
|
107
|
+
deep, remainder = _consume_deep_prefix(raw)
|
|
108
|
+
tokens = _split_steps(remainder)
|
|
109
|
+
|
|
110
|
+
if not tokens:
|
|
111
|
+
return FieldPath(deep=deep)
|
|
112
|
+
|
|
113
|
+
# Check if last token is an attribute reference
|
|
114
|
+
last = tokens[-1]
|
|
115
|
+
if last.startswith('@'):
|
|
116
|
+
steps = tuple(_parse_step(t) for t in tokens[:-1])
|
|
117
|
+
target = _parse_field_target(last, is_attribute=True)
|
|
118
|
+
else:
|
|
119
|
+
steps = tuple(_parse_step(t) for t in tokens[:-1])
|
|
120
|
+
target = _parse_field_target(last, is_attribute=False)
|
|
121
|
+
|
|
122
|
+
return FieldPath(deep=deep, steps=steps, target=target)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _consume_deep_prefix(raw: str) -> tuple[bool, str]:
|
|
126
|
+
"""Strip leading './' or './/' and return (deep, remainder)."""
|
|
127
|
+
if raw.startswith('.//'):
|
|
128
|
+
return True, raw[3:]
|
|
129
|
+
if raw.startswith('./'):
|
|
130
|
+
return False, raw[2:]
|
|
131
|
+
return False, raw
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _split_steps(raw: str) -> list[str]:
|
|
135
|
+
"""Split path into step tokens on '/' boundaries."""
|
|
136
|
+
if not raw:
|
|
137
|
+
return []
|
|
138
|
+
parts = raw.split('/')
|
|
139
|
+
return [p.strip() for p in parts if p.strip()]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_step(token: str) -> XPathStep:
|
|
143
|
+
"""Parse a single step token into an XPathStep."""
|
|
144
|
+
if token == '.':
|
|
145
|
+
return XPathStep(kind='self')
|
|
146
|
+
if token == '*':
|
|
147
|
+
return XPathStep(kind='wildcard')
|
|
148
|
+
if token.endswith(':*'):
|
|
149
|
+
prefix = token[:-2]
|
|
150
|
+
return XPathStep(kind='ns-wildcard', value=prefix)
|
|
151
|
+
# QName — strip namespace prefix
|
|
152
|
+
return XPathStep(kind='name', value=local_name(token))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_field_target(token: str, *, is_attribute: bool) -> FieldTarget:
|
|
156
|
+
"""Parse the terminal token of a field path."""
|
|
157
|
+
raw = token.lstrip('@').strip()
|
|
158
|
+
|
|
159
|
+
if raw == '*':
|
|
160
|
+
return FieldTarget(kind='wildcard', is_attribute=is_attribute)
|
|
161
|
+
if raw.endswith(':*'):
|
|
162
|
+
return FieldTarget(kind='ns-wildcard', value=raw[:-2], is_attribute=is_attribute)
|
|
163
|
+
return FieldTarget(
|
|
164
|
+
kind='attribute' if is_attribute else 'element',
|
|
165
|
+
value=local_name(raw),
|
|
166
|
+
is_attribute=is_attribute,
|
|
167
|
+
)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""xsi:type expansion — resolve XSD type-derivation polymorphism into the IR.
|
|
2
|
+
|
|
3
|
+
Some schemas (notably IEC 61131-10) model their content polymorphically: a named
|
|
4
|
+
element slot is declared with an **abstract** complex type, and instances select a
|
|
5
|
+
concrete variant at runtime via the ``xsi:type`` attribute. For example::
|
|
6
|
+
|
|
7
|
+
<BodyContent xsi:type="FBD"><Network>…</Network></BodyContent>
|
|
8
|
+
|
|
9
|
+
Here ``BodyContent`` is the element name; ``FBD`` is a *type* selected via
|
|
10
|
+
``xsi:type``. The concrete forms (``FBD``, ``Block``, ``DataSource``, …) have **no**
|
|
11
|
+
global element declarations, so the element-instance-driven collector never reaches
|
|
12
|
+
their children.
|
|
13
|
+
|
|
14
|
+
This module reverses the XSD type-derivation graph (``schema.maps.types``) to find,
|
|
15
|
+
for any declared type, the concrete types that may substitute it. Their children and
|
|
16
|
+
attributes are unioned into the slot element (as optional members, since each is only
|
|
17
|
+
required for a specific ``xsi:type``), and an ``xsi:type`` enumeration attribute is
|
|
18
|
+
added listing the valid variant names.
|
|
19
|
+
|
|
20
|
+
This expansion runs automatically for every schema. For slots declared with a
|
|
21
|
+
concrete (non-abstract) type that has no derived variants, ``_concrete_substitution_types``
|
|
22
|
+
returns ``[]`` and ``expand`` is a no-op — zero overhead. Schemas may freely mix
|
|
23
|
+
abstract-typed slots (expanded) with concrete-typed slots (untouched).
|
|
24
|
+
"""
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from generate.extractors.attributes import extract_attributes
|
|
28
|
+
from generate.extractors.children import extract_children_from_type, iter_type_child_elements
|
|
29
|
+
from generate.helpers import local_name
|
|
30
|
+
from generate.ir import AttributeDef, ChildDef, Facets, Namespace
|
|
31
|
+
|
|
32
|
+
XSI_NAMESPACE = Namespace(prefix='xsi', uri='http://www.w3.org/2001/XMLSchema-instance')
|
|
33
|
+
XSI_TYPE_KEY = 'xsi:type'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class XsiTypeExpander:
|
|
37
|
+
"""Expands abstract-typed element slots with their xsi:type substitution variants."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, schema: Any) -> None:
|
|
40
|
+
self._derived = _build_derived_map(schema)
|
|
41
|
+
|
|
42
|
+
def expand(
|
|
43
|
+
self,
|
|
44
|
+
xsd_elem: Any,
|
|
45
|
+
attr_seq: list[str],
|
|
46
|
+
attrs: dict[str, AttributeDef],
|
|
47
|
+
child_seq: list[str],
|
|
48
|
+
children: dict[str, ChildDef],
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Union substitution-variant children/attributes into an element's IR (in place).
|
|
51
|
+
|
|
52
|
+
Idempotent and additive: existing entries are never overwritten or removed.
|
|
53
|
+
Safe to call multiple times for the same element name (e.g. when the same slot
|
|
54
|
+
is declared in several parents with different abstract base types).
|
|
55
|
+
"""
|
|
56
|
+
types = self._concrete_substitution_types(getattr(xsd_elem, 'type', None))
|
|
57
|
+
if not types:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
for variant in types:
|
|
61
|
+
v_seq, _v_any, v_details = extract_children_from_type(variant)
|
|
62
|
+
for name in v_seq:
|
|
63
|
+
if name in children:
|
|
64
|
+
continue
|
|
65
|
+
detail = v_details[name]
|
|
66
|
+
children[name] = ChildDef(
|
|
67
|
+
required=False,
|
|
68
|
+
min_occurs=0,
|
|
69
|
+
max_occurs=detail.max_occurs,
|
|
70
|
+
constraints=detail.constraints,
|
|
71
|
+
facets=detail.facets,
|
|
72
|
+
)
|
|
73
|
+
child_seq.append(name)
|
|
74
|
+
|
|
75
|
+
for variant in types:
|
|
76
|
+
a_seq, _a_any, a_details = extract_attributes(variant)
|
|
77
|
+
for key in a_seq:
|
|
78
|
+
if key in attrs:
|
|
79
|
+
continue
|
|
80
|
+
detail = a_details[key]
|
|
81
|
+
attrs[key] = AttributeDef(
|
|
82
|
+
required=False,
|
|
83
|
+
default=detail.default,
|
|
84
|
+
fixed=detail.fixed,
|
|
85
|
+
namespace=detail.namespace,
|
|
86
|
+
facets=detail.facets,
|
|
87
|
+
)
|
|
88
|
+
attr_seq.append(key)
|
|
89
|
+
|
|
90
|
+
variant_names = sorted(local_name(getattr(t, 'name', '') or '') for t in types)
|
|
91
|
+
if variant_names and XSI_TYPE_KEY not in attrs:
|
|
92
|
+
attrs[XSI_TYPE_KEY] = AttributeDef(
|
|
93
|
+
required=False,
|
|
94
|
+
namespace=XSI_NAMESPACE,
|
|
95
|
+
facets=Facets(enumeration=variant_names),
|
|
96
|
+
)
|
|
97
|
+
attr_seq.append(XSI_TYPE_KEY)
|
|
98
|
+
|
|
99
|
+
# Keep attribute order consistent with extract_attributes (alphabetical).
|
|
100
|
+
attr_seq.sort()
|
|
101
|
+
|
|
102
|
+
def iter_variant_child_elements(self, xsd_elem: Any):
|
|
103
|
+
"""Yield XsdElement children contributed by an element's substitution variants.
|
|
104
|
+
|
|
105
|
+
Used by the collector to recurse into elements that are only reachable through
|
|
106
|
+
xsi:type substitution (e.g. ``Network`` under an abstract ``BodyContent`` slot).
|
|
107
|
+
"""
|
|
108
|
+
for variant in self._concrete_substitution_types(getattr(xsd_elem, 'type', None)):
|
|
109
|
+
yield from iter_type_child_elements(variant)
|
|
110
|
+
|
|
111
|
+
def _concrete_substitution_types(self, xsd_type: Any) -> list[Any]:
|
|
112
|
+
"""Transitive concrete types derivable from *xsd_type* (BFS through abstract bases)."""
|
|
113
|
+
if xsd_type is None:
|
|
114
|
+
return []
|
|
115
|
+
result: list[Any] = []
|
|
116
|
+
seen: set[int] = set()
|
|
117
|
+
queue: list[Any] = [xsd_type]
|
|
118
|
+
while queue:
|
|
119
|
+
current = queue.pop(0)
|
|
120
|
+
for derived in self._derived.get(id(current), []):
|
|
121
|
+
if id(derived) in seen:
|
|
122
|
+
continue
|
|
123
|
+
seen.add(id(derived))
|
|
124
|
+
queue.append(derived)
|
|
125
|
+
if not getattr(derived, 'abstract', False):
|
|
126
|
+
result.append(derived)
|
|
127
|
+
result.sort(key=lambda t: local_name(getattr(t, 'name', '') or ''))
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _build_derived_map(schema: Any) -> dict[int, list[Any]]:
|
|
132
|
+
"""Map ``id(base_type)`` → directly-derived complex types across all schemas.
|
|
133
|
+
|
|
134
|
+
xmlschema API:
|
|
135
|
+
XMLSchema.maps.types: dict[str, XsdType] — every global type in the schema set
|
|
136
|
+
XsdComplexType.base_type: XsdType | None
|
|
137
|
+
XsdComplexType.derivation: 'extension' | 'restriction' | None
|
|
138
|
+
"""
|
|
139
|
+
derived: dict[int, list[Any]] = {}
|
|
140
|
+
types = getattr(getattr(schema, 'maps', None), 'types', None)
|
|
141
|
+
if not types:
|
|
142
|
+
return derived
|
|
143
|
+
for xsd_type in types.values():
|
|
144
|
+
base = getattr(xsd_type, 'base_type', None)
|
|
145
|
+
if base is None:
|
|
146
|
+
continue
|
|
147
|
+
if getattr(xsd_type, 'derivation', None) not in ('extension', 'restriction'):
|
|
148
|
+
continue
|
|
149
|
+
derived.setdefault(id(base), []).append(xsd_type)
|
|
150
|
+
return derived
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dialecte-generate"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "XSD to TypeScript definition generator for Dialecte"
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
dependencies = ["xmlschema>=4.3.1"]
|
|
7
|
+
|
|
8
|
+
[project.optional-dependencies]
|
|
9
|
+
dev = ["pytest>=9.0.2"]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
dialecte-generate = "generate.__main__:main"
|
|
13
|
+
|
|
14
|
+
[tool.pytest.ini_options]
|
|
15
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# **packageName**
|
|
2
|
+
|
|
3
|
+
Dialecte SDK for `__dialecteId__`.
|
|
4
|
+
|
|
5
|
+
Built on [`@dialecte/core`](https://github.com/dialecte/core) and scaffolded with
|
|
6
|
+
[`@dialecte/create`](https://github.com/dialecte/create).
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install __packageName__ @dialecte/core
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { create__DialecteName__Project } from '__packageName__/__version__'
|
|
18
|
+
|
|
19
|
+
const project = create__DialecteName__Project()
|
|
20
|
+
await project.open('my-project')
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Regenerate definitions
|
|
24
|
+
|
|
25
|
+
The element definitions in `src/__version__/definition/` are generated from an XSD schema.
|
|
26
|
+
To regenerate after a schema change:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm create @dialecte generate -- --entry ./path/to/schema.xsd --out-dir ./src/__version__/definition
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Develop
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm install
|
|
36
|
+
npm run build # type-check + bundle (ESM + d.ts)
|
|
37
|
+
npm test # vitest (browser)
|
|
38
|
+
npm run doc:dev # vitepress docs
|
|
39
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from 'vitepress'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
title: '__packageName__',
|
|
5
|
+
description: 'Dialecte SDK for __dialecteId__',
|
|
6
|
+
themeConfig: {
|
|
7
|
+
nav: [{ text: 'Guide', link: '/' }],
|
|
8
|
+
sidebar: [
|
|
9
|
+
{
|
|
10
|
+
text: 'Introduction',
|
|
11
|
+
items: [{ text: 'Getting started', link: '/' }],
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
})
|