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 +50 -0
- usecaseapi/api.py +289 -0
- usecaseapi/cli.py +182 -0
- usecaseapi/contracts.py +103 -0
- usecaseapi/docs.py +88 -0
- usecaseapi/errors.py +75 -0
- usecaseapi/model.py +15 -0
- usecaseapi/py.typed +0 -0
- usecaseapi/scaffold.py +298 -0
- usecaseapi/snapshot.py +173 -0
- usecaseapi-1.0.0.dist-info/METADATA +192 -0
- usecaseapi-1.0.0.dist-info/RECORD +15 -0
- usecaseapi-1.0.0.dist-info/WHEEL +4 -0
- usecaseapi-1.0.0.dist-info/entry_points.txt +2 -0
- usecaseapi-1.0.0.dist-info/licenses/LICENSE +21 -0
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())
|
usecaseapi/contracts.py
ADDED
|
@@ -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)
|