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/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Typed models and schema tooling for Broadband Forum data models.
|
|
2
|
+
|
|
3
|
+
The Broadband Forum publishes its device data models (TR-181, TR-098, TR-104
|
|
4
|
+
and the rest) as XML in the TR-106 schema. This package reads that XML once
|
|
5
|
+
into a normalised intermediate representation, works with the models at
|
|
6
|
+
runtime from that representation, and generates typed bindings from it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("bbfmodels")
|
|
13
|
+
except PackageNotFoundError: # running from a source tree that is not installed
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = ["__version__"]
|
bbfmodels/cli.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from bbfmodels import __version__, sources
|
|
11
|
+
from bbfmodels.ir import Document
|
|
12
|
+
from bbfmodels.parser import ParseError, parse_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="bbfmodels",
|
|
18
|
+
description="Typed models and schema tooling for Broadband Forum data models.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
21
|
+
sub = parser.add_subparsers(dest="command")
|
|
22
|
+
|
|
23
|
+
fetch = sub.add_parser("fetch", help="download published documents into the local cache")
|
|
24
|
+
fetch.add_argument("names", nargs="*", help="document file names; none means the core set")
|
|
25
|
+
fetch.add_argument(
|
|
26
|
+
"--list", choices=sorted(sources.SITES), help="list what the Forum publishes"
|
|
27
|
+
)
|
|
28
|
+
fetch.add_argument("--into", type=Path, help="directory to download into")
|
|
29
|
+
|
|
30
|
+
ir = sub.add_parser("ir", help="parse a document and write its IR as JSON")
|
|
31
|
+
ir.add_argument("document", type=Path)
|
|
32
|
+
ir.add_argument("-o", "--output", type=Path, help="write here instead of stdout")
|
|
33
|
+
ir.add_argument("--indent", type=int, default=None, help="pretty-print with this indent")
|
|
34
|
+
|
|
35
|
+
sub.add_parser("ir-schema", help="print the JSON Schema of the IR")
|
|
36
|
+
|
|
37
|
+
args = parser.parse_args(argv)
|
|
38
|
+
if args.command is None:
|
|
39
|
+
parser.print_help()
|
|
40
|
+
return 0
|
|
41
|
+
try:
|
|
42
|
+
if args.command == "fetch":
|
|
43
|
+
return _fetch(args)
|
|
44
|
+
if args.command == "ir":
|
|
45
|
+
return _ir(args)
|
|
46
|
+
if args.command == "ir-schema":
|
|
47
|
+
print(json.dumps(Document.model_json_schema(), indent=2))
|
|
48
|
+
return 0
|
|
49
|
+
except ParseError as exc:
|
|
50
|
+
print(f"bbfmodels: {exc}", file=sys.stderr)
|
|
51
|
+
return 1
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
print(f"bbfmodels: {exc}", file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
return 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fetch(args: argparse.Namespace) -> int:
|
|
59
|
+
if args.list:
|
|
60
|
+
for name in sources.available(args.list):
|
|
61
|
+
print(name)
|
|
62
|
+
return 0
|
|
63
|
+
for name in args.names or sources.CORE:
|
|
64
|
+
print(sources.fetch(name, args.into))
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ir(args: argparse.Namespace) -> int:
|
|
69
|
+
doc = parse_file(args.document)
|
|
70
|
+
text = doc.model_dump_json(exclude_defaults=True, indent=args.indent)
|
|
71
|
+
if args.output:
|
|
72
|
+
args.output.write_text(text + "\n", encoding="utf-8")
|
|
73
|
+
else:
|
|
74
|
+
print(text)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
sys.exit(main())
|
bbfmodels/codec.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Convert between wire text and Python values, and validate against facets.
|
|
2
|
+
|
|
3
|
+
CWMP and USP carry every parameter value as text. TR-106 defines how each
|
|
4
|
+
base type is written: integers in decimal, booleans as ``true``/``false``
|
|
5
|
+
or ``1``/``0``, dates in ISO 8601 with the Unknown Time as
|
|
6
|
+
``0001-01-01T00:00:00Z``, binary as upper-case hex or base64, and lists as
|
|
7
|
+
comma-separated items of the base type. ``decode`` and ``encode`` are the
|
|
8
|
+
two directions of that rule; ``problems`` checks a value against the
|
|
9
|
+
syntax's facets and reports every violation it finds.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import binascii
|
|
16
|
+
import re
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from decimal import Decimal, InvalidOperation
|
|
19
|
+
|
|
20
|
+
from bbfmodels.ir import BaseType, Range, Size, Syntax
|
|
21
|
+
|
|
22
|
+
Scalar = str | int | bool | datetime | bytes | Decimal
|
|
23
|
+
Value = Scalar | list[Scalar]
|
|
24
|
+
|
|
25
|
+
UNKNOWN_TIME = datetime(1, 1, 1, tzinfo=UTC)
|
|
26
|
+
|
|
27
|
+
_BOUNDS = {
|
|
28
|
+
BaseType.INT: (-(2**31), 2**31 - 1),
|
|
29
|
+
BaseType.UNSIGNED_INT: (0, 2**32 - 1),
|
|
30
|
+
BaseType.LONG: (-(2**63), 2**63 - 1),
|
|
31
|
+
BaseType.UNSIGNED_LONG: (0, 2**64 - 1),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CodecError(ValueError):
|
|
36
|
+
"""The text is not a value of the parameter's type."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# -- scalars ----------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def decode_scalar(base_type: BaseType, text: str) -> Scalar:
|
|
43
|
+
try:
|
|
44
|
+
match base_type:
|
|
45
|
+
case BaseType.STRING:
|
|
46
|
+
return text
|
|
47
|
+
case BaseType.BOOLEAN:
|
|
48
|
+
if text in ("1", "true"):
|
|
49
|
+
return True
|
|
50
|
+
if text in ("0", "false"):
|
|
51
|
+
return False
|
|
52
|
+
raise CodecError(f"{text!r} is not a boolean")
|
|
53
|
+
case BaseType.INT | BaseType.UNSIGNED_INT | BaseType.LONG | BaseType.UNSIGNED_LONG:
|
|
54
|
+
return int(text)
|
|
55
|
+
case BaseType.DECIMAL:
|
|
56
|
+
return Decimal(text)
|
|
57
|
+
case BaseType.DATE_TIME:
|
|
58
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
59
|
+
case BaseType.HEX_BINARY:
|
|
60
|
+
return bytes.fromhex(text)
|
|
61
|
+
case BaseType.BASE64:
|
|
62
|
+
return base64.b64decode(text, validate=True)
|
|
63
|
+
except (ValueError, InvalidOperation, binascii.Error) as exc:
|
|
64
|
+
raise CodecError(f"{text!r} is not a {base_type.value}") from exc
|
|
65
|
+
raise CodecError(f"unknown base type {base_type!r}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def encode_scalar(base_type: BaseType, value: Scalar) -> str:
|
|
69
|
+
match base_type:
|
|
70
|
+
case BaseType.STRING:
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
return value
|
|
73
|
+
case BaseType.BOOLEAN:
|
|
74
|
+
if isinstance(value, bool):
|
|
75
|
+
return "true" if value else "false"
|
|
76
|
+
case BaseType.INT | BaseType.UNSIGNED_INT | BaseType.LONG | BaseType.UNSIGNED_LONG:
|
|
77
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
78
|
+
return str(value)
|
|
79
|
+
case BaseType.DECIMAL:
|
|
80
|
+
if isinstance(value, Decimal | int) and not isinstance(value, bool):
|
|
81
|
+
return str(value)
|
|
82
|
+
case BaseType.DATE_TIME:
|
|
83
|
+
if isinstance(value, datetime):
|
|
84
|
+
if value.tzinfo is None:
|
|
85
|
+
raise CodecError("a dateTime needs a time zone")
|
|
86
|
+
text = value.astimezone(UTC).isoformat()
|
|
87
|
+
return text.replace("+00:00", "Z")
|
|
88
|
+
case BaseType.HEX_BINARY:
|
|
89
|
+
if isinstance(value, bytes):
|
|
90
|
+
return value.hex().upper()
|
|
91
|
+
case BaseType.BASE64:
|
|
92
|
+
if isinstance(value, bytes):
|
|
93
|
+
return base64.b64encode(value).decode("ascii")
|
|
94
|
+
raise CodecError(f"{value!r} cannot be written as a {base_type.value}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# -- lists ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def split_list(text: str, nested_brackets: bool = False) -> list[str]:
|
|
101
|
+
"""Items of a comma-separated list, whitespace around items removed.
|
|
102
|
+
|
|
103
|
+
With ``nested_brackets`` an item may itself be a bracketed list, as in
|
|
104
|
+
``[a,b],[c]``, and the split happens only at the top level.
|
|
105
|
+
"""
|
|
106
|
+
if text.strip() == "":
|
|
107
|
+
return []
|
|
108
|
+
if not nested_brackets:
|
|
109
|
+
return [item.strip() for item in text.split(",")]
|
|
110
|
+
items: list[str] = []
|
|
111
|
+
depth = 0
|
|
112
|
+
start = 0
|
|
113
|
+
for i, ch in enumerate(text):
|
|
114
|
+
if ch == "[":
|
|
115
|
+
depth += 1
|
|
116
|
+
elif ch == "]":
|
|
117
|
+
depth -= 1
|
|
118
|
+
elif ch == "," and depth == 0:
|
|
119
|
+
items.append(text[start:i].strip())
|
|
120
|
+
start = i + 1
|
|
121
|
+
items.append(text[start:].strip())
|
|
122
|
+
return items
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def join_list(items: list[str]) -> str:
|
|
126
|
+
return ",".join(items)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# -- syntax -----------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def decode(syntax: Syntax, text: str) -> Value:
|
|
133
|
+
"""The Python value of a parameter's wire text."""
|
|
134
|
+
if syntax.list is not None:
|
|
135
|
+
items = split_list(text, syntax.list.nested_brackets)
|
|
136
|
+
return [decode_scalar(syntax.base_type, item) for item in items]
|
|
137
|
+
return decode_scalar(syntax.base_type, text)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def encode(syntax: Syntax, value: Value) -> str:
|
|
141
|
+
"""The wire text of a Python value for a parameter."""
|
|
142
|
+
if syntax.list is not None:
|
|
143
|
+
if isinstance(value, list):
|
|
144
|
+
return join_list([encode_scalar(syntax.base_type, item) for item in value])
|
|
145
|
+
if isinstance(value, str):
|
|
146
|
+
# A list given as text is passed through as the caller wrote it.
|
|
147
|
+
return value
|
|
148
|
+
raise CodecError(f"{value!r} is not a list")
|
|
149
|
+
if isinstance(value, list):
|
|
150
|
+
raise CodecError("a list was given for a single value")
|
|
151
|
+
return encode_scalar(syntax.base_type, value)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def problems(syntax: Syntax, text: str) -> list[str]:
|
|
155
|
+
"""Every way the wire text violates the syntax; empty means valid."""
|
|
156
|
+
found: list[str] = []
|
|
157
|
+
if syntax.list is not None:
|
|
158
|
+
items = split_list(text, syntax.list.nested_brackets)
|
|
159
|
+
lf = syntax.list
|
|
160
|
+
if lf.min_items is not None and len(items) < lf.min_items:
|
|
161
|
+
found.append(f"list has {len(items)} items, at least {lf.min_items} required")
|
|
162
|
+
if lf.max_items is not None and len(items) > lf.max_items:
|
|
163
|
+
found.append(f"list has {len(items)} items, at most {lf.max_items} allowed")
|
|
164
|
+
for size in lf.sizes:
|
|
165
|
+
if size.max_length is not None and len(text) > size.max_length:
|
|
166
|
+
found.append(f"list text is {len(text)} characters, at most {size.max_length}")
|
|
167
|
+
for item in items:
|
|
168
|
+
found.extend(f"item {item!r}: {p}" for p in _scalar_problems(syntax, item))
|
|
169
|
+
return found
|
|
170
|
+
return _scalar_problems(syntax, text)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _scalar_problems(syntax: Syntax, text: str) -> list[str]:
|
|
174
|
+
try:
|
|
175
|
+
value = decode_scalar(syntax.base_type, text)
|
|
176
|
+
except CodecError as exc:
|
|
177
|
+
return [str(exc)]
|
|
178
|
+
f = syntax.facets
|
|
179
|
+
found: list[str] = []
|
|
180
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
181
|
+
low, high = _BOUNDS.get(syntax.base_type, (None, None))
|
|
182
|
+
if low is not None and high is not None and not low <= value <= high:
|
|
183
|
+
found.append(f"{value} is outside the {syntax.base_type.value} range")
|
|
184
|
+
if f.ranges and not any(_in_range(value, r) for r in f.ranges):
|
|
185
|
+
found.append(f"{value} is outside the allowed ranges")
|
|
186
|
+
if (
|
|
187
|
+
isinstance(value, str | bytes)
|
|
188
|
+
and f.sizes
|
|
189
|
+
and not any(_in_size(len(value), s) for s in f.sizes)
|
|
190
|
+
):
|
|
191
|
+
found.append(f"length {len(value)} is outside the allowed sizes")
|
|
192
|
+
if isinstance(value, str):
|
|
193
|
+
if (
|
|
194
|
+
f.enumerations
|
|
195
|
+
and f.enumeration_ref is None
|
|
196
|
+
and value not in {e.value for e in f.enumerations}
|
|
197
|
+
):
|
|
198
|
+
found.append(f"{value!r} is not one of the enumerated values")
|
|
199
|
+
if f.patterns and not any(_matches(p.value, value) for p in f.patterns):
|
|
200
|
+
found.append(f"{value!r} matches none of the patterns")
|
|
201
|
+
return found
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _in_range(value: int, r: Range) -> bool:
|
|
205
|
+
if r.min_inclusive is not None and value < r.min_inclusive:
|
|
206
|
+
return False
|
|
207
|
+
if r.max_inclusive is not None and value > r.max_inclusive:
|
|
208
|
+
return False
|
|
209
|
+
return not (r.step and (value - (r.min_inclusive or 0)) % r.step)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _in_size(length: int, s: Size) -> bool:
|
|
213
|
+
if s.min_length is not None and length < s.min_length:
|
|
214
|
+
return False
|
|
215
|
+
return s.max_length is None or length <= s.max_length
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
_pattern_cache: dict[str, re.Pattern[str] | None] = {}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _matches(pattern: str, value: str) -> bool:
|
|
222
|
+
# TR-106 patterns are XML Schema regular expressions, which are implicitly
|
|
223
|
+
# anchored. Python's syntax covers the ones the Forum publishes; a pattern
|
|
224
|
+
# Python cannot compile is treated as matching rather than rejecting
|
|
225
|
+
# every value.
|
|
226
|
+
if pattern not in _pattern_cache:
|
|
227
|
+
try:
|
|
228
|
+
_pattern_cache[pattern] = re.compile(pattern)
|
|
229
|
+
except re.error:
|
|
230
|
+
_pattern_cache[pattern] = None
|
|
231
|
+
compiled = _pattern_cache[pattern]
|
|
232
|
+
return compiled is None or compiled.fullmatch(value) is not None
|
bbfmodels/ir.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""The intermediate representation (IR) of a Broadband Forum data model.
|
|
2
|
+
|
|
3
|
+
The IR is the contract between the parser and everything else. The parser
|
|
4
|
+
turns a published TR-106 XML document into one ``Document``; the runtime
|
|
5
|
+
and every code generator read the ``Document`` and never the XML. It is a
|
|
6
|
+
plain, fully resolved description of the model:
|
|
7
|
+
|
|
8
|
+
- Every object carries its parameters, and for USP its commands and events,
|
|
9
|
+
keyed by name in document order.
|
|
10
|
+
- Every parameter's syntax carries its effective base type and facets, with
|
|
11
|
+
named data types (``IPAddress``, ``StatsCounter32``) already resolved
|
|
12
|
+
through their inheritance chain, so a consumer needs no second lookup.
|
|
13
|
+
The name of the data type is kept as well, for emitters that want to
|
|
14
|
+
generate one named type and refer to it.
|
|
15
|
+
- Paths keep the Forum's spelling. A multi-instance object's path ends in
|
|
16
|
+
``{i}.`` exactly as the specification writes it; a data path on a device
|
|
17
|
+
replaces ``{i}`` with an instance number.
|
|
18
|
+
- An attribute the document leaves out is ``None`` or the schema's default
|
|
19
|
+
(an object or parameter without ``status`` is current).
|
|
20
|
+
|
|
21
|
+
The models are pydantic so the IR serialises to JSON and has a JSON Schema
|
|
22
|
+
of its own without a second description of it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from enum import StrEnum
|
|
28
|
+
from typing import Literal
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Status(StrEnum):
|
|
34
|
+
CURRENT = "current"
|
|
35
|
+
DEPRECATED = "deprecated"
|
|
36
|
+
OBSOLETED = "obsoleted"
|
|
37
|
+
DELETED = "deleted"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Access(StrEnum):
|
|
41
|
+
READ_ONLY = "readOnly"
|
|
42
|
+
READ_WRITE = "readWrite"
|
|
43
|
+
WRITE_ONCE_READ_ONLY = "writeOnceReadOnly"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BaseType(StrEnum):
|
|
47
|
+
"""The primitive types of TR-106. Every parameter resolves to one."""
|
|
48
|
+
|
|
49
|
+
STRING = "string"
|
|
50
|
+
INT = "int"
|
|
51
|
+
UNSIGNED_INT = "unsignedInt"
|
|
52
|
+
LONG = "long"
|
|
53
|
+
UNSIGNED_LONG = "unsignedLong"
|
|
54
|
+
BOOLEAN = "boolean"
|
|
55
|
+
DATE_TIME = "dateTime"
|
|
56
|
+
HEX_BINARY = "hexBinary"
|
|
57
|
+
BASE64 = "base64"
|
|
58
|
+
DECIMAL = "decimal"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ActiveNotify(StrEnum):
|
|
62
|
+
NORMAL = "normal"
|
|
63
|
+
FORCE_ENABLED = "forceEnabled"
|
|
64
|
+
FORCE_DEFAULT_ENABLED = "forceDefaultEnabled"
|
|
65
|
+
CAN_DENY = "canDeny"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Node(BaseModel):
|
|
69
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Size(_Node):
|
|
73
|
+
min_length: int | None = None
|
|
74
|
+
max_length: int | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Range(_Node):
|
|
78
|
+
min_inclusive: int | None = None
|
|
79
|
+
max_inclusive: int | None = None
|
|
80
|
+
step: int | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Enumeration(_Node):
|
|
84
|
+
value: str
|
|
85
|
+
code: int | None = None
|
|
86
|
+
access: Access | None = None
|
|
87
|
+
optional: bool = False
|
|
88
|
+
status: Status = Status.CURRENT
|
|
89
|
+
version: str | None = None
|
|
90
|
+
description: str = ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Pattern(_Node):
|
|
94
|
+
value: str
|
|
95
|
+
status: Status = Status.CURRENT
|
|
96
|
+
version: str | None = None
|
|
97
|
+
description: str = ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PathRef(_Node):
|
|
101
|
+
"""The parameter holds the path of another object or parameter."""
|
|
102
|
+
|
|
103
|
+
ref_type: Literal["strong", "weak"] = "strong"
|
|
104
|
+
target_parent: str | None = None
|
|
105
|
+
target_type: str = "row"
|
|
106
|
+
target_data_type: str | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class InstanceRef(_Node):
|
|
110
|
+
"""The parameter holds the instance number of a row of another table."""
|
|
111
|
+
|
|
112
|
+
ref_type: Literal["strong", "weak"] = "strong"
|
|
113
|
+
target_parent: str | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class EnumerationRef(_Node):
|
|
117
|
+
"""The allowed values are whatever another parameter lists."""
|
|
118
|
+
|
|
119
|
+
target_param: str
|
|
120
|
+
null_value: str | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Facets(_Node):
|
|
124
|
+
"""Constraints on a value. Which ones apply depends on the base type."""
|
|
125
|
+
|
|
126
|
+
sizes: tuple[Size, ...] = ()
|
|
127
|
+
ranges: tuple[Range, ...] = ()
|
|
128
|
+
enumerations: tuple[Enumeration, ...] = ()
|
|
129
|
+
patterns: tuple[Pattern, ...] = ()
|
|
130
|
+
units: str | None = None
|
|
131
|
+
path_ref: PathRef | None = None
|
|
132
|
+
instance_ref: InstanceRef | None = None
|
|
133
|
+
enumeration_ref: EnumerationRef | None = None
|
|
134
|
+
|
|
135
|
+
def merged_over(self, base: Facets) -> Facets:
|
|
136
|
+
"""These facets applied on top of an inherited set.
|
|
137
|
+
|
|
138
|
+
A facet kind stated here replaces the inherited one of the same kind;
|
|
139
|
+
a kind left unstated is inherited. This is how a parameter that uses
|
|
140
|
+
a named data type narrows it, for example a ``StatsCounter64`` with
|
|
141
|
+
its own units.
|
|
142
|
+
"""
|
|
143
|
+
return Facets(
|
|
144
|
+
sizes=self.sizes or base.sizes,
|
|
145
|
+
ranges=self.ranges or base.ranges,
|
|
146
|
+
enumerations=self.enumerations or base.enumerations,
|
|
147
|
+
patterns=self.patterns or base.patterns,
|
|
148
|
+
units=self.units if self.units is not None else base.units,
|
|
149
|
+
path_ref=self.path_ref or base.path_ref,
|
|
150
|
+
instance_ref=self.instance_ref or base.instance_ref,
|
|
151
|
+
enumeration_ref=self.enumeration_ref or base.enumeration_ref,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ListFacets(_Node):
|
|
156
|
+
"""The parameter is a comma-separated list of items of the base type."""
|
|
157
|
+
|
|
158
|
+
min_items: int | None = None
|
|
159
|
+
max_items: int | None = None
|
|
160
|
+
sizes: tuple[Size, ...] = ()
|
|
161
|
+
nested_brackets: bool = False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class DataType(_Node):
|
|
165
|
+
"""A named data type defined at document level."""
|
|
166
|
+
|
|
167
|
+
name: str
|
|
168
|
+
base: str | None = None
|
|
169
|
+
base_type: BaseType
|
|
170
|
+
facets: Facets = Facets()
|
|
171
|
+
list: ListFacets | None = None
|
|
172
|
+
description: str = ""
|
|
173
|
+
status: Status = Status.CURRENT
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Default(_Node):
|
|
177
|
+
kind: Literal["object", "factory", "implementation", "parameter"]
|
|
178
|
+
value: str
|
|
179
|
+
status: Status = Status.CURRENT
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Syntax(_Node):
|
|
183
|
+
base_type: BaseType
|
|
184
|
+
data_type: str | None = None
|
|
185
|
+
facets: Facets = Facets()
|
|
186
|
+
list: ListFacets | None = None
|
|
187
|
+
hidden: bool = False
|
|
188
|
+
secured: bool = False
|
|
189
|
+
command: bool = False
|
|
190
|
+
default: Default | None = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Parameter(_Node):
|
|
194
|
+
name: str
|
|
195
|
+
access: Access | None = None
|
|
196
|
+
description: str = ""
|
|
197
|
+
version: str | None = None
|
|
198
|
+
status: Status = Status.CURRENT
|
|
199
|
+
active_notify: ActiveNotify = ActiveNotify.NORMAL
|
|
200
|
+
forced_inform: bool = False
|
|
201
|
+
mandatory: bool | None = None
|
|
202
|
+
syntax: Syntax
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class UniqueKey(_Node):
|
|
206
|
+
functional: bool = True
|
|
207
|
+
parameters: tuple[str, ...]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Arguments(_Node):
|
|
211
|
+
"""The input or output of a command, or the payload of an event."""
|
|
212
|
+
|
|
213
|
+
parameters: dict[str, Parameter] = Field(default_factory=dict)
|
|
214
|
+
objects: dict[str, Object] = Field(default_factory=dict)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class Command(_Node):
|
|
218
|
+
name: str
|
|
219
|
+
is_async: bool = False
|
|
220
|
+
description: str = ""
|
|
221
|
+
version: str | None = None
|
|
222
|
+
status: Status = Status.CURRENT
|
|
223
|
+
input: Arguments = Field(default_factory=Arguments)
|
|
224
|
+
output: Arguments = Field(default_factory=Arguments)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Event(_Node):
|
|
228
|
+
name: str
|
|
229
|
+
description: str = ""
|
|
230
|
+
version: str | None = None
|
|
231
|
+
status: Status = Status.CURRENT
|
|
232
|
+
arguments: Arguments = Field(default_factory=Arguments)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class Object(_Node):
|
|
236
|
+
"""An object of the model, keyed by its path.
|
|
237
|
+
|
|
238
|
+
``path`` is the full path with a trailing dot, ``Device.WiFi.Radio.{i}.``.
|
|
239
|
+
Inside a command's arguments or an event's payload it is relative to the
|
|
240
|
+
containing object, ``RouteHops.{i}.``.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
path: str
|
|
244
|
+
access: Access | None = None
|
|
245
|
+
min_entries: int = 1
|
|
246
|
+
max_entries: int | None = 1
|
|
247
|
+
num_entries_parameter: str | None = None
|
|
248
|
+
enable_parameter: str | None = None
|
|
249
|
+
discriminator_parameter: str | None = None
|
|
250
|
+
mount_type: str | None = None
|
|
251
|
+
unique_keys: tuple[UniqueKey, ...] = ()
|
|
252
|
+
description: str = ""
|
|
253
|
+
version: str | None = None
|
|
254
|
+
status: Status = Status.CURRENT
|
|
255
|
+
parameters: dict[str, Parameter] = Field(default_factory=dict)
|
|
256
|
+
commands: dict[str, Command] = Field(default_factory=dict)
|
|
257
|
+
events: dict[str, Event] = Field(default_factory=dict)
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def name(self) -> str:
|
|
261
|
+
"""The last segment of the path, ``Radio`` for ``Device.WiFi.Radio.{i}.``."""
|
|
262
|
+
parts = self.path.rstrip(".").split(".")
|
|
263
|
+
return parts[-2] if parts[-1] == "{i}" else parts[-1]
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def multi_instance(self) -> bool:
|
|
267
|
+
return self.path.endswith("{i}.")
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def parent_path(self) -> str:
|
|
271
|
+
"""The containing object's path, or ``""`` for a root object."""
|
|
272
|
+
stem = self.path[:-4] if self.multi_instance else self.path
|
|
273
|
+
head = stem.rstrip(".").rpartition(".")[0]
|
|
274
|
+
return head + "." if head else ""
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# Arguments refers to Object, which is defined after it.
|
|
278
|
+
Arguments.model_rebuild()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class ProfileObject(_Node):
|
|
282
|
+
path: str
|
|
283
|
+
requirement: str
|
|
284
|
+
parameters: dict[str, str] = Field(default_factory=dict)
|
|
285
|
+
commands: dict[str, str] = Field(default_factory=dict)
|
|
286
|
+
events: dict[str, str] = Field(default_factory=dict)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class Profile(_Node):
|
|
290
|
+
name: str
|
|
291
|
+
base: str | None = None
|
|
292
|
+
extends: tuple[str, ...] = ()
|
|
293
|
+
version: str | None = None
|
|
294
|
+
status: Status = Status.CURRENT
|
|
295
|
+
description: str = ""
|
|
296
|
+
parameters: dict[str, str] = Field(default_factory=dict)
|
|
297
|
+
objects: dict[str, ProfileObject] = Field(default_factory=dict)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class Model(_Node):
|
|
301
|
+
"""A root data model, ``Device:2.21``, or a service model, ``VoiceService:2.0``."""
|
|
302
|
+
|
|
303
|
+
name: str
|
|
304
|
+
is_service: bool = False
|
|
305
|
+
description: str = ""
|
|
306
|
+
parameters: dict[str, Parameter] = Field(default_factory=dict)
|
|
307
|
+
objects: dict[str, Object] = Field(default_factory=dict)
|
|
308
|
+
profiles: dict[str, Profile] = Field(default_factory=dict)
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def root(self) -> str:
|
|
312
|
+
return self.name.partition(":")[0]
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def version(self) -> str:
|
|
316
|
+
return self.name.partition(":")[2]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class Document(_Node):
|
|
320
|
+
"""One parsed data model document."""
|
|
321
|
+
|
|
322
|
+
spec: str
|
|
323
|
+
file: str | None = None
|
|
324
|
+
protocol: Literal["cwmp", "usp"] | None = None
|
|
325
|
+
description: str = ""
|
|
326
|
+
data_types: dict[str, DataType] = Field(default_factory=dict)
|
|
327
|
+
models: tuple[Model, ...] = ()
|