composeai 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.
- composeai/__init__.py +33 -0
- composeai/_encoding.py +240 -0
- composeai/_ids.py +41 -0
- composeai/_schema.py +147 -0
- composeai/agentfn.py +1177 -0
- composeai/cli.py +885 -0
- composeai/combinators.py +535 -0
- composeai/errors.py +96 -0
- composeai/events.py +172 -0
- composeai/flow.py +591 -0
- composeai/hitl.py +113 -0
- composeai/messages.py +202 -0
- composeai/models/__init__.py +3 -0
- composeai/models/anthropic.py +485 -0
- composeai/models/base.py +128 -0
- composeai/models/compatible.py +589 -0
- composeai/models/openai.py +638 -0
- composeai/models/prices.py +127 -0
- composeai/models/registry.py +105 -0
- composeai/py.typed +0 -0
- composeai/runs.py +1426 -0
- composeai/testing.py +653 -0
- composeai/tools.py +301 -0
- composeai/tracing.py +625 -0
- composeai-0.1.0.dist-info/METADATA +299 -0
- composeai-0.1.0.dist-info/RECORD +29 -0
- composeai-0.1.0.dist-info/WHEEL +4 -0
- composeai-0.1.0.dist-info/entry_points.txt +2 -0
- composeai-0.1.0.dist-info/licenses/LICENSE +21 -0
composeai/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""composeai: a radically simple, functional framework for multi-agent AI workflows.
|
|
2
|
+
|
|
3
|
+
Typed agent functions, pipe/aggregate/map composition with build-time type
|
|
4
|
+
checks, always-on local tracing, and durable, resumable flows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .agentfn import agent, prompt
|
|
8
|
+
from .combinators import aggregate, map, pipe
|
|
9
|
+
from .flow import flow, resume, task
|
|
10
|
+
from .hitl import Interrupt, approve, ask_human
|
|
11
|
+
from .models.compatible import openai_compatible
|
|
12
|
+
from .runs import Budget, Run
|
|
13
|
+
from .tools import tool
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Budget",
|
|
19
|
+
"Interrupt",
|
|
20
|
+
"Run",
|
|
21
|
+
"agent",
|
|
22
|
+
"aggregate",
|
|
23
|
+
"approve",
|
|
24
|
+
"ask_human",
|
|
25
|
+
"flow",
|
|
26
|
+
"map",
|
|
27
|
+
"openai_compatible",
|
|
28
|
+
"pipe",
|
|
29
|
+
"prompt",
|
|
30
|
+
"resume",
|
|
31
|
+
"task",
|
|
32
|
+
"tool",
|
|
33
|
+
]
|
composeai/_encoding.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""JSON-safe encode/decode for journal and trace persistence.
|
|
2
|
+
|
|
3
|
+
``to_jsonable``/``from_jsonable`` turn a restricted set of Python values
|
|
4
|
+
into plain ``dict``/``list``/scalar JSON trees (and back), tagging
|
|
5
|
+
non-trivial types with a reserved ``"$kind"`` key so they can be
|
|
6
|
+
rehydrated. Deliberately **no pickle** and **no dynamic import**:
|
|
7
|
+
decoding data that references an unregistered type raises
|
|
8
|
+
:class:`~composeai.errors.SerializationError` naming the missing type
|
|
9
|
+
rather than importing it -- guessing and importing arbitrary dotted
|
|
10
|
+
paths found in persisted data is a code-execution hole (this exact
|
|
11
|
+
issue was found and fixed in a prior audit). Callers must import the
|
|
12
|
+
module that defines a type -- or call :func:`register_serializable` --
|
|
13
|
+
before decoding data that references it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import datetime
|
|
20
|
+
import enum
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
from .errors import SerializationError
|
|
26
|
+
|
|
27
|
+
_KIND = "$kind"
|
|
28
|
+
_TYPE = "$type"
|
|
29
|
+
_VALUE = "value"
|
|
30
|
+
|
|
31
|
+
# Type registry: never used to import anything, only to look up classes
|
|
32
|
+
# that some part of this process has already imported and registered.
|
|
33
|
+
_REGISTRY: dict[str, type] = {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register_serializable(cls: type) -> type:
|
|
37
|
+
"""Register ``cls`` so :func:`from_jsonable` can rehydrate it by tag.
|
|
38
|
+
|
|
39
|
+
Encoding a pydantic model, dataclass, or enum auto-registers its
|
|
40
|
+
class. Call this explicitly in a process that decodes data it never
|
|
41
|
+
encoded (e.g. a fresh journal reader started in a new process).
|
|
42
|
+
Returns ``cls`` unchanged, so it can also be used as a decorator.
|
|
43
|
+
"""
|
|
44
|
+
_REGISTRY[_type_tag(cls)] = cls
|
|
45
|
+
return cls
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _type_tag(cls: type) -> str:
|
|
49
|
+
return f"{cls.__module__}:{cls.__qualname__}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _child_path(path: str, key: str) -> str:
|
|
53
|
+
return f"{path}.{key}" if path else key
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _index_path(path: str, index: int) -> str:
|
|
57
|
+
return f"{path}[{index}]"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_namedtuple(obj: Any) -> bool:
|
|
61
|
+
"""Best-effort, standard-library-idiomatic NamedTuple detection.
|
|
62
|
+
|
|
63
|
+
``isinstance(obj, tuple)`` is true for NamedTuple instances too (they're
|
|
64
|
+
tuple subclasses) -- this must be checked *before* the generic tuple
|
|
65
|
+
branch, or a NamedTuple silently degrades to a plain tuple.
|
|
66
|
+
"""
|
|
67
|
+
return (
|
|
68
|
+
isinstance(obj, tuple)
|
|
69
|
+
and type(obj) is not tuple
|
|
70
|
+
and hasattr(type(obj), "_fields")
|
|
71
|
+
and hasattr(type(obj), "_asdict")
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def to_jsonable(obj: Any) -> Any:
|
|
76
|
+
"""Convert ``obj`` into a plain JSON-safe tree.
|
|
77
|
+
|
|
78
|
+
Raises :class:`SerializationError` naming the offending path if any
|
|
79
|
+
value nested in ``obj`` isn't supported.
|
|
80
|
+
"""
|
|
81
|
+
return _encode(obj, "")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _encode(obj: Any, path: str) -> Any:
|
|
85
|
+
if obj is None or isinstance(obj, (bool, int, float, str)):
|
|
86
|
+
return obj
|
|
87
|
+
|
|
88
|
+
if isinstance(obj, BaseModel):
|
|
89
|
+
register_serializable(type(obj))
|
|
90
|
+
return {
|
|
91
|
+
_KIND: "pydantic",
|
|
92
|
+
_TYPE: _type_tag(type(obj)),
|
|
93
|
+
_VALUE: obj.model_dump(mode="json"),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if isinstance(obj, enum.Enum):
|
|
97
|
+
register_serializable(type(obj))
|
|
98
|
+
return {_KIND: "enum", _TYPE: _type_tag(type(obj)), _VALUE: obj.name}
|
|
99
|
+
|
|
100
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
101
|
+
register_serializable(type(obj))
|
|
102
|
+
non_init = [field.name for field in dataclasses.fields(obj) if not field.init]
|
|
103
|
+
if non_init:
|
|
104
|
+
raise SerializationError(
|
|
105
|
+
f"{path or '<root>'}: dataclass {type(obj).__name__!r} has non-init "
|
|
106
|
+
f"field(s) {non_init!r} (declared with field(init=False), typically "
|
|
107
|
+
"computed in __post_init__) -- these can't round-trip the journal: "
|
|
108
|
+
"encoding would capture the computed value, but decoding calls "
|
|
109
|
+
"cls(**fields), which rejects any keyword the real __init__ doesn't "
|
|
110
|
+
"accept. Exclude non-init fields from what you journal (e.g. store a "
|
|
111
|
+
"plain dict/dataclass of only the init fields), or give this "
|
|
112
|
+
"dataclass a classmethod constructor you call explicitly on decode."
|
|
113
|
+
)
|
|
114
|
+
value = {
|
|
115
|
+
field.name: _encode(getattr(obj, field.name), _child_path(path, field.name))
|
|
116
|
+
for field in dataclasses.fields(obj)
|
|
117
|
+
}
|
|
118
|
+
return {_KIND: "dataclass", _TYPE: _type_tag(type(obj)), _VALUE: value}
|
|
119
|
+
|
|
120
|
+
if isinstance(obj, datetime.datetime):
|
|
121
|
+
return {_KIND: "datetime", _VALUE: obj.isoformat()}
|
|
122
|
+
if isinstance(obj, datetime.date):
|
|
123
|
+
return {_KIND: "date", _VALUE: obj.isoformat()}
|
|
124
|
+
if isinstance(obj, datetime.time):
|
|
125
|
+
return {_KIND: "time", _VALUE: obj.isoformat()}
|
|
126
|
+
|
|
127
|
+
if _is_namedtuple(obj):
|
|
128
|
+
raise SerializationError(
|
|
129
|
+
f"{path or '<root>'}: {type(obj).__name__!r} is a NamedTuple, which the "
|
|
130
|
+
"journal encoder doesn't support -- it's a tuple subclass, so encoding it "
|
|
131
|
+
"as a plain tuple would silently lose field-name access on decode (an "
|
|
132
|
+
"AttributeError on the replayed value where the original worked). Convert "
|
|
133
|
+
"it to a dataclass, a dict, or a plain tuple before journaling it."
|
|
134
|
+
)
|
|
135
|
+
if isinstance(obj, tuple):
|
|
136
|
+
return {
|
|
137
|
+
_KIND: "tuple",
|
|
138
|
+
_VALUE: [_encode(v, _index_path(path, i)) for i, v in enumerate(obj)],
|
|
139
|
+
}
|
|
140
|
+
if isinstance(obj, frozenset):
|
|
141
|
+
return {
|
|
142
|
+
_KIND: "frozenset",
|
|
143
|
+
_VALUE: [_encode(v, _index_path(path, i)) for i, v in enumerate(obj)],
|
|
144
|
+
}
|
|
145
|
+
if isinstance(obj, set):
|
|
146
|
+
return {
|
|
147
|
+
_KIND: "set",
|
|
148
|
+
_VALUE: [_encode(v, _index_path(path, i)) for i, v in enumerate(obj)],
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if isinstance(obj, list):
|
|
152
|
+
return [_encode(v, _index_path(path, i)) for i, v in enumerate(obj)]
|
|
153
|
+
|
|
154
|
+
if isinstance(obj, dict):
|
|
155
|
+
for key in obj:
|
|
156
|
+
if not isinstance(key, str):
|
|
157
|
+
raise SerializationError(
|
|
158
|
+
f"{path or '<root>'}: dict keys must be str, got {key!r}"
|
|
159
|
+
)
|
|
160
|
+
encoded = {k: _encode(v, _child_path(path, k)) for k, v in obj.items()}
|
|
161
|
+
if _KIND in obj:
|
|
162
|
+
# A plain dict that happens to use our reserved key: escape it
|
|
163
|
+
# so decoding is unambiguous.
|
|
164
|
+
return {_KIND: "dict", _VALUE: encoded}
|
|
165
|
+
return encoded
|
|
166
|
+
|
|
167
|
+
raise SerializationError(f"{path or '<root>'}: {type(obj)!r} is not JSON-serializable")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def from_jsonable(data: Any) -> Any:
|
|
171
|
+
"""Inverse of :func:`to_jsonable`.
|
|
172
|
+
|
|
173
|
+
Raises :class:`SerializationError` if a ``$type`` tag isn't in the
|
|
174
|
+
registry (see :func:`register_serializable`). Never imports modules
|
|
175
|
+
to resolve a type.
|
|
176
|
+
"""
|
|
177
|
+
if isinstance(data, dict):
|
|
178
|
+
if _KIND in data:
|
|
179
|
+
return _decode_tagged(data)
|
|
180
|
+
return {k: from_jsonable(v) for k, v in data.items()}
|
|
181
|
+
if isinstance(data, list):
|
|
182
|
+
return [from_jsonable(v) for v in data]
|
|
183
|
+
return data
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _decode_tagged(data: dict[str, Any]) -> Any:
|
|
187
|
+
kind = data.get(_KIND)
|
|
188
|
+
try:
|
|
189
|
+
if kind == "dict":
|
|
190
|
+
return {k: from_jsonable(v) for k, v in data[_VALUE].items()}
|
|
191
|
+
if kind == "pydantic":
|
|
192
|
+
cls = _resolve_type(data[_TYPE])
|
|
193
|
+
value = {k: from_jsonable(v) for k, v in data[_VALUE].items()}
|
|
194
|
+
return cls.model_validate(value)
|
|
195
|
+
if kind == "dataclass":
|
|
196
|
+
cls = _resolve_type(data[_TYPE])
|
|
197
|
+
value = {k: from_jsonable(v) for k, v in data[_VALUE].items()}
|
|
198
|
+
return cls(**value)
|
|
199
|
+
if kind == "enum":
|
|
200
|
+
cls = _resolve_type(data[_TYPE])
|
|
201
|
+
return cls[data[_VALUE]]
|
|
202
|
+
if kind == "datetime":
|
|
203
|
+
return datetime.datetime.fromisoformat(data[_VALUE])
|
|
204
|
+
if kind == "date":
|
|
205
|
+
return datetime.date.fromisoformat(data[_VALUE])
|
|
206
|
+
if kind == "time":
|
|
207
|
+
return datetime.time.fromisoformat(data[_VALUE])
|
|
208
|
+
if kind == "tuple":
|
|
209
|
+
return tuple(from_jsonable(v) for v in data[_VALUE])
|
|
210
|
+
if kind == "set":
|
|
211
|
+
return {from_jsonable(v) for v in data[_VALUE]}
|
|
212
|
+
if kind == "frozenset":
|
|
213
|
+
return frozenset(from_jsonable(v) for v in data[_VALUE])
|
|
214
|
+
except SerializationError:
|
|
215
|
+
raise # already the right type (e.g. _resolve_type's "unregistered type") -- pass through
|
|
216
|
+
except (KeyError, TypeError) as exc:
|
|
217
|
+
# A tagged value missing an expected key (e.g. `{"$kind": "dataclass",
|
|
218
|
+
# "value": {...}}` with no "$type" -- DB corruption, a manual edit, a
|
|
219
|
+
# future schema/version mismatch, ...) used to raise a raw KeyError/
|
|
220
|
+
# TypeError here instead of this module's own SerializationError, so
|
|
221
|
+
# callers that specifically catch SerializationError (the module's
|
|
222
|
+
# documented contract) didn't catch this.
|
|
223
|
+
raise SerializationError(
|
|
224
|
+
f"corrupted journal/state row (kind={kind!r}): {type(exc).__name__}: {exc}"
|
|
225
|
+
) from exc
|
|
226
|
+
|
|
227
|
+
raise SerializationError(f"Unknown {_KIND!r} tag: {kind!r}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _resolve_type(tag: str) -> Any:
|
|
231
|
+
cls = _REGISTRY.get(tag)
|
|
232
|
+
if cls is not None:
|
|
233
|
+
return cls
|
|
234
|
+
module_hint = tag.split(":", 1)[0]
|
|
235
|
+
raise SerializationError(
|
|
236
|
+
f"Cannot decode unregistered type {tag!r}: import the module that "
|
|
237
|
+
f"defines it (e.g. `import {module_hint}`) or call "
|
|
238
|
+
f"register_serializable(...) before decoding this data. "
|
|
239
|
+
f"Types are never imported automatically, by design (security)."
|
|
240
|
+
)
|
composeai/_ids.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Sortable unique identifiers (ULID-style) for composeai.
|
|
2
|
+
|
|
3
|
+
A ``new_ulid()`` result is a 26-character Crockford Base32 string encoding
|
|
4
|
+
128 bits: a 48-bit Unix-millisecond timestamp followed by 80 bits of
|
|
5
|
+
cryptographically random data from :func:`os.urandom`. Because the
|
|
6
|
+
timestamp occupies the most significant bits, two IDs generated at least
|
|
7
|
+
1 millisecond apart sort lexicographically in time order -- useful for
|
|
8
|
+
run/span/message identifiers that should also read as roughly
|
|
9
|
+
chronological when listed.
|
|
10
|
+
|
|
11
|
+
Stdlib only; no third-party dependency.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
# Crockford's Base32 alphabet: excludes I, L, O, U to avoid visual ambiguity.
|
|
20
|
+
_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
21
|
+
_TIMESTAMP_BYTES = 6 # 48 bits
|
|
22
|
+
_RANDOM_BYTES = 10 # 80 bits
|
|
23
|
+
_ENCODED_LENGTH = 26 # 128 bits / 5 bits-per-char (rounded up)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def new_ulid() -> str:
|
|
27
|
+
"""Return a new 26-character, lexicographically time-sortable ID."""
|
|
28
|
+
timestamp_ms = int(time.time() * 1000)
|
|
29
|
+
ts_bytes = timestamp_ms.to_bytes(_TIMESTAMP_BYTES, byteorder="big")
|
|
30
|
+
rand_bytes = os.urandom(_RANDOM_BYTES)
|
|
31
|
+
return _encode(ts_bytes + rand_bytes)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _encode(data: bytes) -> str:
|
|
35
|
+
"""Encode 16 raw bytes (128 bits) as 26 Crockford Base32 characters."""
|
|
36
|
+
value = int.from_bytes(data, byteorder="big")
|
|
37
|
+
chars = [""] * _ENCODED_LENGTH
|
|
38
|
+
for i in range(_ENCODED_LENGTH - 1, -1, -1):
|
|
39
|
+
chars[i] = _ALPHABET[value & 0x1F]
|
|
40
|
+
value >>= 5
|
|
41
|
+
return "".join(chars)
|
composeai/_schema.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Shared JSON Schema post-processing for ``@tool`` and ``@agent`` structured output.
|
|
2
|
+
|
|
3
|
+
Both derive their schemas from pydantic (``create_model`` / ``TypeAdapter``) and
|
|
4
|
+
both need the same treatment before handing a schema to a model: strict
|
|
5
|
+
``additionalProperties: false`` everywhere -- including nested ``$defs`` --
|
|
6
|
+
and no pydantic-generated ``title`` noise (models rely on ``description``,
|
|
7
|
+
not ``title``, and a stray auto-title only wastes tokens).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
import enum
|
|
14
|
+
import inspect
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any, get_args, get_type_hints
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from ._encoding import register_serializable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_annotations(fn: Callable[..., Any], *, include_extras: bool = True) -> dict[str, Any]:
|
|
24
|
+
"""Best-effort ``typing.get_type_hints`` for ``fn``.
|
|
25
|
+
|
|
26
|
+
``get_type_hints`` resolves string-form annotations (e.g. under
|
|
27
|
+
``from __future__ import annotations``) against ``fn.__globals__``, but
|
|
28
|
+
it cannot see names from an enclosing function's local/closure scope --
|
|
29
|
+
a common pattern in tests that define a small model right next to the
|
|
30
|
+
function using it. When that lookup fails, fall back to each
|
|
31
|
+
parameter's raw annotation from :func:`inspect.signature`: on every
|
|
32
|
+
supported Python version, an annotation that's already a real object
|
|
33
|
+
(the common case without the ``__future__`` import, and -- since
|
|
34
|
+
Python 3.14's lazy annotation evaluation -- even local/closure names
|
|
35
|
+
under it) survives untouched; only an unresolved string annotation
|
|
36
|
+
combining *both* the ``__future__`` import *and* a local/closure name
|
|
37
|
+
would still come back as a plain string here.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
return get_type_hints(fn, include_extras=include_extras)
|
|
41
|
+
except NameError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
sig = inspect.signature(fn)
|
|
45
|
+
raw: dict[str, Any] = {
|
|
46
|
+
name: param.annotation
|
|
47
|
+
for name, param in sig.parameters.items()
|
|
48
|
+
if param.annotation is not inspect.Parameter.empty
|
|
49
|
+
}
|
|
50
|
+
if sig.return_annotation is not inspect.Signature.empty:
|
|
51
|
+
raw["return"] = sig.return_annotation
|
|
52
|
+
return raw
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def register_annotation_types(fn: Callable[..., Any]) -> None:
|
|
56
|
+
"""Register every pydantic model / dataclass / enum reachable from ``fn``'s annotations.
|
|
57
|
+
|
|
58
|
+
Walks :func:`resolve_annotations` (every parameter plus the return
|
|
59
|
+
type), recursing into generic args (``typing.get_args`` -- e.g. the
|
|
60
|
+
``Inner`` in ``list[Inner]`` or ``dict[str, Outer]``) and, for
|
|
61
|
+
pydantic models and dataclasses, into their own field types. Called by
|
|
62
|
+
``@task``/``@flow``/``@agent`` decoration so a *fresh process* (e.g. a
|
|
63
|
+
resumed flow started via a new ``python`` invocation) can decode
|
|
64
|
+
journaled values referencing these types via
|
|
65
|
+
:func:`~composeai._encoding.from_jsonable` without composeai ever
|
|
66
|
+
importing a type dynamically -- that would be a code-execution hole,
|
|
67
|
+
so decoding an unregistered type always raises instead (see
|
|
68
|
+
:mod:`composeai._encoding`).
|
|
69
|
+
"""
|
|
70
|
+
hints = resolve_annotations(fn, include_extras=True)
|
|
71
|
+
seen: set[type] = set()
|
|
72
|
+
for value in hints.values():
|
|
73
|
+
_register_type_recursive(value, seen)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _register_type_recursive(annotation: Any, seen: set[type]) -> None:
|
|
77
|
+
if isinstance(annotation, type):
|
|
78
|
+
if annotation in seen:
|
|
79
|
+
return
|
|
80
|
+
seen.add(annotation)
|
|
81
|
+
if issubclass(annotation, BaseModel):
|
|
82
|
+
register_serializable(annotation)
|
|
83
|
+
for field_info in annotation.model_fields.values():
|
|
84
|
+
_register_type_recursive(field_info.annotation, seen)
|
|
85
|
+
elif dataclasses.is_dataclass(annotation):
|
|
86
|
+
register_serializable(annotation)
|
|
87
|
+
# `dataclasses.fields(annotation)[i].type` is the field's *raw*
|
|
88
|
+
# annotation -- under `from __future__ import annotations` (or
|
|
89
|
+
# any other postponed-evaluation source), that's an unresolved
|
|
90
|
+
# string (e.g. `"Inner"`), not the actual class object. Recursing
|
|
91
|
+
# on it directly means `isinstance(..., type)` is always False
|
|
92
|
+
# and `get_args(...)` always empty, so a dataclass field typed
|
|
93
|
+
# with another custom dataclass/enum/pydantic model silently
|
|
94
|
+
# never gets registered -- exactly the gap the pydantic branch
|
|
95
|
+
# above doesn't have, since `model_fields[...].annotation` is
|
|
96
|
+
# always pydantic-resolved to a real type object already.
|
|
97
|
+
# `typing.get_type_hints` resolves the same way `model_fields`
|
|
98
|
+
# does; fall back to the raw (possibly-string) annotation if
|
|
99
|
+
# resolution itself fails (e.g. a name only in a caller's local
|
|
100
|
+
# scope -- see `resolve_annotations`'s own docstring for the
|
|
101
|
+
# same trade-off).
|
|
102
|
+
try:
|
|
103
|
+
hints = get_type_hints(annotation, include_extras=True)
|
|
104
|
+
except NameError:
|
|
105
|
+
hints = {}
|
|
106
|
+
for field in dataclasses.fields(annotation):
|
|
107
|
+
_register_type_recursive(hints.get(field.name, field.type), seen)
|
|
108
|
+
elif issubclass(annotation, enum.Enum):
|
|
109
|
+
register_serializable(annotation)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
for arg in get_args(annotation):
|
|
113
|
+
_register_type_recursive(arg, seen)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def seal_schema(schema: Any) -> None:
|
|
117
|
+
"""Recursively strip auto-generated ``title`` keys and seal every object schema.
|
|
118
|
+
|
|
119
|
+
Mutates ``schema`` in place. "Object schema" means any nested dict with
|
|
120
|
+
``"type": "object"`` -- including entries under ``$defs`` -- which gets
|
|
121
|
+
``additionalProperties: false`` set, *unless* it already has a
|
|
122
|
+
dict-valued ``additionalProperties`` (a real schema, not a boolean --
|
|
123
|
+
what pydantic generates for an open mapping like ``dict[str, V]``/
|
|
124
|
+
``Mapping[str, V]``, describing the permitted *value* type). Overwriting
|
|
125
|
+
that with ``False`` would turn a schema that legitimately accepts any
|
|
126
|
+
string-keyed dict of ``V`` into one that only ever matches ``{}`` --
|
|
127
|
+
silently forbidding the model from ever populating the field/argument
|
|
128
|
+
it's meant to constrain, not forbid. A boolean (or absent)
|
|
129
|
+
``additionalProperties`` is still always forced to ``False`` (the
|
|
130
|
+
fixed-shape-object case this was designed for -- pydantic models and
|
|
131
|
+
dataclasses). Non-dict values (e.g. entries of a ``"required"`` list)
|
|
132
|
+
are left untouched.
|
|
133
|
+
"""
|
|
134
|
+
if not isinstance(schema, dict):
|
|
135
|
+
return
|
|
136
|
+
# Auto-generated titles are always strings; a dict under a "title" key is
|
|
137
|
+
# a *property named title* (inside a "properties" map) and must survive.
|
|
138
|
+
if isinstance(schema.get("title"), str):
|
|
139
|
+
schema.pop("title")
|
|
140
|
+
if schema.get("type") == "object" and not isinstance(schema.get("additionalProperties"), dict):
|
|
141
|
+
schema["additionalProperties"] = False
|
|
142
|
+
for value in schema.values():
|
|
143
|
+
if isinstance(value, dict):
|
|
144
|
+
seal_schema(value)
|
|
145
|
+
elif isinstance(value, list):
|
|
146
|
+
for item in value:
|
|
147
|
+
seal_schema(item)
|