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/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """Public package exports for UseCaseAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .api import Binding, Caller, CallRecord, UseCaseAPI
6
+ from .contracts import Contract, UseCase, UseCaseRef, define_usecase
7
+ from .docs import render_markdown, render_mermaid
8
+ from .errors import (
9
+ ContractDefinitionError,
10
+ DuplicateUseCaseError,
11
+ InvalidHandlerError,
12
+ MissingBindingError,
13
+ UndeclaredUseCaseDependencyError,
14
+ UndeclaredUseCaseError,
15
+ UseCaseAPIError,
16
+ UseCaseError,
17
+ )
18
+ from .model import Model
19
+ from .scaffold import ScaffoldOptions, ScaffoldResult, scaffold_usecase
20
+ from .snapshot import ContractDiff, diff_snapshots, load_snapshot, snapshot_from_api, write_snapshot
21
+
22
+ __all__ = [
23
+ "Binding",
24
+ "CallRecord",
25
+ "Caller",
26
+ "Contract",
27
+ "ContractDefinitionError",
28
+ "ContractDiff",
29
+ "DuplicateUseCaseError",
30
+ "InvalidHandlerError",
31
+ "MissingBindingError",
32
+ "Model",
33
+ "ScaffoldOptions",
34
+ "ScaffoldResult",
35
+ "UndeclaredUseCaseDependencyError",
36
+ "UndeclaredUseCaseError",
37
+ "UseCase",
38
+ "UseCaseAPI",
39
+ "UseCaseAPIError",
40
+ "UseCaseError",
41
+ "UseCaseRef",
42
+ "define_usecase",
43
+ "diff_snapshots",
44
+ "load_snapshot",
45
+ "render_markdown",
46
+ "render_mermaid",
47
+ "scaffold_usecase",
48
+ "snapshot_from_api",
49
+ "write_snapshot",
50
+ ]
usecaseapi/api.py ADDED
@@ -0,0 +1,289 @@
1
+ """Runtime registry and caller implementation for UseCaseAPI contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+
8
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, TypeVar, cast, get_type_hints
11
+
12
+ from .contracts import InputT, OutputT, UseCase, UseCaseRef
13
+ from .errors import (
14
+ DuplicateUseCaseError,
15
+ InvalidHandlerError,
16
+ MissingBindingError,
17
+ UndeclaredUseCaseDependencyError,
18
+ UndeclaredUseCaseError,
19
+ UseCaseError,
20
+ )
21
+
22
+ ContextT = TypeVar("ContextT")
23
+
24
+ HandlerFactory = Callable[["Caller[Any]"], UseCase[Any, Any]]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class Binding[ContextT]:
29
+ """A runtime connection between a contract token and an implementation factory."""
30
+
31
+ ref: UseCaseRef[Any, Any]
32
+ factory: Callable[[Caller[ContextT]], UseCase[Any, Any]]
33
+ uses: frozenset[str] = field(default_factory=frozenset)
34
+ description: str | None = None
35
+ tags: tuple[str, ...] = field(default_factory=tuple)
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class CallRecord:
40
+ """One edge observed during runtime calls."""
41
+
42
+ caller_key: str | None
43
+ callee_key: str
44
+
45
+
46
+ class UseCaseAPI[ContextT]:
47
+ """Registry and contract runtime for same-process usecase calls.
48
+
49
+ This class is not a DI container. It keeps contract bindings and creates
50
+ ``Caller`` objects for contexts that are supplied by the host application.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ strict_dependencies: bool = True,
57
+ strict_errors: bool = True,
58
+ validate_handlers: bool = True,
59
+ ) -> None:
60
+ """Create a registry with dependency, error, and handler validation options."""
61
+ self.strict_dependencies = strict_dependencies
62
+ self.strict_errors = strict_errors
63
+ self.validate_handlers = validate_handlers
64
+ self._contracts: dict[str, UseCaseRef[Any, Any]] = {}
65
+ self._bindings: dict[str, Binding[ContextT]] = {}
66
+ self._validated_handler_types: set[tuple[str, type[Any]]] = set()
67
+
68
+ def register(self, *refs: UseCaseRef[Any, Any]) -> UseCaseAPI[ContextT]:
69
+ """Register contracts without binding implementations yet."""
70
+ for ref in refs:
71
+ existing = self._contracts.get(ref.key)
72
+ if existing is not None and existing is not ref:
73
+ raise DuplicateUseCaseError(f"duplicate usecase contract {ref.key!r}")
74
+ self._contracts[ref.key] = ref
75
+ return self
76
+
77
+ def bind(
78
+ self,
79
+ ref: UseCaseRef[InputT, OutputT],
80
+ factory: Callable[[Caller[ContextT]], UseCase[InputT, OutputT]],
81
+ *,
82
+ uses: Iterable[UseCaseRef[Any, Any]] = (),
83
+ replace: bool = False,
84
+ description: str | None = None,
85
+ tags: Sequence[str] = (),
86
+ ) -> UseCaseAPI[ContextT]:
87
+ """Bind a contract token to an implementation factory.
88
+
89
+ ``factory`` receives the current ``Caller`` and returns a callable object
90
+ that structurally conforms to the contract Protocol.
91
+ """
92
+ self.register(ref)
93
+ if ref.key in self._bindings and not replace:
94
+ raise DuplicateUseCaseError(f"duplicate binding for {ref.key!r}")
95
+ use_keys = frozenset(use_ref.key for use_ref in uses)
96
+ for use_ref in uses:
97
+ self.register(use_ref)
98
+ self._bindings[ref.key] = Binding(
99
+ ref=ref,
100
+ factory=cast(Callable[[Caller[ContextT]], UseCase[Any, Any]], factory),
101
+ uses=use_keys,
102
+ description=description,
103
+ tags=tuple(tags),
104
+ )
105
+ return self
106
+
107
+ def caller(self, context: ContextT) -> Caller[ContextT]:
108
+ """Create a caller for a host-application context."""
109
+ return Caller(api=self, context=context, current_key=None, records=[])
110
+
111
+ def validate(self, *, require_handlers: bool = True) -> None:
112
+ """Validate registry-level consistency.
113
+
114
+ Handler signatures are validated lazily when a factory produces a handler,
115
+ because UseCaseAPI deliberately does not own construction lifecycles.
116
+ """
117
+ if require_handlers:
118
+ missing = sorted(set(self._contracts) - set(self._bindings))
119
+ if missing:
120
+ raise MissingBindingError("missing usecase bindings: " + ", ".join(missing))
121
+ for binding in self._bindings.values():
122
+ unknown_uses = sorted(binding.uses - set(self._contracts))
123
+ if unknown_uses:
124
+ raise MissingBindingError(
125
+ f"binding {binding.ref.key!r} declares unknown uses: " + ", ".join(unknown_uses)
126
+ )
127
+
128
+ @property
129
+ def contracts(self) -> tuple[UseCaseRef[Any, Any], ...]:
130
+ """Registered contract references in insertion order."""
131
+ return tuple(self._contracts.values())
132
+
133
+ @property
134
+ def bindings(self) -> tuple[Binding[ContextT], ...]:
135
+ """Registered implementation bindings in insertion order."""
136
+ return tuple(self._bindings.values())
137
+
138
+ def _get_binding(self, ref: UseCaseRef[Any, Any]) -> Binding[ContextT]:
139
+ return self._get_binding_by_key(ref.key)
140
+
141
+ def _get_binding_by_key(self, key: str) -> Binding[ContextT]:
142
+ binding = self._bindings.get(key)
143
+ if binding is None:
144
+ raise MissingBindingError(f"missing binding for {key!r}")
145
+ return binding
146
+
147
+ def _validate_handler(self, ref: UseCaseRef[Any, Any], handler: UseCase[Any, Any]) -> None:
148
+ handler_type = type(handler)
149
+ cache_key = (ref.key, handler_type)
150
+ if cache_key in self._validated_handler_types:
151
+ return
152
+ target = _callable_target(handler)
153
+ if not inspect.iscoroutinefunction(target):
154
+ raise InvalidHandlerError(f"handler for {ref.key!r} must be async")
155
+
156
+ signature = inspect.signature(target)
157
+ positional_parameters = [
158
+ parameter
159
+ for parameter in signature.parameters.values()
160
+ if parameter.kind
161
+ in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
162
+ ]
163
+ if len(positional_parameters) != 1:
164
+ raise InvalidHandlerError(
165
+ f"handler for {ref.key!r} must accept exactly one positional input"
166
+ )
167
+ parameter = positional_parameters[0]
168
+ hints = get_type_hints(target)
169
+ input_hint = hints.get(parameter.name, parameter.annotation)
170
+ output_hint = hints.get("return", signature.return_annotation)
171
+ if input_hint is inspect.Signature.empty:
172
+ raise InvalidHandlerError(f"handler for {ref.key!r} must annotate input")
173
+ if output_hint is inspect.Signature.empty:
174
+ raise InvalidHandlerError(f"handler for {ref.key!r} must annotate return")
175
+ if input_hint is not ref.contract.input:
176
+ raise InvalidHandlerError(
177
+ f"handler for {ref.key!r} input annotation must be "
178
+ f"{ref.contract.input.__name__}, got {input_hint!r}"
179
+ )
180
+ if output_hint is not ref.contract.output:
181
+ raise InvalidHandlerError(
182
+ f"handler for {ref.key!r} return annotation must be "
183
+ f"{ref.contract.output.__name__}, got {output_hint!r}"
184
+ )
185
+ self._validated_handler_types.add(cache_key)
186
+
187
+
188
+ class Caller[ContextT]:
189
+ """Context-bound caller used to invoke usecase contracts."""
190
+
191
+ def __init__(
192
+ self,
193
+ *,
194
+ api: UseCaseAPI[ContextT],
195
+ context: ContextT,
196
+ current_key: str | None,
197
+ records: list[CallRecord],
198
+ ) -> None:
199
+ """Create a context-bound caller used by the registry internals."""
200
+ self._api = api
201
+ self.context = context
202
+ self._current_key = current_key
203
+ self._records = records
204
+
205
+ async def call(self, ref: UseCaseRef[InputT, OutputT], input: InputT, /) -> OutputT:
206
+ """Call a usecase by contract token."""
207
+ if self._current_key is not None and self._api.strict_dependencies:
208
+ parent = self._api._get_binding_by_key(self._current_key)
209
+ if ref.key not in parent.uses:
210
+ raise UndeclaredUseCaseDependencyError(
211
+ caller_key=self._current_key,
212
+ callee_key=ref.key,
213
+ )
214
+
215
+ binding = self._api._get_binding(ref)
216
+ self._records.append(CallRecord(caller_key=self._current_key, callee_key=ref.key))
217
+ child_caller = Caller(
218
+ api=self._api,
219
+ context=self.context,
220
+ current_key=ref.key,
221
+ records=self._records,
222
+ )
223
+ handler = binding.factory(child_caller)
224
+ if self._api.validate_handlers:
225
+ self._api._validate_handler(ref, handler)
226
+
227
+ try:
228
+ result = handler(input)
229
+ if not inspect.isawaitable(result):
230
+ raise InvalidHandlerError(f"handler for {ref.key!r} did not return an awaitable")
231
+ output = await result
232
+ except Exception as exc:
233
+ self._validate_exception(ref, exc)
234
+ raise
235
+
236
+ if not isinstance(output, ref.contract.output):
237
+ raise InvalidHandlerError(
238
+ f"handler for {ref.key!r} returned {type(output).__name__}, "
239
+ f"expected {ref.contract.output.__name__}"
240
+ )
241
+ return output
242
+
243
+ async def gather(self, *awaitables: Awaitable[Any]) -> tuple[Any, ...]:
244
+ """Run multiple usecase calls concurrently and preserve ExceptionGroup semantics."""
245
+ results: list[Any] = [None] * len(awaitables)
246
+
247
+ async def run_one(index: int, awaitable: Awaitable[Any]) -> None:
248
+ results[index] = await awaitable
249
+
250
+ async with asyncio.TaskGroup() as task_group:
251
+ for index, awaitable in enumerate(awaitables):
252
+ task_group.create_task(run_one(index, awaitable))
253
+ return tuple(results)
254
+
255
+ @property
256
+ def records(self) -> tuple[CallRecord, ...]:
257
+ """Runtime call records captured by this caller."""
258
+ return tuple(self._records)
259
+
260
+ def _validate_exception(self, ref: UseCaseRef[Any, Any], exc: BaseException) -> None:
261
+ if not self._api.strict_errors:
262
+ return
263
+ undeclared = _find_undeclared_usecase_error(exc, ref.contract.raises)
264
+ if undeclared is not None:
265
+ raise UndeclaredUseCaseError(usecase_key=ref.key, error=undeclared) from exc
266
+
267
+
268
+ def _callable_target(handler: UseCase[Any, Any]) -> Callable[..., Any]:
269
+ if inspect.isfunction(handler) or inspect.ismethod(handler):
270
+ return cast(Callable[..., Any], handler)
271
+ if not callable(handler):
272
+ raise InvalidHandlerError(f"handler {handler!r} is not callable")
273
+ return cast(Callable[..., Any], handler.__call__)
274
+
275
+
276
+ def _find_undeclared_usecase_error(
277
+ exc: BaseException,
278
+ declared: tuple[type[UseCaseError], ...],
279
+ ) -> UseCaseError | None:
280
+ if isinstance(exc, UseCaseError):
281
+ if declared and isinstance(exc, declared):
282
+ return None
283
+ return exc
284
+ if isinstance(exc, BaseExceptionGroup):
285
+ for nested in exc.exceptions:
286
+ found = _find_undeclared_usecase_error(nested, declared)
287
+ if found is not None:
288
+ return found
289
+ return None
usecaseapi/cli.py ADDED
@@ -0,0 +1,182 @@
1
+ """Command-line interface for inspecting and scaffolding UseCaseAPI projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import Any, cast
12
+
13
+ from .api import UseCaseAPI
14
+ from .docs import render_markdown, render_mermaid
15
+ from .scaffold import ScaffoldOptions, scaffold_usecase
16
+ from .snapshot import diff_snapshots, load_snapshot, snapshot_from_api
17
+
18
+
19
+ def main(argv: Sequence[str] | None = None) -> int:
20
+ """Run the command-line interface and return a process exit code."""
21
+ parser = _build_parser()
22
+ args = parser.parse_args(argv)
23
+ command = cast(str, args.command)
24
+ if command == "scaffold":
25
+ return _cmd_scaffold(args)
26
+ if command == "inspect":
27
+ return _cmd_inspect(args)
28
+ if command == "snapshot":
29
+ return _cmd_snapshot(args)
30
+ if command == "docs":
31
+ return _cmd_docs(args)
32
+ if command == "graph":
33
+ return _cmd_graph(args)
34
+ if command == "diff":
35
+ return _cmd_diff(args)
36
+ if command == "check":
37
+ return _cmd_check(args)
38
+ parser.print_help()
39
+ return 2
40
+
41
+
42
+ def _build_parser() -> argparse.ArgumentParser:
43
+ parser = argparse.ArgumentParser(prog="usecaseapi")
44
+ subparsers = parser.add_subparsers(dest="command", required=True)
45
+
46
+ scaffold = subparsers.add_parser("scaffold", help="create a versioned usecase skeleton")
47
+ scaffold.add_argument("name", help="contract name, e.g. orders.place_order")
48
+ version_group = scaffold.add_mutually_exclusive_group()
49
+ version_group.add_argument("--version", type=int, default=None, help="target major version")
50
+ version_group.add_argument(
51
+ "--next",
52
+ action="store_true",
53
+ help="create the next available major version",
54
+ )
55
+ scaffold.add_argument(
56
+ "--from-version",
57
+ type=int,
58
+ default=None,
59
+ help="copy an existing contract version and bump metadata",
60
+ )
61
+ scaffold.add_argument("--contracts-root", default="app/contracts")
62
+ scaffold.add_argument("--implementations-root", default="app/usecases")
63
+ scaffold.add_argument("--tests-root", default="tests")
64
+ scaffold.add_argument("--contracts-package", default="app.contracts")
65
+ scaffold.add_argument("--implementations-package", default="app.usecases")
66
+ scaffold.add_argument("--force", action="store_true")
67
+ scaffold.add_argument("--dry-run", action="store_true")
68
+ scaffold.add_argument("--no-implementation", action="store_true")
69
+ scaffold.add_argument("--no-tests", action="store_true")
70
+ scaffold.add_argument("--no-init", action="store_true")
71
+
72
+ for name in ("inspect", "snapshot", "docs", "graph", "check"):
73
+ sub = subparsers.add_parser(name, help=f"{name} a UseCaseAPI instance")
74
+ sub.add_argument("app", help="import path like 'myapp.composition:usecases'")
75
+ if name in {"snapshot", "docs", "graph"}:
76
+ sub.add_argument("--output", "-o", default=None)
77
+
78
+ diff = subparsers.add_parser("diff", help="compare two UseCaseAPI snapshots")
79
+ diff.add_argument("old")
80
+ diff.add_argument("new")
81
+ diff.add_argument("--json", action="store_true")
82
+
83
+ return parser
84
+
85
+
86
+ def _cmd_scaffold(args: argparse.Namespace) -> int:
87
+ result = scaffold_usecase(
88
+ ScaffoldOptions(
89
+ name=cast(str, args.name),
90
+ version=None if cast(bool, args.next) else cast(int | None, args.version),
91
+ from_version=cast(int | None, args.from_version),
92
+ contracts_root=Path(cast(str, args.contracts_root)),
93
+ implementations_root=Path(cast(str, args.implementations_root)),
94
+ tests_root=Path(cast(str, args.tests_root)),
95
+ contracts_package=cast(str, args.contracts_package),
96
+ implementations_package=cast(str, args.implementations_package),
97
+ force=cast(bool, args.force),
98
+ dry_run=cast(bool, args.dry_run),
99
+ create_implementation=not cast(bool, args.no_implementation),
100
+ create_tests=not cast(bool, args.no_tests),
101
+ create_init=not cast(bool, args.no_init),
102
+ )
103
+ )
104
+ print(f"scaffolded version: v{result.version}")
105
+ for file_path in result.files:
106
+ print(f"created: {file_path}")
107
+ for file_path in result.skipped:
108
+ print(f"skipped: {file_path}")
109
+ return 0
110
+
111
+
112
+ def _cmd_inspect(args: argparse.Namespace) -> int:
113
+ api = _load_api(cast(str, args.app))
114
+ print(json.dumps(snapshot_from_api(api), ensure_ascii=False, indent=2))
115
+ return 0
116
+
117
+
118
+ def _cmd_snapshot(args: argparse.Namespace) -> int:
119
+ api = _load_api(cast(str, args.app))
120
+ payload = json.dumps(snapshot_from_api(api), ensure_ascii=False, indent=2) + "\n"
121
+ _write_or_print(payload, cast(str | None, args.output))
122
+ return 0
123
+
124
+
125
+ def _cmd_docs(args: argparse.Namespace) -> int:
126
+ api = _load_api(cast(str, args.app))
127
+ _write_or_print(render_markdown(api), cast(str | None, args.output))
128
+ return 0
129
+
130
+
131
+ def _cmd_graph(args: argparse.Namespace) -> int:
132
+ api = _load_api(cast(str, args.app))
133
+ _write_or_print(render_mermaid(api), cast(str | None, args.output))
134
+ return 0
135
+
136
+
137
+ def _cmd_check(args: argparse.Namespace) -> int:
138
+ api = _load_api(cast(str, args.app))
139
+ api.validate(require_handlers=True)
140
+ print("UseCaseAPI check passed")
141
+ return 0
142
+
143
+
144
+ def _cmd_diff(args: argparse.Namespace) -> int:
145
+ diff = diff_snapshots(load_snapshot(cast(str, args.old)), load_snapshot(cast(str, args.new)))
146
+ if cast(bool, args.json):
147
+ print(json.dumps(diff.to_dict(), ensure_ascii=False, indent=2))
148
+ else:
149
+ for label, items in (
150
+ ("Breaking", diff.breaking),
151
+ ("Warnings", diff.warnings),
152
+ ("Additions", diff.additions),
153
+ ):
154
+ print(label + ":")
155
+ if items:
156
+ for item in items:
157
+ print(f" - {item}")
158
+ else:
159
+ print(" - none")
160
+ return 1 if diff.has_breaking_changes else 0
161
+
162
+
163
+ def _load_api(import_path: str) -> UseCaseAPI[Any]:
164
+ module_name, separator, attribute_name = import_path.partition(":")
165
+ if not separator or not module_name or not attribute_name:
166
+ raise ValueError("app import path must look like 'module:attribute'")
167
+ module = importlib.import_module(module_name)
168
+ value = getattr(module, attribute_name)
169
+ if not isinstance(value, UseCaseAPI):
170
+ raise TypeError(f"{import_path!r} is not a UseCaseAPI instance")
171
+ return value
172
+
173
+
174
+ def _write_or_print(content: str, output: str | None) -> None:
175
+ if output is None:
176
+ print(content, end="")
177
+ return
178
+ Path(output).write_text(content)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ raise SystemExit(main())
@@ -0,0 +1,103 @@
1
+ """Contract metadata and typed usecase references."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Protocol, TypeVar, runtime_checkable
7
+
8
+ from .errors import ContractDefinitionError, UseCaseError
9
+ from .model import Model
10
+
11
+ InputT = TypeVar("InputT", bound=Model)
12
+ OutputT = TypeVar("OutputT", bound=Model)
13
+ UseCaseInputT = TypeVar("UseCaseInputT", bound=Model, contravariant=True)
14
+ UseCaseOutputT = TypeVar("UseCaseOutputT", bound=Model, covariant=True)
15
+
16
+
17
+ @runtime_checkable
18
+ class UseCase(Protocol[UseCaseInputT, UseCaseOutputT]):
19
+ """Structural protocol for a same-process usecase implementation."""
20
+
21
+ async def __call__(self, input: UseCaseInputT, /) -> UseCaseOutputT:
22
+ """Run the usecase."""
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class Contract[InputT: Model, OutputT: Model]:
27
+ """Runtime metadata for a Protocol-first usecase contract."""
28
+
29
+ name: str
30
+ version: int
31
+ input: type[InputT]
32
+ output: type[OutputT]
33
+ raises: tuple[type[UseCaseError], ...] = ()
34
+ known_errors: tuple[type[UseCaseError], ...] = ()
35
+ stable: bool = True
36
+ deprecated: bool = False
37
+ superseded_by: str | None = None
38
+ description: str | None = None
39
+ tags: tuple[str, ...] = field(default_factory=tuple)
40
+
41
+ def __post_init__(self) -> None:
42
+ """Validate contract metadata after dataclass initialization."""
43
+ if not self.name or not self.name.strip():
44
+ raise ContractDefinitionError("contract name must not be empty")
45
+ if self.version < 1:
46
+ raise ContractDefinitionError("contract version must be >= 1")
47
+ if not issubclass(self.input, Model):
48
+ raise ContractDefinitionError("contract input must inherit usecaseapi.Model")
49
+ if not issubclass(self.output, Model):
50
+ raise ContractDefinitionError("contract output must inherit usecaseapi.Model")
51
+ for error_type in (*self.raises, *self.known_errors):
52
+ if not issubclass(error_type, UseCaseError):
53
+ raise ContractDefinitionError(
54
+ "contract errors must inherit usecaseapi.UseCaseError"
55
+ )
56
+ if self.raises:
57
+ for error_type in self.known_errors:
58
+ if not any(issubclass(error_type, declared) for declared in self.raises):
59
+ raise ContractDefinitionError(
60
+ f"known error {error_type.__qualname__} is not covered by raises"
61
+ )
62
+
63
+ @property
64
+ def key(self) -> str:
65
+ """Stable contract key in ``name@vN`` form."""
66
+ return f"{self.name}@v{self.version}"
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class UseCaseRef[InputT: Model, OutputT: Model]:
71
+ """Typed token used to call and bind a usecase contract."""
72
+
73
+ protocol: type[Any]
74
+ contract: Contract[InputT, OutputT]
75
+
76
+ @property
77
+ def key(self) -> str:
78
+ """Stable contract key in ``name@vN`` form."""
79
+ return self.contract.key
80
+
81
+ @property
82
+ def name(self) -> str:
83
+ """Contract name without the version suffix."""
84
+ return self.contract.name
85
+
86
+ @property
87
+ def version(self) -> int:
88
+ """Contract major version."""
89
+ return self.contract.version
90
+
91
+ def __repr__(self) -> str:
92
+ """Return a compact debug representation."""
93
+ return f"UseCaseRef({self.key})"
94
+
95
+
96
+ def define_usecase[InputT: Model, OutputT: Model](
97
+ protocol: type[Any],
98
+ contract: Contract[InputT, OutputT],
99
+ ) -> UseCaseRef[InputT, OutputT]:
100
+ """Create a typed token for a usecase Protocol and its contract metadata."""
101
+ if protocol is object:
102
+ raise ContractDefinitionError("protocol must be a concrete Protocol class")
103
+ return UseCaseRef(protocol=protocol, contract=contract)