smithy-python 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.
@@ -0,0 +1,573 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Load a Smithy JSON AST into a flattened :class:`Model`.
4
+
5
+ Loading happens in phases:
6
+
7
+ 1. Decode and structurally check the JSON AST, parsing every shape into an
8
+ intermediate ``_RawShape`` (input order is kept everywhere; Python dicts
9
+ preserve insertion order, and ``json`` decodes objects in document order).
10
+ 2. Add the embedded prelude shapes the input does not define.
11
+ 3. Apply ``"type": "apply"`` entries in input order. Traits applied to a shape
12
+ or to one of its locally declared members are merged into that local
13
+ definition (so they propagate through mixins); traits applied to a member
14
+ the shape only inherits are kept aside and merged after flattening.
15
+ 4. Flatten mixins (depth-first, memoized, cycle-checked).
16
+ 5. Resolve every relationship and member target, then build the immutable
17
+ shape objects.
18
+
19
+ Trait merge order, used both for mixins and member overrides, is ``dict``
20
+ update order: traits inherited from each mixin in declaration order, then the
21
+ shape's own traits, then applied traits. A key keeps the position where it was
22
+ first introduced and takes the value from the last writer.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from collections.abc import Iterable, Mapping, Sequence
29
+ from dataclasses import dataclass, field
30
+ from types import MappingProxyType
31
+ from typing import Final, cast
32
+
33
+ from ..exceptions import InvalidShapeIdError, ModelError
34
+ from ._model import Model
35
+ from ._prelude import prelude_ast
36
+ from ._shape_id import ShapeId
37
+ from ._shapes import (
38
+ MIXIN_TRAIT,
39
+ SHAPE_CLASSES,
40
+ UNIT,
41
+ MemberShape,
42
+ Node,
43
+ OperationShape,
44
+ ResourceShape,
45
+ ServiceShape,
46
+ Shape,
47
+ ShapeType,
48
+ SimpleShape,
49
+ )
50
+
51
+ # Smithy 1.0 differs semantically (set shapes, boxing), so only 2.x is loaded.
52
+ SUPPORTED_MAJOR_VERSION: Final = "2"
53
+
54
+ type _Json = dict[str, object]
55
+ type _Traits = dict[ShapeId, Node]
56
+
57
+ _LIFECYCLE: Final = ("create", "put", "read", "update", "delete", "list")
58
+
59
+
60
+ def load_model(source: bytes | str | Mapping[str, object]) -> Model:
61
+ """Load a Smithy JSON AST model.
62
+
63
+ :param source: The JSON AST as bytes, text, or an already-decoded mapping.
64
+ :returns: The flattened model, with embedded prelude shapes.
65
+ :raises ModelError: If the input is malformed or a reference is dangling.
66
+ """
67
+ root = _decode(source)
68
+ version = root.get("smithy")
69
+ if not isinstance(version, str):
70
+ raise ModelError('Smithy JSON AST model must contain a "smithy" version string')
71
+ if version.partition(".")[0] != SUPPORTED_MAJOR_VERSION:
72
+ raise ModelError(
73
+ f"Unsupported Smithy version {version!r}: only Smithy "
74
+ f"{SUPPORTED_MAJOR_VERSION}.x JSON ASTs are supported"
75
+ )
76
+ metadata = _freeze(_object(root.get("metadata", {}), '"metadata"'))
77
+ shapes_node = _object(root.get("shapes", {}), '"shapes"')
78
+
79
+ raw_shapes: dict[ShapeId, _RawShape] = {}
80
+ applies: list[tuple[ShapeId, _Traits]] = []
81
+ for key, value in shapes_node.items():
82
+ shape_id = _parse_id(key, "Shape ID")
83
+ node = _object(value, f"Shape {key}")
84
+ type_name = node.get("type")
85
+ if type_name == "apply":
86
+ traits = _parse_traits(node.get("traits", {}), key)
87
+ applies.append((shape_id, traits))
88
+ continue
89
+ if shape_id.member is not None:
90
+ raise ModelError(
91
+ f"Shape ID {key} must not contain a member; "
92
+ "only apply entries may target members"
93
+ )
94
+ raw_shapes[shape_id] = _parse_shape(shape_id, node)
95
+
96
+ for key, value in prelude_ast().items():
97
+ shape_id = ShapeId.from_string(key)
98
+ if shape_id not in raw_shapes:
99
+ raw_shapes[shape_id] = _parse_shape(shape_id, dict(value))
100
+
101
+ for target, traits in applies:
102
+ _apply(raw_shapes, target, traits)
103
+
104
+ flattener = _Flattener(raw_shapes)
105
+ flat = {shape_id: flattener.flatten(shape_id) for shape_id in raw_shapes}
106
+
107
+ for raw in flat.values():
108
+ _check_required_members(raw)
109
+ _check_references(raw, flat)
110
+
111
+ return Model(
112
+ smithy_version=version,
113
+ metadata=cast(Mapping[str, Node], metadata),
114
+ shapes={shape_id: _build(raw) for shape_id, raw in flat.items()},
115
+ )
116
+
117
+
118
+ @dataclass(slots=True)
119
+ class _RawMember:
120
+ target: ShapeId
121
+ traits: _Traits
122
+
123
+
124
+ @dataclass(slots=True)
125
+ class _RawShape:
126
+ id: ShapeId
127
+ type: ShapeType
128
+ traits: _Traits = field(default_factory=dict[ShapeId, Node])
129
+ mixins: list[ShapeId] = field(default_factory=list[ShapeId])
130
+ members: dict[str, _RawMember] = field(default_factory=dict[str, _RawMember])
131
+ # Service / resource / operation properties. ``None`` means "not set".
132
+ version: str | None = None
133
+ refs: dict[str, ShapeId] = field(default_factory=dict[str, ShapeId])
134
+ """Single references: resource lifecycle operations, operation input/output."""
135
+ ref_lists: dict[str, list[ShapeId]] = field(
136
+ default_factory=dict[str, list[ShapeId]]
137
+ )
138
+ """operations, resources, errors, collectionOperations."""
139
+ named_refs: dict[str, dict[str, ShapeId]] = field(
140
+ default_factory=dict[str, dict[str, ShapeId]]
141
+ )
142
+ """identifiers, properties."""
143
+ rename: dict[ShapeId, str] = field(default_factory=dict[ShapeId, str])
144
+ applied_member_traits: dict[str, _Traits] = field(
145
+ default_factory=dict[str, _Traits]
146
+ )
147
+ """Traits applied to members this shape does not declare locally."""
148
+
149
+
150
+ # --- Decoding -----------------------------------------------------------------
151
+
152
+
153
+ def _reject_constant(value: str) -> object:
154
+ # Python's json module accepts NaN/Infinity by default; JSON does not.
155
+ raise ValueError(f"{value} is not valid JSON")
156
+
157
+
158
+ def _decode(source: bytes | str | Mapping[str, object]) -> _Json:
159
+ if isinstance(source, bytes | str):
160
+ try:
161
+ decoded: object = json.loads(source, parse_constant=_reject_constant)
162
+ except ValueError as error:
163
+ raise ModelError(f"Invalid JSON in Smithy model: {error}") from None
164
+ else:
165
+ decoded = source
166
+ if not isinstance(decoded, Mapping):
167
+ raise ModelError("Smithy JSON AST model must be a JSON object")
168
+ return dict(cast(Mapping[str, object], decoded))
169
+
170
+
171
+ def _object(value: object, what: str) -> _Json:
172
+ if not isinstance(value, Mapping):
173
+ raise ModelError(f"{what} must be a JSON object")
174
+ return dict(cast(Mapping[str, object], value))
175
+
176
+
177
+ def _array(value: object, what: str) -> list[object]:
178
+ if not isinstance(value, list):
179
+ raise ModelError(f"{what} must be a JSON array")
180
+ return cast(list[object], value)
181
+
182
+
183
+ def _parse_id(value: object, what: str) -> ShapeId:
184
+ if not isinstance(value, str):
185
+ raise ModelError(f"{what} must be a string shape ID, found {value!r}")
186
+ try:
187
+ return ShapeId.from_string(value)
188
+ except InvalidShapeIdError as error:
189
+ raise InvalidShapeIdError(f"{what}: {error}") from None
190
+
191
+
192
+ def _parse_ref(value: object, what: str) -> ShapeId:
193
+ """Parse a ``{"target": "ns#Name"}`` reference."""
194
+ node = _object(value, what)
195
+ if "target" not in node:
196
+ raise ModelError(f'{what} is missing a "target"')
197
+ return _parse_id(node["target"], what)
198
+
199
+
200
+ def _parse_traits(value: object, owner: str) -> _Traits:
201
+ node = _object(value, f'{owner}: "traits"')
202
+ return {
203
+ _parse_id(key, f"{owner}: trait"): _freeze(trait) for key, trait in node.items()
204
+ }
205
+
206
+
207
+ def _freeze(value: object) -> Node:
208
+ """Copy a decoded JSON value into a deeply immutable form.
209
+
210
+ Objects become read-only mappings (keeping key order) and arrays become
211
+ tuples. Values inherited through mixins are shared between shapes, so
212
+ freezing them keeps one consumer from changing another shape's traits.
213
+ """
214
+ if isinstance(value, Mapping):
215
+ return MappingProxyType(
216
+ {k: _freeze(v) for k, v in cast(Mapping[str, object], value).items()}
217
+ )
218
+ if isinstance(value, list | tuple):
219
+ return tuple(_freeze(v) for v in cast(Sequence[object], value))
220
+ return cast(Node, value)
221
+
222
+
223
+ def _parse_member(container: ShapeId, name: str, value: object) -> _RawMember:
224
+ member_id = str(container.with_member(name))
225
+ node = _object(value, f"Member {member_id}")
226
+ if "target" not in node:
227
+ raise ModelError(f'Member {member_id} is missing a "target"')
228
+ return _RawMember(
229
+ target=_parse_id(node["target"], f"Member {member_id}"),
230
+ traits=_parse_traits(node.get("traits", {}), f"Member {member_id}"),
231
+ )
232
+
233
+
234
+ def _parse_shape(shape_id: ShapeId, node: _Json) -> _RawShape:
235
+ if "type" not in node:
236
+ raise ModelError(f'Shape {shape_id} is missing a "type"')
237
+ type_name = node["type"]
238
+ try:
239
+ shape_type = ShapeType(type_name)
240
+ except ValueError:
241
+ shape_type = None
242
+ if shape_type is None or shape_type is ShapeType.MEMBER:
243
+ raise ModelError(f"Shape {shape_id} has unknown type {type_name!r}")
244
+
245
+ what = f"Shape {shape_id}"
246
+ raw = _RawShape(
247
+ id=shape_id,
248
+ type=shape_type,
249
+ traits=_parse_traits(node.get("traits", {}), what),
250
+ mixins=[
251
+ _parse_ref(ref, f"{what}: mixin")
252
+ for ref in _array(node.get("mixins", []), f'{what}: "mixins"')
253
+ ],
254
+ )
255
+
256
+ match shape_type:
257
+ case ShapeType.LIST:
258
+ _parse_named_members(raw, node, ("member",))
259
+ case ShapeType.MAP:
260
+ _parse_named_members(raw, node, ("key", "value"))
261
+ case (
262
+ ShapeType.STRUCTURE | ShapeType.UNION | ShapeType.ENUM | ShapeType.INT_ENUM
263
+ ):
264
+ members = _object(node.get("members", {}), f'{what}: "members"')
265
+ for name, member in members.items():
266
+ raw.members[name] = _parse_member(shape_id, name, member)
267
+ case ShapeType.SERVICE:
268
+ if "version" in node:
269
+ version = node["version"]
270
+ if not isinstance(version, str):
271
+ raise ModelError(f'{what}: "version" must be a string')
272
+ raw.version = version
273
+ _parse_ref_lists(raw, node, ("operations", "resources", "errors"))
274
+ rename = _object(node.get("rename", {}), f'{what}: "rename"')
275
+ for key, name in rename.items():
276
+ if not isinstance(name, str):
277
+ raise ModelError(f'{what}: "rename" values must be strings')
278
+ raw.rename[_parse_id(key, f"{what}: rename")] = name
279
+ case ShapeType.RESOURCE:
280
+ for prop in ("identifiers", "properties"):
281
+ named = _object(node.get(prop, {}), f'{what}: "{prop}"')
282
+ raw.named_refs[prop] = {
283
+ name: _parse_ref(ref, f"{what}: {prop} {name!r}")
284
+ for name, ref in named.items()
285
+ }
286
+ _parse_refs(raw, node, _LIFECYCLE)
287
+ _parse_ref_lists(
288
+ raw, node, ("operations", "collectionOperations", "resources")
289
+ )
290
+ case ShapeType.OPERATION:
291
+ _parse_refs(raw, node, ("input", "output"))
292
+ _parse_ref_lists(raw, node, ("errors",))
293
+ case _:
294
+ pass
295
+ return raw
296
+
297
+
298
+ def _parse_named_members(raw: _RawShape, node: _Json, names: Iterable[str]) -> None:
299
+ for name in names:
300
+ if name in node:
301
+ raw.members[name] = _parse_member(raw.id, name, node[name])
302
+
303
+
304
+ def _parse_refs(raw: _RawShape, node: _Json, keys: Iterable[str]) -> None:
305
+ for key in keys:
306
+ if key in node:
307
+ raw.refs[key] = _parse_ref(node[key], f'Shape {raw.id}: "{key}"')
308
+
309
+
310
+ def _parse_ref_lists(raw: _RawShape, node: _Json, keys: Iterable[str]) -> None:
311
+ for key in keys:
312
+ what = f'Shape {raw.id}: "{key}"'
313
+ if key in node:
314
+ raw.ref_lists[key] = [
315
+ _parse_ref(ref, what) for ref in _array(node[key], what)
316
+ ]
317
+
318
+
319
+ # --- Apply ----------------------------------------------------------------------
320
+
321
+
322
+ def _merge_applied(traits: _Traits, applied: Mapping[ShapeId, Node]) -> None:
323
+ """Merge applied traits; array values are concatenated as the spec requires."""
324
+ for key, value in applied.items():
325
+ existing = traits.get(key)
326
+ if isinstance(existing, tuple) and isinstance(value, tuple):
327
+ traits[key] = (*existing, *value)
328
+ else:
329
+ traits[key] = value
330
+
331
+
332
+ def _apply(
333
+ raw_shapes: Mapping[ShapeId, _RawShape], target: ShapeId, traits: _Traits
334
+ ) -> None:
335
+ raw = raw_shapes.get(target.root)
336
+ if raw is None:
337
+ raise ModelError(
338
+ f"Cannot apply traits to {target}: shape {target.root} is not defined"
339
+ )
340
+ if target.member is None:
341
+ _merge_applied(raw.traits, traits)
342
+ elif (member := raw.members.get(target.member)) is not None:
343
+ _merge_applied(member.traits, traits)
344
+ else:
345
+ # Possibly an inherited member; checked after flattening.
346
+ _merge_applied(raw.applied_member_traits.setdefault(target.member, {}), traits)
347
+
348
+
349
+ # --- Mixins ---------------------------------------------------------------------
350
+
351
+
352
+ def _inheritable_traits(mixin: _RawShape) -> _Traits:
353
+ local: set[ShapeId] = {MIXIN_TRAIT}
354
+ mixin_trait = mixin.traits.get(MIXIN_TRAIT)
355
+ if isinstance(mixin_trait, Mapping):
356
+ local_traits = cast(Mapping[str, Node], mixin_trait).get("localTraits", [])
357
+ if isinstance(local_traits, tuple):
358
+ for trait in local_traits:
359
+ local.add(_parse_id(trait, f"Mixin {mixin.id}: localTraits"))
360
+ return {k: v for k, v in mixin.traits.items() if k not in local}
361
+
362
+
363
+ def _union(*lists: Iterable[ShapeId]) -> list[ShapeId]:
364
+ return list(dict.fromkeys(item for ids in lists for item in ids))
365
+
366
+
367
+ class _Flattener:
368
+ def __init__(self, raw_shapes: Mapping[ShapeId, _RawShape]) -> None:
369
+ self._raw = raw_shapes
370
+ self._done: dict[ShapeId, _RawShape] = {}
371
+ self._visiting: list[ShapeId] = []
372
+
373
+ def flatten(self, shape_id: ShapeId) -> _RawShape:
374
+ if (done := self._done.get(shape_id)) is not None:
375
+ return done
376
+ if shape_id in self._visiting:
377
+ cycle = [*self._visiting[self._visiting.index(shape_id) :], shape_id]
378
+ raise ModelError(
379
+ "Mixin cycle detected: " + " -> ".join(str(i) for i in cycle)
380
+ )
381
+ raw = self._raw[shape_id]
382
+ self._visiting.append(shape_id)
383
+ try:
384
+ mixins: list[_RawShape] = []
385
+ for mixin_id in raw.mixins:
386
+ if mixin_id not in self._raw:
387
+ raise ModelError(
388
+ f"Shape {shape_id} uses mixin {mixin_id}, which is not "
389
+ "defined in the model or prelude"
390
+ )
391
+ mixin = self.flatten(mixin_id)
392
+ if mixin.type is not raw.type:
393
+ raise ModelError(
394
+ f"Shape {shape_id} ({raw.type}) cannot use mixin "
395
+ f"{mixin_id} ({mixin.type})"
396
+ )
397
+ mixins.append(mixin)
398
+ flat = _merge(raw, mixins)
399
+ finally:
400
+ self._visiting.pop()
401
+ self._done[shape_id] = flat
402
+ return flat
403
+
404
+
405
+ def _merge(raw: _RawShape, mixins: list[_RawShape]) -> _RawShape:
406
+ traits: _Traits = {}
407
+ members: dict[str, _RawMember] = {}
408
+ for mixin in mixins:
409
+ traits.update(_inheritable_traits(mixin))
410
+ for name, member in mixin.members.items():
411
+ inherited = members.get(name)
412
+ merged = {**inherited.traits} if inherited else {}
413
+ merged.update(member.traits)
414
+ members[name] = _RawMember(member.target, merged)
415
+ traits.update(raw.traits)
416
+ for name, member in raw.members.items():
417
+ inherited = members.get(name)
418
+ merged = {**inherited.traits, **member.traits} if inherited else member.traits
419
+ members[name] = _RawMember(member.target, dict(merged))
420
+ for name, applied in raw.applied_member_traits.items():
421
+ member = members.get(name)
422
+ if member is None:
423
+ raise ModelError(
424
+ f"Cannot apply traits to {raw.id.with_member(name)}: "
425
+ f"member is not defined on {raw.id}"
426
+ )
427
+ _merge_applied(member.traits, applied)
428
+
429
+ flat = _RawShape(
430
+ id=raw.id,
431
+ type=raw.type,
432
+ traits=traits,
433
+ mixins=list(raw.mixins),
434
+ members=members,
435
+ version=raw.version,
436
+ )
437
+ for mixin in mixins:
438
+ if flat.version is None:
439
+ flat.version = mixin.version
440
+ if raw.version is not None:
441
+ flat.version = raw.version
442
+
443
+ for key in {k: None for m in [*mixins, raw] for k in m.refs}:
444
+ value = raw.refs.get(key)
445
+ for mixin in reversed(mixins):
446
+ if value is not None:
447
+ break
448
+ value = mixin.refs.get(key)
449
+ if value is not None:
450
+ flat.refs[key] = value
451
+ for key in {k: None for m in [*mixins, raw] for k in m.ref_lists}:
452
+ flat.ref_lists[key] = _union(
453
+ *(m.ref_lists.get(key, ()) for m in [*mixins, raw])
454
+ )
455
+ for key in {k: None for m in [*mixins, raw] for k in m.named_refs}:
456
+ named: dict[str, ShapeId] = {}
457
+ for m in [*mixins, raw]:
458
+ named.update(m.named_refs.get(key, {}))
459
+ flat.named_refs[key] = named
460
+ for m in [*mixins, raw]:
461
+ flat.rename.update(m.rename)
462
+ return flat
463
+
464
+
465
+ # --- Validation -------------------------------------------------------------------
466
+
467
+
468
+ def _check_required_members(raw: _RawShape) -> None:
469
+ required: tuple[str, ...] = ()
470
+ if raw.type is ShapeType.LIST:
471
+ required = ("member",)
472
+ elif raw.type is ShapeType.MAP:
473
+ required = ("key", "value")
474
+ for name in required:
475
+ if name not in raw.members:
476
+ raise ModelError(f'Shape {raw.id} ({raw.type}) is missing its "{name}"')
477
+
478
+
479
+ def _check_references(raw: _RawShape, shapes: Mapping[ShapeId, _RawShape]) -> None:
480
+ def missing(target: ShapeId) -> bool:
481
+ return target not in shapes
482
+
483
+ for name, member in raw.members.items():
484
+ if missing(member.target):
485
+ raise ModelError(
486
+ f"Member {raw.id.with_member(name)} targets {member.target}, "
487
+ "which is not defined in the model or prelude"
488
+ )
489
+ refs: list[ShapeId] = [
490
+ *raw.refs.values(),
491
+ *(ref for refs in raw.ref_lists.values() for ref in refs),
492
+ *(ref for named in raw.named_refs.values() for ref in named.values()),
493
+ *raw.rename,
494
+ ]
495
+ for ref in refs:
496
+ if missing(ref):
497
+ raise ModelError(
498
+ f"Shape {raw.id} references {ref}, which is not defined in the "
499
+ "model or prelude"
500
+ )
501
+
502
+
503
+ # --- Building -----------------------------------------------------------------------
504
+
505
+
506
+ def _freeze_traits(traits: _Traits) -> Mapping[ShapeId, Node]:
507
+ return MappingProxyType(dict(traits))
508
+
509
+
510
+ def _build(raw: _RawShape) -> Shape:
511
+ traits = _freeze_traits(raw.traits)
512
+ mixins = tuple(raw.mixins)
513
+ members = MappingProxyType(
514
+ {
515
+ name: MemberShape(
516
+ id=raw.id.with_member(name),
517
+ target=member.target,
518
+ traits=_freeze_traits(member.traits),
519
+ )
520
+ for name, member in raw.members.items()
521
+ }
522
+ )
523
+ cls = SHAPE_CLASSES[raw.type]
524
+ shape_id, shape_type = raw.id, raw.type
525
+ lists = {k: tuple(v) for k, v in raw.ref_lists.items()}
526
+ if cls is ServiceShape:
527
+ return ServiceShape(
528
+ id=shape_id,
529
+ type=shape_type,
530
+ traits=traits,
531
+ mixins=mixins,
532
+ version=raw.version,
533
+ operations=lists.get("operations", ()),
534
+ resources=lists.get("resources", ()),
535
+ errors=lists.get("errors", ()),
536
+ rename=MappingProxyType(dict(raw.rename)),
537
+ )
538
+ if cls is ResourceShape:
539
+ return ResourceShape(
540
+ id=shape_id,
541
+ type=shape_type,
542
+ traits=traits,
543
+ mixins=mixins,
544
+ identifiers=MappingProxyType(raw.named_refs.get("identifiers", {})),
545
+ properties=MappingProxyType(raw.named_refs.get("properties", {})),
546
+ create=raw.refs.get("create"),
547
+ put=raw.refs.get("put"),
548
+ read=raw.refs.get("read"),
549
+ update=raw.refs.get("update"),
550
+ delete=raw.refs.get("delete"),
551
+ list=raw.refs.get("list"),
552
+ operations=lists.get("operations", ()),
553
+ collection_operations=lists.get("collectionOperations", ()),
554
+ resources=lists.get("resources", ()),
555
+ )
556
+ if cls is OperationShape:
557
+ return OperationShape(
558
+ id=shape_id,
559
+ type=shape_type,
560
+ traits=traits,
561
+ mixins=mixins,
562
+ input=raw.refs.get("input", UNIT),
563
+ output=raw.refs.get("output", UNIT),
564
+ errors=lists.get("errors", ()),
565
+ )
566
+ # Simple shapes carry no members; aggregate shapes carry all of theirs.
567
+ return cls(
568
+ id=shape_id,
569
+ type=shape_type,
570
+ traits=traits,
571
+ mixins=mixins,
572
+ members=members if cls is not SimpleShape else MappingProxyType({}),
573
+ )
@@ -0,0 +1,134 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The loaded model and its navigation API."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Iterator, Mapping
8
+ from types import MappingProxyType
9
+ from typing import overload
10
+
11
+ from ..exceptions import ModelError, ShapeNotFoundError
12
+ from ._shape_id import PRELUDE_NAMESPACE, ShapeId, as_shape_id
13
+ from ._shapes import MemberShape, Node, ServiceShape, Shape, ShapeType
14
+
15
+
16
+ class Model:
17
+ """A loaded, mixin-flattened Smithy model.
18
+
19
+ ``shapes`` holds top-level shapes (never members) in input order followed
20
+ by any embedded prelude shapes the input did not define. Members are
21
+ reachable via their container or ``get_shape`` with a member ID.
22
+ """
23
+
24
+ __slots__ = ("_metadata", "_shapes", "_smithy_version")
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ smithy_version: str,
30
+ metadata: Mapping[str, Node],
31
+ shapes: Mapping[ShapeId, Shape],
32
+ ) -> None:
33
+ self._smithy_version = smithy_version
34
+ self._metadata: Mapping[str, Node] = MappingProxyType(dict(metadata))
35
+ self._shapes: Mapping[ShapeId, Shape] = MappingProxyType(dict(shapes))
36
+
37
+ @property
38
+ def smithy_version(self) -> str:
39
+ """The ``smithy`` version string from the JSON AST."""
40
+ return self._smithy_version
41
+
42
+ @property
43
+ def metadata(self) -> Mapping[str, Node]:
44
+ """Model metadata in input key order."""
45
+ return self._metadata
46
+
47
+ @property
48
+ def shapes(self) -> Mapping[ShapeId, Shape]:
49
+ """All top-level shapes, including prelude shapes."""
50
+ return self._shapes
51
+
52
+ def __contains__(self, shape_id: object) -> bool:
53
+ if isinstance(shape_id, str):
54
+ try:
55
+ shape_id = as_shape_id(shape_id)
56
+ except ModelError:
57
+ return False
58
+ return isinstance(shape_id, ShapeId) and self.find_shape(shape_id) is not None
59
+
60
+ def find_shape(self, shape_id: ShapeId | str) -> Shape | None:
61
+ """Return the shape (or member) with this ID, or None."""
62
+ shape_id = as_shape_id(shape_id)
63
+ shape = self._shapes.get(shape_id.root)
64
+ if shape is None or shape_id.member is None:
65
+ return shape
66
+ return shape.members.get(shape_id.member)
67
+
68
+ def get_shape(self, shape_id: ShapeId | str) -> Shape:
69
+ """Return the shape (or member) with this ID or raise ShapeNotFoundError."""
70
+ shape = self.find_shape(shape_id)
71
+ if shape is None:
72
+ raise ShapeNotFoundError(f"Shape {shape_id} is not defined in the model")
73
+ return shape
74
+
75
+ def expect_shape[T: Shape](self, shape_id: ShapeId | str, kind: type[T]) -> T:
76
+ """Return the shape with this ID, requiring it to be a ``kind``."""
77
+ shape = self.get_shape(shape_id)
78
+ if not isinstance(shape, kind):
79
+ raise ModelError(
80
+ f"Shape {shape_id} is a {type(shape).__name__}; "
81
+ f"expected {kind.__name__}"
82
+ )
83
+ return shape
84
+
85
+ def get_target(self, member: MemberShape) -> Shape:
86
+ """Return the shape a member targets."""
87
+ return self.get_shape(member.target)
88
+
89
+ def is_prelude(self, shape: Shape | ShapeId) -> bool:
90
+ """Whether a shape belongs to the Smithy prelude (never generated)."""
91
+ shape_id = shape if isinstance(shape, ShapeId) else shape.id
92
+ return shape_id.namespace == PRELUDE_NAMESPACE
93
+
94
+ @overload
95
+ def iter_shapes(
96
+ self, kind: None = None, *, include_prelude: bool = False
97
+ ) -> Iterator[Shape]: ...
98
+
99
+ @overload
100
+ def iter_shapes[T: Shape](
101
+ self, kind: type[T], *, include_prelude: bool = False
102
+ ) -> Iterator[T]: ...
103
+
104
+ @overload
105
+ def iter_shapes(
106
+ self, kind: ShapeType, *, include_prelude: bool = False
107
+ ) -> Iterator[Shape]: ...
108
+
109
+ def iter_shapes(
110
+ self,
111
+ kind: type[Shape] | ShapeType | None = None,
112
+ *,
113
+ include_prelude: bool = False,
114
+ ) -> Iterator[Shape]:
115
+ """Iterate top-level shapes in model order, optionally filtered.
116
+
117
+ ``kind`` may be a shape class or a ShapeType. Prelude shapes are
118
+ skipped unless ``include_prelude`` is set. Mixin shapes are included;
119
+ filter with ``Shape.is_mixin``.
120
+ """
121
+ for shape in self._shapes.values():
122
+ if not include_prelude and self.is_prelude(shape):
123
+ continue
124
+ if kind is None:
125
+ yield shape
126
+ elif isinstance(kind, ShapeType):
127
+ if shape.type is kind:
128
+ yield shape
129
+ elif isinstance(shape, kind):
130
+ yield shape
131
+
132
+ def services(self) -> tuple[ServiceShape, ...]:
133
+ """All non-mixin service shapes in model order."""
134
+ return tuple(s for s in self.iter_shapes(ServiceShape) if not s.is_mixin)