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 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, ...] = ()