usecaseapi 1.0.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.
usecaseapi/docs.py ADDED
@@ -0,0 +1,88 @@
1
+ """Markdown and Mermaid renderers for registered usecase contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .api import UseCaseAPI
8
+ from .contracts import UseCaseRef
9
+
10
+
11
+ def render_markdown(api: UseCaseAPI[Any]) -> str:
12
+ """Render registered usecases as human-readable Markdown."""
13
+ bindings = {binding.ref.key: binding for binding in api.bindings}
14
+ lines: list[str] = ["# UseCaseAPI Contracts", ""]
15
+ for ref in sorted(api.contracts, key=lambda item: item.key):
16
+ contract = ref.contract
17
+ lines.extend(
18
+ [
19
+ f"## {contract.name} v{contract.version}",
20
+ "",
21
+ f"Key: `{contract.key}`",
22
+ f"Protocol: `{ref.protocol.__module__}.{ref.protocol.__qualname__}`",
23
+ f"Input: `{contract.input.__module__}.{contract.input.__qualname__}`",
24
+ f"Output: `{contract.output.__module__}.{contract.output.__qualname__}`",
25
+ f"Stable: `{contract.stable}`",
26
+ f"Deprecated: `{contract.deprecated}`",
27
+ ]
28
+ )
29
+ if contract.superseded_by:
30
+ lines.append(f"Superseded by: `{contract.superseded_by}`")
31
+ if contract.description:
32
+ lines.extend(["", contract.description])
33
+ lines.extend(["", "### Raises", ""])
34
+ if contract.raises:
35
+ for error_type in contract.raises:
36
+ lines.append(
37
+ f"- `{error_type.__module__}.{error_type.__qualname__}` "
38
+ f"(`{getattr(error_type, 'code', '')}`)"
39
+ )
40
+ else:
41
+ lines.append("- None declared")
42
+ if contract.known_errors:
43
+ lines.extend(["", "### Known leaf errors", ""])
44
+ for error_type in contract.known_errors:
45
+ lines.append(
46
+ f"- `{error_type.__module__}.{error_type.__qualname__}` "
47
+ f"(`{getattr(error_type, 'code', '')}`)"
48
+ )
49
+ lines.extend(["", "### Declared uses", ""])
50
+ binding = bindings.get(ref.key)
51
+ if binding is not None and binding.uses:
52
+ for use_key in sorted(binding.uses):
53
+ lines.append(f"- `{use_key}`")
54
+ else:
55
+ lines.append("- None")
56
+ lines.extend(["", "### Input fields", ""])
57
+ _append_model_fields(lines, ref, kind="input")
58
+ lines.extend(["", "### Output fields", ""])
59
+ _append_model_fields(lines, ref, kind="output")
60
+ lines.append("")
61
+ return "\n".join(lines).rstrip() + "\n"
62
+
63
+
64
+ def render_mermaid(api: UseCaseAPI[Any]) -> str:
65
+ """Render declared usecase dependencies as a Mermaid graph."""
66
+ lines = ["flowchart TD"]
67
+ for ref in sorted(api.contracts, key=lambda item: item.key):
68
+ node = _node_id(ref.key)
69
+ lines.append(f' {node}["{ref.key}"]')
70
+ for binding in sorted(api.bindings, key=lambda item: item.ref.key):
71
+ source = _node_id(binding.ref.key)
72
+ for use_key in sorted(binding.uses):
73
+ lines.append(f" {source} --> {_node_id(use_key)}")
74
+ return "\n".join(lines) + "\n"
75
+
76
+
77
+ def _append_model_fields(lines: list[str], ref: UseCaseRef[Any, Any], *, kind: str) -> None:
78
+ model_type = ref.contract.input if kind == "input" else ref.contract.output
79
+ if not model_type.model_fields:
80
+ lines.append("- No fields")
81
+ return
82
+ for name, field in model_type.model_fields.items():
83
+ annotation = field.annotation
84
+ lines.append(f"- `{name}`: `{annotation!r}`")
85
+
86
+
87
+ def _node_id(key: str) -> str:
88
+ return "uc_" + "".join(character if character.isalnum() else "_" for character in key)
usecaseapi/errors.py ADDED
@@ -0,0 +1,75 @@
1
+ """Framework and domain error classes used by UseCaseAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from types import MappingProxyType
7
+ from typing import Any, ClassVar
8
+
9
+
10
+ class UseCaseAPIError(Exception):
11
+ """Base class for framework-level errors raised by UseCaseAPI."""
12
+
13
+
14
+ class ContractDefinitionError(UseCaseAPIError):
15
+ """Raised when a contract definition is invalid."""
16
+
17
+
18
+ class DuplicateUseCaseError(UseCaseAPIError):
19
+ """Raised when the same usecase key is registered twice in an invalid way."""
20
+
21
+
22
+ class MissingBindingError(UseCaseAPIError):
23
+ """Raised when a usecase is called without a registered implementation binding."""
24
+
25
+
26
+ class InvalidHandlerError(UseCaseAPIError):
27
+ """Raised when a bound handler does not match its contract."""
28
+
29
+
30
+ class UndeclaredUseCaseDependencyError(UseCaseAPIError):
31
+ """Raised when a usecase calls another usecase that was not declared in ``uses``."""
32
+
33
+ def __init__(self, *, caller_key: str, callee_key: str) -> None:
34
+ """Create an undeclared dependency error for a caller and callee pair."""
35
+ self.caller_key = caller_key
36
+ self.callee_key = callee_key
37
+ super().__init__(f"{caller_key!r} attempted to call undeclared dependency {callee_key!r}")
38
+
39
+
40
+ class UndeclaredUseCaseError(UseCaseAPIError):
41
+ """Raised when a handler leaks a domain error outside its declared raises contract."""
42
+
43
+ def __init__(self, *, usecase_key: str, error: UseCaseError) -> None:
44
+ """Create an undeclared domain error wrapper."""
45
+ self.usecase_key = usecase_key
46
+ self.error = error
47
+ super().__init__(
48
+ f"{usecase_key!r} raised undeclared usecase error "
49
+ f"{type(error).__module__}.{type(error).__qualname__}"
50
+ )
51
+
52
+
53
+ class UseCaseError(Exception):
54
+ """Base class for domain errors that are part of a usecase contract.
55
+
56
+ UseCaseAPI deliberately models domain failures as real Python exceptions.
57
+ This preserves normal exception hierarchy behavior, stack traces, ``except`` /
58
+ ``except*`` handling, and ExceptionGroup semantics.
59
+ """
60
+
61
+ code: ClassVar[str] = "usecase.error"
62
+
63
+ @property
64
+ def details(self) -> Mapping[str, Any]:
65
+ """Public attributes attached to this domain exception."""
66
+ return MappingProxyType(dict(self.__dict__))
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ """Return a JSON-friendly representation useful for docs, logs, or catalogs."""
70
+ return {
71
+ "type": f"{type(self).__module__}.{type(self).__qualname__}",
72
+ "code": self.code,
73
+ "message": str(self),
74
+ "details": dict(self.details),
75
+ }
usecaseapi/model.py ADDED
@@ -0,0 +1,15 @@
1
+ """Pydantic model base class exported by UseCaseAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class Model(BaseModel):
9
+ """Base model for UseCaseAPI input and output objects.
10
+
11
+ The default configuration is intentionally strict and immutable-ish:
12
+ unknown fields are rejected and model instances are frozen.
13
+ """
14
+
15
+ model_config = ConfigDict(extra="forbid", frozen=True)
usecaseapi/py.typed ADDED
File without changes
usecaseapi/scaffold.py ADDED
@@ -0,0 +1,298 @@
1
+ """Scaffold versioned usecase contracts, implementations, and tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ _USECASE_NAME = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
11
+ _VERSION_FILE = re.compile(r"^v([1-9][0-9]*)\.py$")
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ScaffoldResult:
16
+ """Files planned or created by the scaffold command."""
17
+
18
+ files: tuple[Path, ...]
19
+ skipped: tuple[Path, ...] = ()
20
+ version: int = 1
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class ScaffoldOptions:
25
+ """Options for creating a versioned usecase skeleton.
26
+
27
+ ``version=None`` means "create the next available major version". If
28
+ ``from_version`` is set and ``version`` is omitted, the target version is
29
+ ``from_version + 1`` and the previous contract is copied forward with the
30
+ contract metadata updated.
31
+ """
32
+
33
+ name: str
34
+ version: int | None = None
35
+ from_version: int | None = None
36
+ contracts_root: Path = Path("app/contracts")
37
+ implementations_root: Path = Path("app/usecases")
38
+ tests_root: Path = Path("tests")
39
+ contracts_package: str = "app.contracts"
40
+ implementations_package: str = "app.usecases"
41
+ force: bool = False
42
+ dry_run: bool = False
43
+ create_implementation: bool = True
44
+ create_tests: bool = True
45
+ create_init: bool = True
46
+
47
+
48
+ def scaffold_usecase(options: ScaffoldOptions) -> ScaffoldResult:
49
+ """Create contract, implementation, and optional test files for one usecase."""
50
+ _validate_options(options)
51
+ parts = options.name.split(".")
52
+ domain_parts = parts[:-1]
53
+ usecase_name = parts[-1]
54
+ version = _resolve_version(options, domain_parts=domain_parts, usecase_name=usecase_name)
55
+ if options.from_version is not None and options.from_version >= version:
56
+ raise ValueError("from_version must be lower than target version")
57
+
58
+ class_name = _camel(usecase_name)
59
+ constant_name = usecase_name.upper()
60
+
61
+ contract_dir = options.contracts_root / Path(*domain_parts) / usecase_name
62
+ contract_file = contract_dir / f"v{version}.py"
63
+ implementation_file = options.implementations_root / Path(*domain_parts) / f"{usecase_name}.py"
64
+ test_file = options.tests_root / f"test_{'_'.join(parts)}_v{version}.py"
65
+
66
+ contract_module = ".".join(
67
+ [options.contracts_package, *domain_parts, usecase_name, f"v{version}"]
68
+ )
69
+ implementation_module = ".".join([options.implementations_package, *domain_parts, usecase_name])
70
+
71
+ if options.from_version is None:
72
+ contract_content = _contract_template(
73
+ name=options.name,
74
+ version=version,
75
+ class_name=class_name,
76
+ constant_name=constant_name,
77
+ )
78
+ else:
79
+ previous_contract_file = contract_dir / f"v{options.from_version}.py"
80
+ contract_content = _copy_contract_template(
81
+ previous_contract_file,
82
+ from_version=options.from_version,
83
+ target_version=version,
84
+ )
85
+
86
+ created: list[Path] = []
87
+ skipped: list[Path] = []
88
+
89
+ _write_file(contract_file, contract_content, force=options.force, dry_run=options.dry_run)
90
+ created.append(contract_file)
91
+
92
+ if options.create_implementation:
93
+ implementation_content = _implementation_template(
94
+ contract_module=contract_module,
95
+ class_name=class_name,
96
+ )
97
+ if implementation_file.exists() and not options.force:
98
+ skipped.append(implementation_file)
99
+ else:
100
+ _write_file(
101
+ implementation_file,
102
+ implementation_content,
103
+ force=options.force,
104
+ dry_run=options.dry_run,
105
+ )
106
+ created.append(implementation_file)
107
+
108
+ if options.create_tests:
109
+ _write_file(
110
+ test_file,
111
+ _test_template(
112
+ contract_module=contract_module,
113
+ implementation_module=implementation_module,
114
+ class_name=class_name,
115
+ constant_name=constant_name,
116
+ name=options.name,
117
+ version=version,
118
+ ),
119
+ force=options.force,
120
+ dry_run=options.dry_run,
121
+ )
122
+ created.append(test_file)
123
+
124
+ if options.create_init and not options.dry_run:
125
+ _ensure_init_files(contract_file.parent, stop_at=options.contracts_root.parent)
126
+ if options.create_implementation:
127
+ _ensure_init_files(
128
+ implementation_file.parent,
129
+ stop_at=options.implementations_root.parent,
130
+ )
131
+
132
+ return ScaffoldResult(files=tuple(created), skipped=tuple(skipped), version=version)
133
+
134
+
135
+ def _validate_options(options: ScaffoldOptions) -> None:
136
+ if options.version is not None and options.version < 1:
137
+ raise ValueError("version must be >= 1")
138
+ if options.from_version is not None and options.from_version < 1:
139
+ raise ValueError("from_version must be >= 1")
140
+ if _USECASE_NAME.fullmatch(options.name) is None:
141
+ raise ValueError("usecase name must look like 'domain.use_case'")
142
+
143
+
144
+ def _resolve_version(
145
+ options: ScaffoldOptions,
146
+ *,
147
+ domain_parts: list[str],
148
+ usecase_name: str,
149
+ ) -> int:
150
+ if options.version is not None:
151
+ return options.version
152
+ if options.from_version is not None:
153
+ return options.from_version + 1
154
+
155
+ contract_dir = options.contracts_root / Path(*domain_parts) / usecase_name
156
+ versions: list[int] = []
157
+ if contract_dir.exists():
158
+ for child in contract_dir.iterdir():
159
+ match = _VERSION_FILE.fullmatch(child.name)
160
+ if match is not None:
161
+ versions.append(int(match.group(1)))
162
+ if not versions:
163
+ return 1
164
+ return max(versions) + 1
165
+
166
+
167
+ def _write_file(path: Path, content: str, *, force: bool, dry_run: bool) -> None:
168
+ if path.exists() and not force:
169
+ raise FileExistsError(f"{path} already exists; pass force=True to overwrite")
170
+ if dry_run:
171
+ return
172
+ path.parent.mkdir(parents=True, exist_ok=True)
173
+ path.write_text(content)
174
+
175
+
176
+ def _copy_contract_template(
177
+ previous_contract_file: Path,
178
+ *,
179
+ from_version: int,
180
+ target_version: int,
181
+ ) -> str:
182
+ if not previous_contract_file.exists():
183
+ raise FileNotFoundError(f"previous contract file does not exist: {previous_contract_file}")
184
+
185
+ content = previous_contract_file.read_text()
186
+ content, count = re.subn(
187
+ rf"(\bversion\s*=\s*){from_version}\b",
188
+ rf"\g<1>{target_version}",
189
+ content,
190
+ )
191
+ if count == 0:
192
+ raise ValueError(
193
+ f"could not find version={from_version} in {previous_contract_file}; "
194
+ "pass --version with a fresh scaffold instead"
195
+ )
196
+ content = content.replace(f" v{from_version}", f" v{target_version}")
197
+ content = content.replace(f"@v{from_version}", f"@v{target_version}")
198
+ return content
199
+
200
+
201
+ def _ensure_init_files(directory: Path, *, stop_at: Path) -> None:
202
+ current = directory
203
+ while True:
204
+ init_file = current / "__init__.py"
205
+ if not init_file.exists():
206
+ init_file.write_text("")
207
+ if current == stop_at or current.parent == current:
208
+ break
209
+ current = current.parent
210
+
211
+
212
+ def _camel(value: str) -> str:
213
+ return "".join(part.capitalize() for part in value.split("_"))
214
+
215
+
216
+ def _contract_template(*, name: str, version: int, class_name: str, constant_name: str) -> str:
217
+ error_name = f"{class_name}Error"
218
+ return f'''from __future__ import annotations
219
+
220
+ from typing import ClassVar, Protocol
221
+
222
+ from usecaseapi import Contract, Model, UseCase, UseCaseError, UseCaseRef, define_usecase
223
+
224
+
225
+ class Input(Model):
226
+ """Input for {name} v{version}."""
227
+
228
+
229
+ class Output(Model):
230
+ """Output for {name} v{version}."""
231
+
232
+
233
+ class {error_name}(UseCaseError):
234
+ """Base domain error for {name} v{version}."""
235
+
236
+ code: ClassVar[str] = "{name}"
237
+
238
+
239
+ class {class_name}(UseCase[Input, Output], Protocol):
240
+ """Contract Protocol for {name} v{version}."""
241
+
242
+ async def __call__(self, input: Input, /) -> Output:
243
+ ...
244
+
245
+
246
+ {constant_name}: UseCaseRef[Input, Output] = define_usecase(
247
+ {class_name},
248
+ Contract(
249
+ name="{name}",
250
+ version={version},
251
+ input=Input,
252
+ output=Output,
253
+ raises=({error_name},),
254
+ known_errors=(),
255
+ ),
256
+ )
257
+ '''
258
+
259
+
260
+ def _implementation_template(*, contract_module: str, class_name: str) -> str:
261
+ return f"""from __future__ import annotations
262
+
263
+ from {contract_module} import Input, Output, {class_name}
264
+
265
+
266
+ class {class_name}Impl:
267
+ async def __call__(self, input: Input, /) -> Output:
268
+ raise NotImplementedError("Implement {class_name}Impl.__call__")
269
+
270
+
271
+ _impl: {class_name} = {class_name}Impl()
272
+ """
273
+
274
+
275
+ def _test_template(
276
+ *,
277
+ contract_module: str,
278
+ implementation_module: str,
279
+ class_name: str,
280
+ constant_name: str,
281
+ name: str,
282
+ version: int,
283
+ ) -> str:
284
+ return f'''from __future__ import annotations
285
+
286
+ from {contract_module} import {class_name}, {constant_name}
287
+ from {implementation_module} import {class_name}Impl
288
+
289
+
290
+ def test_{constant_name.lower()}_contract_metadata() -> None:
291
+ assert {constant_name}.contract.name == "{name}"
292
+ assert {constant_name}.contract.version == {version}
293
+
294
+
295
+ def test_{constant_name.lower()}_implementation_matches_protocol() -> None:
296
+ _impl: {class_name} = {class_name}Impl()
297
+ assert _impl is not None
298
+ '''
usecaseapi/snapshot.py ADDED
@@ -0,0 +1,173 @@
1
+ """Snapshot serialization and diffing for UseCaseAPI contract catalogs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, cast
11
+
12
+ from .api import UseCaseAPI
13
+ from .contracts import UseCaseRef
14
+ from .errors import UseCaseError
15
+
16
+
17
+ def exception_to_dict(error_type: type[UseCaseError]) -> dict[str, Any]:
18
+ """Serialize exception class metadata for catalogs and snapshots."""
19
+ parents: list[str] = []
20
+ for parent in error_type.__mro__[1:]:
21
+ if parent is Exception or parent is BaseException or parent is object:
22
+ break
23
+ parents.append(f"{parent.__module__}.{parent.__qualname__}")
24
+ return {
25
+ "type": f"{error_type.__module__}.{error_type.__qualname__}",
26
+ "code": getattr(error_type, "code", ""),
27
+ "parents": parents,
28
+ }
29
+
30
+
31
+ def ref_to_dict(ref: UseCaseRef[Any, Any], *, uses: tuple[str, ...] = ()) -> dict[str, Any]:
32
+ """Serialize a usecase reference and contract metadata."""
33
+ contract = ref.contract
34
+ return {
35
+ "key": contract.key,
36
+ "name": contract.name,
37
+ "version": contract.version,
38
+ "protocol": f"{ref.protocol.__module__}.{ref.protocol.__qualname__}",
39
+ "input": {
40
+ "type": f"{contract.input.__module__}.{contract.input.__qualname__}",
41
+ "schema": contract.input.model_json_schema(),
42
+ },
43
+ "output": {
44
+ "type": f"{contract.output.__module__}.{contract.output.__qualname__}",
45
+ "schema": contract.output.model_json_schema(),
46
+ },
47
+ "raises": [exception_to_dict(error_type) for error_type in contract.raises],
48
+ "known_errors": [exception_to_dict(error_type) for error_type in contract.known_errors],
49
+ "stable": contract.stable,
50
+ "deprecated": contract.deprecated,
51
+ "superseded_by": contract.superseded_by,
52
+ "description": contract.description,
53
+ "tags": list(contract.tags),
54
+ "uses": list(uses),
55
+ }
56
+
57
+
58
+ def snapshot_from_api(api: UseCaseAPI[Any]) -> dict[str, Any]:
59
+ """Build a JSON-serializable snapshot from a UseCaseAPI instance."""
60
+ uses_by_key = {binding.ref.key: tuple(sorted(binding.uses)) for binding in api.bindings}
61
+ return {
62
+ "schema_version": 1,
63
+ "usecases": [
64
+ ref_to_dict(ref, uses=uses_by_key.get(ref.key, ()))
65
+ for ref in sorted(api.contracts, key=lambda item: item.key)
66
+ ],
67
+ }
68
+
69
+
70
+ def write_snapshot(api: UseCaseAPI[Any], path: str | Path) -> None:
71
+ """Write a stable JSON snapshot to disk."""
72
+ payload = snapshot_from_api(api)
73
+ Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
74
+
75
+
76
+ def load_snapshot(path: str | Path) -> dict[str, Any]:
77
+ """Load a snapshot from disk."""
78
+ payload = json.loads(Path(path).read_text())
79
+ if not isinstance(payload, dict):
80
+ raise ValueError("snapshot must be a JSON object")
81
+ return cast(dict[str, Any], payload)
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class ContractDiff:
86
+ """Result of comparing two snapshots."""
87
+
88
+ breaking: tuple[str, ...]
89
+ warnings: tuple[str, ...]
90
+ additions: tuple[str, ...]
91
+
92
+ @property
93
+ def has_breaking_changes(self) -> bool:
94
+ """Whether the diff contains at least one breaking change."""
95
+ return bool(self.breaking)
96
+
97
+ def to_dict(self) -> dict[str, list[str]]:
98
+ """Return a JSON-friendly diff payload."""
99
+ return {
100
+ "breaking": list(self.breaking),
101
+ "warnings": list(self.warnings),
102
+ "additions": list(self.additions),
103
+ }
104
+
105
+
106
+ def diff_snapshots(old: Mapping[str, Any], new: Mapping[str, Any]) -> ContractDiff:
107
+ """Diff snapshots with conservative breaking-change detection."""
108
+ old_cases = _index_usecases(old)
109
+ new_cases = _index_usecases(new)
110
+ breaking: list[str] = []
111
+ warnings: list[str] = []
112
+ additions: list[str] = []
113
+
114
+ for key in sorted(set(old_cases) - set(new_cases)):
115
+ breaking.append(f"removed usecase {key}")
116
+ for key in sorted(set(new_cases) - set(old_cases)):
117
+ additions.append(f"added usecase {key}")
118
+
119
+ for key in sorted(set(old_cases) & set(new_cases)):
120
+ old_case = old_cases[key]
121
+ new_case = new_cases[key]
122
+ if old_case.get("input") != new_case.get("input"):
123
+ breaking.append(f"changed input schema for {key}")
124
+ if old_case.get("output") != new_case.get("output"):
125
+ breaking.append(f"changed output schema for {key}")
126
+ old_raises = _error_codes(old_case.get("raises"))
127
+ new_raises = _error_codes(new_case.get("raises"))
128
+ removed_raises = sorted(old_raises - new_raises)
129
+ if removed_raises:
130
+ breaking.append(f"removed declared errors for {key}: {', '.join(removed_raises)}")
131
+ old_uses = set(_string_list(old_case.get("uses")))
132
+ new_uses = set(_string_list(new_case.get("uses")))
133
+ removed_uses = sorted(old_uses - new_uses)
134
+ if removed_uses:
135
+ warnings.append(f"removed declared uses for {key}: {', '.join(removed_uses)}")
136
+ if old_case.get("deprecated") is False and new_case.get("deprecated") is True:
137
+ warnings.append(f"deprecated usecase {key}")
138
+ return ContractDiff(
139
+ breaking=tuple(breaking),
140
+ warnings=tuple(warnings),
141
+ additions=tuple(additions),
142
+ )
143
+
144
+
145
+ def _index_usecases(snapshot: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]:
146
+ usecases = snapshot.get("usecases", [])
147
+ if not isinstance(usecases, list):
148
+ raise ValueError("snapshot.usecases must be a list")
149
+ indexed: dict[str, Mapping[str, Any]] = {}
150
+ for item in usecases:
151
+ if not isinstance(item, dict):
152
+ raise ValueError("snapshot usecase must be an object")
153
+ key = item.get("key")
154
+ if not isinstance(key, str):
155
+ raise ValueError("snapshot usecase key must be a string")
156
+ indexed[key] = item
157
+ return indexed
158
+
159
+
160
+ def _error_codes(value: object) -> set[str]:
161
+ if not isinstance(value, list):
162
+ return set()
163
+ codes: set[str] = set()
164
+ for item in value:
165
+ if isinstance(item, dict) and isinstance(item.get("code"), str):
166
+ codes.add(cast(str, item["code"]))
167
+ return codes
168
+
169
+
170
+ def _string_list(value: object) -> list[str]:
171
+ if not isinstance(value, list):
172
+ return []
173
+ return [item for item in value if isinstance(item, str)]