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/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("&lt;")
125
+ last = i + 1
126
+ out.append(text[last:])
127
+ repaired = "".join(out).replace('="""', '="&quot;"')
128
+ return _BARE_AMP.sub("&amp;", 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