bbfmodels 0.1.0__py3-none-any.whl
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.
- bbfmodels/__init__.py +16 -0
- bbfmodels/cli.py +79 -0
- bbfmodels/codec.py +232 -0
- bbfmodels/ir.py +327 -0
- bbfmodels/parser.py +533 -0
- bbfmodels/py.typed +0 -0
- bbfmodels/schema.py +152 -0
- bbfmodels/sources.py +79 -0
- bbfmodels/tree.py +200 -0
- bbfmodels-0.1.0.dist-info/METADATA +79 -0
- bbfmodels-0.1.0.dist-info/RECORD +15 -0
- bbfmodels-0.1.0.dist-info/WHEEL +4 -0
- bbfmodels-0.1.0.dist-info/entry_points.txt +2 -0
- bbfmodels-0.1.0.dist-info/licenses/LICENSE +201 -0
- bbfmodels-0.1.0.dist-info/licenses/NOTICE +2 -0
bbfmodels/parser.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""Parse a published TR-106 data model document into the IR.
|
|
2
|
+
|
|
3
|
+
The parser reads the Forum's flattened ``-full.xml`` documents, in which
|
|
4
|
+
imports and components are already resolved, so every object carries its
|
|
5
|
+
parameters inline and every named data type is defined in the same file.
|
|
6
|
+
A document that still has ``<import>`` or ``<component>`` elements is not
|
|
7
|
+
supported and says so.
|
|
8
|
+
|
|
9
|
+
The published files are not always well-formed XML: descriptions carry
|
|
10
|
+
bare ampersands and literal ``<OUI>`` markup, and at least one attribute
|
|
11
|
+
value is published as ``value=\"\"\"``. ``sanitize`` repairs exactly those
|
|
12
|
+
before the standard library parser sees the text, so a parse failure
|
|
13
|
+
afterwards means the file genuinely is not a data model document.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
from bbfmodels.ir import (
|
|
25
|
+
Access,
|
|
26
|
+
ActiveNotify,
|
|
27
|
+
Arguments,
|
|
28
|
+
BaseType,
|
|
29
|
+
Command,
|
|
30
|
+
DataType,
|
|
31
|
+
Default,
|
|
32
|
+
Document,
|
|
33
|
+
Enumeration,
|
|
34
|
+
EnumerationRef,
|
|
35
|
+
Event,
|
|
36
|
+
Facets,
|
|
37
|
+
InstanceRef,
|
|
38
|
+
ListFacets,
|
|
39
|
+
Model,
|
|
40
|
+
Object,
|
|
41
|
+
Parameter,
|
|
42
|
+
PathRef,
|
|
43
|
+
Pattern,
|
|
44
|
+
Profile,
|
|
45
|
+
ProfileObject,
|
|
46
|
+
Range,
|
|
47
|
+
Size,
|
|
48
|
+
Status,
|
|
49
|
+
Syntax,
|
|
50
|
+
UniqueKey,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ParseError(ValueError):
|
|
55
|
+
"""The document cannot be read as a data model."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Every element name the TR-106 schema defines. A ``<`` that does not open
|
|
59
|
+
# one of these, a closing tag, a comment or a processing instruction is prose
|
|
60
|
+
# and gets escaped.
|
|
61
|
+
_ELEMENTS = (
|
|
62
|
+
"document",
|
|
63
|
+
"import",
|
|
64
|
+
"dataType",
|
|
65
|
+
"model",
|
|
66
|
+
"object",
|
|
67
|
+
"parameter",
|
|
68
|
+
"command",
|
|
69
|
+
"event",
|
|
70
|
+
"input",
|
|
71
|
+
"output",
|
|
72
|
+
"description",
|
|
73
|
+
"bibliography",
|
|
74
|
+
"reference",
|
|
75
|
+
"template",
|
|
76
|
+
"profile",
|
|
77
|
+
"uniqueKey",
|
|
78
|
+
"syntax",
|
|
79
|
+
"list",
|
|
80
|
+
"default",
|
|
81
|
+
"size",
|
|
82
|
+
"range",
|
|
83
|
+
"enumeration",
|
|
84
|
+
"enumerationRef",
|
|
85
|
+
"pattern",
|
|
86
|
+
"units",
|
|
87
|
+
"pathRef",
|
|
88
|
+
"instanceRef",
|
|
89
|
+
"string",
|
|
90
|
+
"int",
|
|
91
|
+
"unsignedInt",
|
|
92
|
+
"long",
|
|
93
|
+
"unsignedLong",
|
|
94
|
+
"boolean",
|
|
95
|
+
"dateTime",
|
|
96
|
+
"hexBinary",
|
|
97
|
+
"base64",
|
|
98
|
+
"decimal",
|
|
99
|
+
"name",
|
|
100
|
+
"title",
|
|
101
|
+
"organization",
|
|
102
|
+
"category",
|
|
103
|
+
"date",
|
|
104
|
+
"hyperlink",
|
|
105
|
+
"component",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
_TAG_START = re.compile(
|
|
109
|
+
r"<(?=(?:[!?/]|(?:[A-Za-z_][\w.-]*:)?(?:" + "|".join(sorted(_ELEMENTS)) + r")[\s/>]))"
|
|
110
|
+
)
|
|
111
|
+
_BARE_AMP = re.compile(r"&(?!(?:[A-Za-z][A-Za-z0-9]*|#\d+|#x[0-9A-Fa-f]+);)")
|
|
112
|
+
_BASE_TYPES = {t.value for t in BaseType}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sanitize(text: str) -> str:
|
|
116
|
+
"""Repair the well-formedness faults the published documents carry."""
|
|
117
|
+
out: list[str] = []
|
|
118
|
+
last = 0
|
|
119
|
+
for match in re.finditer("<", text):
|
|
120
|
+
i = match.start()
|
|
121
|
+
if _TAG_START.match(text, i):
|
|
122
|
+
continue
|
|
123
|
+
out.append(text[last:i])
|
|
124
|
+
out.append("<")
|
|
125
|
+
last = i + 1
|
|
126
|
+
out.append(text[last:])
|
|
127
|
+
repaired = "".join(out).replace('="""', '="""')
|
|
128
|
+
return _BARE_AMP.sub("&", repaired)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_file(path: str | Path) -> Document:
|
|
132
|
+
text = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
133
|
+
return parse(text)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse(text: str) -> Document:
|
|
137
|
+
try:
|
|
138
|
+
root = ET.fromstring(sanitize(text))
|
|
139
|
+
except ET.ParseError as exc:
|
|
140
|
+
raise ParseError(f"not a data model document: {exc}") from exc
|
|
141
|
+
if _local(root.tag) != "document":
|
|
142
|
+
raise ParseError(f"root element is <{_local(root.tag)}>, expected <document>")
|
|
143
|
+
return _Parser().document(root)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _local(tag: str) -> str:
|
|
147
|
+
return tag.rpartition("}")[2]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _children(el: ET.Element, name: str) -> Iterator[ET.Element]:
|
|
151
|
+
return (c for c in el if _local(c.tag) == name)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _child(el: ET.Element, name: str) -> ET.Element | None:
|
|
155
|
+
return next(_children(el, name), None)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _attr(el: ET.Element, name: str) -> str | None:
|
|
159
|
+
"""A plain attribute; namespaced attributes such as ``dmr:*`` are ignored."""
|
|
160
|
+
return el.get(name)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _flag(el: ET.Element, name: str) -> bool:
|
|
164
|
+
return _attr(el, name) == "true"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _int(el: ET.Element, name: str) -> int | None:
|
|
168
|
+
raw = _attr(el, name)
|
|
169
|
+
if raw is None:
|
|
170
|
+
return None
|
|
171
|
+
try:
|
|
172
|
+
return int(raw)
|
|
173
|
+
except ValueError as exc:
|
|
174
|
+
raise ParseError(f"<{_local(el.tag)} {name}={raw!r}> is not an integer") from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _status(el: ET.Element) -> Status:
|
|
178
|
+
raw = _attr(el, "status")
|
|
179
|
+
return Status(raw) if raw else Status.CURRENT
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _text(el: ET.Element | None) -> str:
|
|
183
|
+
"""The description text with the authors' line structure kept.
|
|
184
|
+
|
|
185
|
+
Indentation and trailing spaces go, runs of blank lines collapse to one,
|
|
186
|
+
and the ``{{...}}`` templates stay as written: they are the Forum's own
|
|
187
|
+
markup and a renderer decides what to do with them.
|
|
188
|
+
"""
|
|
189
|
+
if el is None:
|
|
190
|
+
return ""
|
|
191
|
+
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in "".join(el.itertext()).splitlines()]
|
|
192
|
+
out: list[str] = []
|
|
193
|
+
for line in lines:
|
|
194
|
+
if line or (out and out[-1]):
|
|
195
|
+
out.append(line)
|
|
196
|
+
while out and not out[-1]:
|
|
197
|
+
out.pop()
|
|
198
|
+
return "\n".join(out)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _Parser:
|
|
202
|
+
def __init__(self) -> None:
|
|
203
|
+
self.data_types: dict[str, DataType] = {}
|
|
204
|
+
|
|
205
|
+
def document(self, root: ET.Element) -> Document:
|
|
206
|
+
if _child(root, "import") is not None or _child(root, "component") is not None:
|
|
207
|
+
raise ParseError(
|
|
208
|
+
"the document has imports or components; parse the Forum's -full.xml "
|
|
209
|
+
"flattened edition instead"
|
|
210
|
+
)
|
|
211
|
+
spec = _attr(root, "spec") or ""
|
|
212
|
+
for el in _children(root, "dataType"):
|
|
213
|
+
dt = self.data_type(el)
|
|
214
|
+
self.data_types[dt.name] = dt
|
|
215
|
+
models = tuple(self.model(el) for el in _children(root, "model"))
|
|
216
|
+
protocol: Literal["cwmp", "usp"] | None = None
|
|
217
|
+
if spec.endswith("-cwmp"):
|
|
218
|
+
protocol = "cwmp"
|
|
219
|
+
elif spec.endswith("-usp"):
|
|
220
|
+
protocol = "usp"
|
|
221
|
+
return Document(
|
|
222
|
+
spec=spec,
|
|
223
|
+
file=_attr(root, "file"),
|
|
224
|
+
protocol=protocol,
|
|
225
|
+
description=_text(_child(root, "description")),
|
|
226
|
+
data_types=self.data_types,
|
|
227
|
+
models=models,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# -- types -------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def data_type(self, el: ET.Element) -> DataType:
|
|
233
|
+
name = _attr(el, "name") or ""
|
|
234
|
+
base = _attr(el, "base")
|
|
235
|
+
list_facets = self.list_facets(_child(el, "list"))
|
|
236
|
+
primitive = next((c for c in el if _local(c.tag) in _BASE_TYPES), None)
|
|
237
|
+
if base is not None:
|
|
238
|
+
parent = self.named(base, name)
|
|
239
|
+
base_type = parent.base_type
|
|
240
|
+
facets = self.facets(el).merged_over(parent.facets)
|
|
241
|
+
list_facets = list_facets or parent.list
|
|
242
|
+
elif primitive is not None:
|
|
243
|
+
base_type = BaseType(_local(primitive.tag))
|
|
244
|
+
facets = self.facets(primitive)
|
|
245
|
+
else:
|
|
246
|
+
raise ParseError(f"dataType {name!r} has neither a base nor a primitive type")
|
|
247
|
+
return DataType(
|
|
248
|
+
name=name,
|
|
249
|
+
base=base,
|
|
250
|
+
base_type=base_type,
|
|
251
|
+
facets=facets,
|
|
252
|
+
list=list_facets,
|
|
253
|
+
description=_text(_child(el, "description")),
|
|
254
|
+
status=_status(el),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def named(self, name: str, used_by: str) -> DataType:
|
|
258
|
+
try:
|
|
259
|
+
return self.data_types[name]
|
|
260
|
+
except KeyError:
|
|
261
|
+
raise ParseError(f"{used_by!r} uses data type {name!r}, which is not defined") from None
|
|
262
|
+
|
|
263
|
+
def facets(self, el: ET.Element) -> Facets:
|
|
264
|
+
sizes: list[Size] = []
|
|
265
|
+
ranges: list[Range] = []
|
|
266
|
+
enums: list[Enumeration] = []
|
|
267
|
+
patterns: list[Pattern] = []
|
|
268
|
+
units = path_ref = instance_ref = enum_ref = None
|
|
269
|
+
for c in el:
|
|
270
|
+
tag = _local(c.tag)
|
|
271
|
+
if tag == "size":
|
|
272
|
+
# One published document spells the attribute ``maxlength``.
|
|
273
|
+
sizes.append(
|
|
274
|
+
Size(
|
|
275
|
+
min_length=_int(c, "minLength"),
|
|
276
|
+
max_length=_int(c, "maxLength")
|
|
277
|
+
if "maxLength" in c.attrib
|
|
278
|
+
else _int(c, "maxlength"),
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
elif tag == "range":
|
|
282
|
+
ranges.append(
|
|
283
|
+
Range(
|
|
284
|
+
min_inclusive=_int(c, "minInclusive"),
|
|
285
|
+
max_inclusive=_int(c, "maxInclusive"),
|
|
286
|
+
step=_int(c, "step"),
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
elif tag == "enumeration":
|
|
290
|
+
access = _attr(c, "access")
|
|
291
|
+
enums.append(
|
|
292
|
+
Enumeration(
|
|
293
|
+
value=_attr(c, "value") or "",
|
|
294
|
+
code=_int(c, "code"),
|
|
295
|
+
access=Access(access) if access else None,
|
|
296
|
+
optional=_flag(c, "optional"),
|
|
297
|
+
status=_status(c),
|
|
298
|
+
version=_attr(c, "version"),
|
|
299
|
+
description=_text(_child(c, "description")),
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
elif tag == "pattern":
|
|
303
|
+
patterns.append(
|
|
304
|
+
Pattern(
|
|
305
|
+
value=_attr(c, "value") or "",
|
|
306
|
+
status=_status(c),
|
|
307
|
+
version=_attr(c, "version"),
|
|
308
|
+
description=_text(_child(c, "description")),
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
elif tag == "units":
|
|
312
|
+
units = _attr(c, "value")
|
|
313
|
+
elif tag == "pathRef":
|
|
314
|
+
path_ref = PathRef(
|
|
315
|
+
ref_type=_attr(c, "refType") or "strong", # type: ignore[arg-type]
|
|
316
|
+
target_parent=_attr(c, "targetParent"),
|
|
317
|
+
target_type=_attr(c, "targetType") or "row",
|
|
318
|
+
target_data_type=_attr(c, "targetDataType"),
|
|
319
|
+
)
|
|
320
|
+
elif tag == "instanceRef":
|
|
321
|
+
instance_ref = InstanceRef(
|
|
322
|
+
ref_type=_attr(c, "refType") or "strong", # type: ignore[arg-type]
|
|
323
|
+
target_parent=_attr(c, "targetParent"),
|
|
324
|
+
)
|
|
325
|
+
elif tag == "enumerationRef":
|
|
326
|
+
enum_ref = EnumerationRef(
|
|
327
|
+
target_param=_attr(c, "targetParam") or "",
|
|
328
|
+
null_value=_attr(c, "nullValue"),
|
|
329
|
+
)
|
|
330
|
+
return Facets(
|
|
331
|
+
sizes=tuple(sizes),
|
|
332
|
+
ranges=tuple(ranges),
|
|
333
|
+
enumerations=tuple(enums),
|
|
334
|
+
patterns=tuple(patterns),
|
|
335
|
+
units=units,
|
|
336
|
+
path_ref=path_ref,
|
|
337
|
+
instance_ref=instance_ref,
|
|
338
|
+
enumeration_ref=enum_ref,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def list_facets(self, el: ET.Element | None) -> ListFacets | None:
|
|
342
|
+
if el is None:
|
|
343
|
+
return None
|
|
344
|
+
return ListFacets(
|
|
345
|
+
min_items=_int(el, "minItems"),
|
|
346
|
+
max_items=_int(el, "maxItems"),
|
|
347
|
+
sizes=tuple(
|
|
348
|
+
Size(min_length=_int(s, "minLength"), max_length=_int(s, "maxLength"))
|
|
349
|
+
for s in _children(el, "size")
|
|
350
|
+
),
|
|
351
|
+
nested_brackets=_attr(el, "nestedBrackets") == "required",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def syntax(self, el: ET.Element, owner: str) -> Syntax:
|
|
355
|
+
base_type: BaseType | None = None
|
|
356
|
+
data_type: str | None = None
|
|
357
|
+
facets = Facets()
|
|
358
|
+
list_facets = self.list_facets(_child(el, "list"))
|
|
359
|
+
default = None
|
|
360
|
+
for c in el:
|
|
361
|
+
tag = _local(c.tag)
|
|
362
|
+
if tag == "dataType":
|
|
363
|
+
# ``ref`` uses the named type as is; ``base`` derives an
|
|
364
|
+
# anonymous type from it. Both resolve the same way here.
|
|
365
|
+
data_type = _attr(c, "ref") or _attr(c, "base") or ""
|
|
366
|
+
named = self.named(data_type, owner)
|
|
367
|
+
base_type = named.base_type
|
|
368
|
+
facets = self.facets(c).merged_over(named.facets)
|
|
369
|
+
list_facets = list_facets or named.list
|
|
370
|
+
elif tag in _BASE_TYPES:
|
|
371
|
+
base_type = BaseType(tag)
|
|
372
|
+
facets = self.facets(c)
|
|
373
|
+
elif tag == "default":
|
|
374
|
+
default = Default(
|
|
375
|
+
kind=_attr(c, "type") or "object", # type: ignore[arg-type]
|
|
376
|
+
value=_attr(c, "value") or "",
|
|
377
|
+
status=_status(c),
|
|
378
|
+
)
|
|
379
|
+
if base_type is None:
|
|
380
|
+
raise ParseError(f"{owner!r} has a syntax without a type")
|
|
381
|
+
return Syntax(
|
|
382
|
+
base_type=base_type,
|
|
383
|
+
data_type=data_type,
|
|
384
|
+
facets=facets,
|
|
385
|
+
list=list_facets,
|
|
386
|
+
hidden=_flag(el, "hidden"),
|
|
387
|
+
secured=_flag(el, "secured"),
|
|
388
|
+
command=_flag(el, "command"),
|
|
389
|
+
default=default,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# -- objects -----------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
def parameter(self, el: ET.Element, owner: str) -> Parameter:
|
|
395
|
+
name = _attr(el, "name") or ""
|
|
396
|
+
syntax_el = _child(el, "syntax")
|
|
397
|
+
if syntax_el is None:
|
|
398
|
+
raise ParseError(f"parameter {owner}{name} has no syntax")
|
|
399
|
+
access = _attr(el, "access")
|
|
400
|
+
notify = _attr(el, "activeNotify")
|
|
401
|
+
mandatory = _attr(el, "mandatory")
|
|
402
|
+
return Parameter(
|
|
403
|
+
name=name,
|
|
404
|
+
access=Access(access) if access else None,
|
|
405
|
+
description=_text(_child(el, "description")),
|
|
406
|
+
version=_attr(el, "version"),
|
|
407
|
+
status=_status(el),
|
|
408
|
+
active_notify=ActiveNotify(notify) if notify else ActiveNotify.NORMAL,
|
|
409
|
+
forced_inform=_flag(el, "forcedInform"),
|
|
410
|
+
mandatory=None if mandatory is None else mandatory == "true",
|
|
411
|
+
syntax=self.syntax(syntax_el, owner + name),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def object(self, el: ET.Element, prefix: str = "") -> Object:
|
|
415
|
+
path = _attr(el, "name") or ""
|
|
416
|
+
full = prefix + path
|
|
417
|
+
max_raw = _attr(el, "maxEntries")
|
|
418
|
+
min_raw = _int(el, "minEntries")
|
|
419
|
+
access = _attr(el, "access")
|
|
420
|
+
return Object(
|
|
421
|
+
path=path,
|
|
422
|
+
access=Access(access) if access else None,
|
|
423
|
+
min_entries=1 if min_raw is None else min_raw,
|
|
424
|
+
max_entries=None if max_raw == "unbounded" else (int(max_raw) if max_raw else 1),
|
|
425
|
+
num_entries_parameter=_attr(el, "numEntriesParameter"),
|
|
426
|
+
enable_parameter=_attr(el, "enableParameter"),
|
|
427
|
+
discriminator_parameter=_attr(el, "discriminatorParameter"),
|
|
428
|
+
mount_type=_attr(el, "mountType"),
|
|
429
|
+
unique_keys=tuple(
|
|
430
|
+
UniqueKey(
|
|
431
|
+
functional=_attr(k, "functional") != "false",
|
|
432
|
+
parameters=tuple(_attr(p, "ref") or "" for p in _children(k, "parameter")),
|
|
433
|
+
)
|
|
434
|
+
for k in _children(el, "uniqueKey")
|
|
435
|
+
),
|
|
436
|
+
description=_text(_child(el, "description")),
|
|
437
|
+
version=_attr(el, "version"),
|
|
438
|
+
status=_status(el),
|
|
439
|
+
parameters={
|
|
440
|
+
p.name: p for p in (self.parameter(c, full) for c in _children(el, "parameter"))
|
|
441
|
+
},
|
|
442
|
+
commands={c.name: c for c in (self.command(e, full) for e in _children(el, "command"))},
|
|
443
|
+
events={e.name: e for e in (self.event(x, full) for x in _children(el, "event"))},
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def arguments(self, el: ET.Element | None, owner: str) -> Arguments:
|
|
447
|
+
if el is None:
|
|
448
|
+
return Arguments()
|
|
449
|
+
return Arguments(
|
|
450
|
+
parameters={
|
|
451
|
+
p.name: p for p in (self.parameter(c, owner) for c in _children(el, "parameter"))
|
|
452
|
+
},
|
|
453
|
+
objects={o.path: o for o in (self.object(c, owner) for c in _children(el, "object"))},
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def command(self, el: ET.Element, owner: str) -> Command:
|
|
457
|
+
name = _attr(el, "name") or ""
|
|
458
|
+
return Command(
|
|
459
|
+
name=name,
|
|
460
|
+
is_async=_flag(el, "async"),
|
|
461
|
+
description=_text(_child(el, "description")),
|
|
462
|
+
version=_attr(el, "version"),
|
|
463
|
+
status=_status(el),
|
|
464
|
+
input=self.arguments(_child(el, "input"), f"{owner}{name} input "),
|
|
465
|
+
output=self.arguments(_child(el, "output"), f"{owner}{name} output "),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def event(self, el: ET.Element, owner: str) -> Event:
|
|
469
|
+
name = _attr(el, "name") or ""
|
|
470
|
+
return Event(
|
|
471
|
+
name=name,
|
|
472
|
+
description=_text(_child(el, "description")),
|
|
473
|
+
version=_attr(el, "version"),
|
|
474
|
+
status=_status(el),
|
|
475
|
+
arguments=self.arguments(el, f"{owner}{name} "),
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# -- models ------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
def model(self, el: ET.Element) -> Model:
|
|
481
|
+
name = _attr(el, "name") or ""
|
|
482
|
+
objects: dict[str, Object] = {}
|
|
483
|
+
for c in _children(el, "object"):
|
|
484
|
+
if _attr(c, "name"):
|
|
485
|
+
obj = self.object(c)
|
|
486
|
+
objects[obj.path] = obj
|
|
487
|
+
profiles = {p.name: p for p in (self.profile(c) for c in _children(el, "profile"))}
|
|
488
|
+
return Model(
|
|
489
|
+
name=name,
|
|
490
|
+
is_service=_flag(el, "isService"),
|
|
491
|
+
description=_text(_child(el, "description")),
|
|
492
|
+
parameters={
|
|
493
|
+
p.name: p
|
|
494
|
+
for p in (self.parameter(c, name + " ") for c in _children(el, "parameter"))
|
|
495
|
+
},
|
|
496
|
+
objects=objects,
|
|
497
|
+
profiles=profiles,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def profile(self, el: ET.Element) -> Profile:
|
|
501
|
+
extends = _attr(el, "extends")
|
|
502
|
+
objects: dict[str, ProfileObject] = {}
|
|
503
|
+
for o in _children(el, "object"):
|
|
504
|
+
ref = _attr(o, "ref") or ""
|
|
505
|
+
objects[ref] = ProfileObject(
|
|
506
|
+
path=ref,
|
|
507
|
+
requirement=_attr(o, "requirement") or "present",
|
|
508
|
+
parameters={
|
|
509
|
+
_attr(p, "ref") or "": _attr(p, "requirement") or ""
|
|
510
|
+
for p in _children(o, "parameter")
|
|
511
|
+
},
|
|
512
|
+
commands={
|
|
513
|
+
_attr(p, "ref") or "": _attr(p, "requirement") or ""
|
|
514
|
+
for p in _children(o, "command")
|
|
515
|
+
},
|
|
516
|
+
events={
|
|
517
|
+
_attr(p, "ref") or "": _attr(p, "requirement") or ""
|
|
518
|
+
for p in _children(o, "event")
|
|
519
|
+
},
|
|
520
|
+
)
|
|
521
|
+
return Profile(
|
|
522
|
+
name=_attr(el, "name") or "",
|
|
523
|
+
base=_attr(el, "base"),
|
|
524
|
+
extends=tuple(extends.split()) if extends else (),
|
|
525
|
+
version=_attr(el, "version"),
|
|
526
|
+
status=_status(el),
|
|
527
|
+
description=_text(_child(el, "description")),
|
|
528
|
+
parameters={
|
|
529
|
+
_attr(p, "ref") or "": _attr(p, "requirement") or ""
|
|
530
|
+
for p in _children(el, "parameter")
|
|
531
|
+
},
|
|
532
|
+
objects=objects,
|
|
533
|
+
)
|
bbfmodels/py.typed
ADDED
|
File without changes
|
bbfmodels/schema.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""A schema: the objects of one or more documents, addressable by path.
|
|
2
|
+
|
|
3
|
+
A device's tree is described by several documents at once: the root model
|
|
4
|
+
(TR-181 or TR-098), service models mounted under ``Services.`` (TR-104
|
|
5
|
+
voice, TR-135 set-top box) and a vendor's extensions. ``Schema`` indexes
|
|
6
|
+
all of them by full schema path, the path with ``{i}`` in place of every
|
|
7
|
+
instance number, and resolves data paths (``Device.WiFi.Radio.1.Channel``)
|
|
8
|
+
to the schema node they belong to.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from bbfmodels.ir import DataType, Document, Object, Parameter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnknownPath(KeyError):
|
|
19
|
+
"""The path names nothing the schema knows."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Resolved:
|
|
24
|
+
"""What a data path points at."""
|
|
25
|
+
|
|
26
|
+
object: Object
|
|
27
|
+
parameter: Parameter | None
|
|
28
|
+
instances: tuple[int, ...]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def schema_path(self) -> str:
|
|
32
|
+
return self.object.path + (self.parameter.name if self.parameter else "")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Schema:
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.documents: list[Document] = []
|
|
38
|
+
self.objects: dict[str, Object] = {}
|
|
39
|
+
self.data_types: dict[str, DataType] = {}
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_documents(cls, *documents: Document) -> Schema:
|
|
43
|
+
schema = cls()
|
|
44
|
+
for doc in documents:
|
|
45
|
+
schema.add(doc)
|
|
46
|
+
return schema
|
|
47
|
+
|
|
48
|
+
def add(self, document: Document, mount: str | None = None) -> None:
|
|
49
|
+
"""Index a document's objects.
|
|
50
|
+
|
|
51
|
+
A service model (``VoiceService:2.0``) is mounted under ``mount``,
|
|
52
|
+
or under the ``Services.`` object of the root model already added,
|
|
53
|
+
so its objects get the full path they have on a device.
|
|
54
|
+
"""
|
|
55
|
+
self.documents.append(document)
|
|
56
|
+
self.data_types.update(document.data_types)
|
|
57
|
+
for model in document.models:
|
|
58
|
+
prefix = ""
|
|
59
|
+
if model.is_service:
|
|
60
|
+
prefix = mount if mount is not None else self._services_mount()
|
|
61
|
+
for obj in model.objects.values():
|
|
62
|
+
mounted = obj.model_copy(update={"path": prefix + obj.path}) if prefix else obj
|
|
63
|
+
self.objects[mounted.path] = mounted
|
|
64
|
+
|
|
65
|
+
def _services_mount(self) -> str:
|
|
66
|
+
roots = [p for p in self.objects if p.count(".") == 1]
|
|
67
|
+
if len(roots) != 1:
|
|
68
|
+
raise ValueError("a service model needs a mount point or exactly one root model")
|
|
69
|
+
return roots[0] + "Services."
|
|
70
|
+
|
|
71
|
+
# -- lookup ----------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def roots(self) -> list[Object]:
|
|
75
|
+
return [o for p, o in self.objects.items() if p.count(".") == 1]
|
|
76
|
+
|
|
77
|
+
def object(self, schema_path: str) -> Object:
|
|
78
|
+
try:
|
|
79
|
+
return self.objects[schema_path]
|
|
80
|
+
except KeyError:
|
|
81
|
+
raise UnknownPath(schema_path) from None
|
|
82
|
+
|
|
83
|
+
def parameter(self, schema_path: str) -> Parameter:
|
|
84
|
+
obj_path, _, name = schema_path.rpartition(".")
|
|
85
|
+
obj = self.object(obj_path + ".")
|
|
86
|
+
try:
|
|
87
|
+
return obj.parameters[name]
|
|
88
|
+
except KeyError:
|
|
89
|
+
raise UnknownPath(schema_path) from None
|
|
90
|
+
|
|
91
|
+
def children(self, schema_path: str) -> list[Object]:
|
|
92
|
+
"""The objects directly under an object, tables included."""
|
|
93
|
+
return [o for o in self.objects.values() if o.parent_path == schema_path]
|
|
94
|
+
|
|
95
|
+
def resolve(self, data_path: str) -> Resolved:
|
|
96
|
+
"""The schema node a data path points at, and the instance numbers in it.
|
|
97
|
+
|
|
98
|
+
A path ending in ``.`` is an object; otherwise the last segment is a
|
|
99
|
+
parameter. A table may be addressed without an instance
|
|
100
|
+
(``Device.WiFi.Radio.``) or with the literal ``{i}``, so a schema path
|
|
101
|
+
resolves too. Raises ``UnknownPath`` for anything the schema does not
|
|
102
|
+
define, including a vendor extension it was not given.
|
|
103
|
+
"""
|
|
104
|
+
is_object = data_path.endswith(".")
|
|
105
|
+
segments = data_path.rstrip(".").split(".")
|
|
106
|
+
param_name = None if is_object else segments.pop()
|
|
107
|
+
path = ""
|
|
108
|
+
instances: list[int] = []
|
|
109
|
+
pending = False # a table was named and its instance has not been seen yet
|
|
110
|
+
for seg in segments:
|
|
111
|
+
if pending:
|
|
112
|
+
if seg == "{i}":
|
|
113
|
+
pending = False
|
|
114
|
+
elif seg.isdigit():
|
|
115
|
+
instances.append(int(seg))
|
|
116
|
+
pending = False
|
|
117
|
+
else:
|
|
118
|
+
raise UnknownPath(data_path)
|
|
119
|
+
elif path + seg + "." in self.objects:
|
|
120
|
+
path += seg + "."
|
|
121
|
+
elif path + seg + ".{i}." in self.objects:
|
|
122
|
+
path += seg + ".{i}."
|
|
123
|
+
pending = True
|
|
124
|
+
else:
|
|
125
|
+
raise UnknownPath(data_path)
|
|
126
|
+
obj = self.objects[path]
|
|
127
|
+
if param_name is None:
|
|
128
|
+
return Resolved(obj, None, tuple(instances))
|
|
129
|
+
if pending:
|
|
130
|
+
# Parameters belong to an instance, not to the table itself.
|
|
131
|
+
raise UnknownPath(data_path)
|
|
132
|
+
try:
|
|
133
|
+
return Resolved(obj, obj.parameters[param_name], tuple(instances))
|
|
134
|
+
except KeyError:
|
|
135
|
+
raise UnknownPath(data_path) from None
|
|
136
|
+
|
|
137
|
+
def schema_path(self, data_path: str) -> str:
|
|
138
|
+
return self.resolve(data_path).schema_path
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def data_path(schema_path: str, *instances: int) -> str:
|
|
142
|
+
"""A schema path with its ``{i}`` placeholders filled in order."""
|
|
143
|
+
parts = schema_path.split("{i}")
|
|
144
|
+
if len(parts) - 1 != len(instances):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"{schema_path!r} has {len(parts) - 1} instance placeholders, "
|
|
147
|
+
f"{len(instances)} numbers given"
|
|
148
|
+
)
|
|
149
|
+
out = parts[0]
|
|
150
|
+
for number, part in zip(instances, parts[1:], strict=True):
|
|
151
|
+
out += str(number) + part
|
|
152
|
+
return out
|