modelfill 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.
- modelfill/__init__.py +22 -0
- modelfill/adapters.py +422 -0
- modelfill/backend.py +350 -0
- modelfill/errors.py +25 -0
- modelfill/extract.py +576 -0
- modelfill/filler.py +250 -0
- modelfill/hypothesis.py +54 -0
- modelfill/ir.py +152 -0
- modelfill/py.typed +0 -0
- modelfill-0.1.0.dist-info/METADATA +375 -0
- modelfill-0.1.0.dist-info/RECORD +13 -0
- modelfill-0.1.0.dist-info/WHEEL +4 -0
- modelfill-0.1.0.dist-info/licenses/LICENSE +21 -0
modelfill/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Generate validated model completions from existing constraints."""
|
|
2
|
+
|
|
3
|
+
from .errors import (
|
|
4
|
+
GenerationExhausted,
|
|
5
|
+
ModelFillError,
|
|
6
|
+
SolverTimeout,
|
|
7
|
+
SolverUnknown,
|
|
8
|
+
UnsatisfiableConstraints,
|
|
9
|
+
UnsupportedConstraint,
|
|
10
|
+
)
|
|
11
|
+
from .filler import ModelFiller, fill
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"GenerationExhausted",
|
|
15
|
+
"ModelFillError",
|
|
16
|
+
"ModelFiller",
|
|
17
|
+
"SolverTimeout",
|
|
18
|
+
"SolverUnknown",
|
|
19
|
+
"UnsatisfiableConstraints",
|
|
20
|
+
"UnsupportedConstraint",
|
|
21
|
+
"fill",
|
|
22
|
+
]
|
modelfill/adapters.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Model discovery and normal construction; no solver dependency.
|
|
2
|
+
|
|
3
|
+
Pydantic's original decorator functions are not exposed by its public field API.
|
|
4
|
+
That compatibility-sensitive discovery is confined to ``_pydantic_validators``;
|
|
5
|
+
the only other private API is the guarded ``init_private_attributes`` import.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import inspect
|
|
12
|
+
import math
|
|
13
|
+
import types
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from copy import deepcopy
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Literal, Union, get_args, get_origin, get_type_hints
|
|
18
|
+
|
|
19
|
+
from annotated_types import Ge, Gt, Le, Lt, MaxLen, MinLen, MultipleOf
|
|
20
|
+
from pydantic import BaseModel, ConfigDict
|
|
21
|
+
from pydantic.types import Strict
|
|
22
|
+
from pydantic_core import PydanticUndefined
|
|
23
|
+
|
|
24
|
+
from .errors import UnsupportedConstraint
|
|
25
|
+
from .ir import (
|
|
26
|
+
INT,
|
|
27
|
+
PRIMITIVES,
|
|
28
|
+
TIME_TYPES,
|
|
29
|
+
Domain,
|
|
30
|
+
Expr,
|
|
31
|
+
const,
|
|
32
|
+
encodable,
|
|
33
|
+
evaluate,
|
|
34
|
+
node,
|
|
35
|
+
plain_enum,
|
|
36
|
+
var,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from _typeshed import DataclassInstance
|
|
41
|
+
|
|
42
|
+
try: # If a future Pydantic moves it, PrivateAttr models just fall back to guidance.
|
|
43
|
+
from pydantic._internal._model_construction import init_private_attributes
|
|
44
|
+
except ImportError:
|
|
45
|
+
init_private_attributes = None
|
|
46
|
+
|
|
47
|
+
MISSING = dataclasses.MISSING
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclasses.dataclass(frozen=True)
|
|
51
|
+
class FieldSpec:
|
|
52
|
+
name: str
|
|
53
|
+
domain: Domain | None # None: an external field, supplied by the caller or its default.
|
|
54
|
+
default: Any
|
|
55
|
+
default_factory: Callable[..., Any] | None = None # Never data-dependent here.
|
|
56
|
+
input_name: str | None = None
|
|
57
|
+
unchecked_default: bool = False
|
|
58
|
+
required: bool = True
|
|
59
|
+
annotation: Any = None
|
|
60
|
+
|
|
61
|
+
def preferred(self) -> Any:
|
|
62
|
+
"""Evaluate an ordinary default once per fill; it is a preference."""
|
|
63
|
+
if self.default_factory is not None:
|
|
64
|
+
return self.default_factory()
|
|
65
|
+
return MISSING if self.default is MISSING else deepcopy(self.default)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclasses.dataclass(frozen=True)
|
|
69
|
+
class ValidatorSpec:
|
|
70
|
+
name: str
|
|
71
|
+
function: Callable[..., Any]
|
|
72
|
+
kind: Literal["model", "dataclass", "field"]
|
|
73
|
+
mode: str
|
|
74
|
+
field_name: str | None = None
|
|
75
|
+
earlier_fields: tuple[str, ...] = ()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclasses.dataclass(frozen=True)
|
|
79
|
+
class Adapter:
|
|
80
|
+
model: type
|
|
81
|
+
fields: tuple[FieldSpec, ...]
|
|
82
|
+
constraints: tuple[tuple[str, Expr], ...] # (label, rule) for explaining contradictions
|
|
83
|
+
validators: tuple[ValidatorSpec, ...]
|
|
84
|
+
unsupported: tuple[str, ...]
|
|
85
|
+
|
|
86
|
+
def construct(self, values: dict[str, Any]) -> Any:
|
|
87
|
+
"""Run the model's real constructor, including its original validators."""
|
|
88
|
+
inputs = {
|
|
89
|
+
field.input_name or field.name: values[field.name]
|
|
90
|
+
for field in self.fields
|
|
91
|
+
if field.name in values
|
|
92
|
+
}
|
|
93
|
+
return self.model(**inputs)
|
|
94
|
+
|
|
95
|
+
def check_fixed(self, values: dict[str, Any]) -> None:
|
|
96
|
+
"""The public API takes canonical field names and exact Python types."""
|
|
97
|
+
fields = {field.name: field for field in self.fields}
|
|
98
|
+
unknown = values.keys() - fields.keys()
|
|
99
|
+
if unknown:
|
|
100
|
+
raise ValueError(f"Unknown field(s): {', '.join(sorted(unknown))}")
|
|
101
|
+
for name, value in values.items():
|
|
102
|
+
domain = fields[name].domain
|
|
103
|
+
if domain is not None and not domain.accepts(value):
|
|
104
|
+
expected = domain.kind + (" or None" if domain.nullable else "")
|
|
105
|
+
raise TypeError(
|
|
106
|
+
f"Fixed field {name!r} requires a canonical {expected} value, got {value!r}"
|
|
107
|
+
)
|
|
108
|
+
missing = [
|
|
109
|
+
field.name
|
|
110
|
+
for field in self.fields
|
|
111
|
+
if field.domain is None and field.required and field.name not in values
|
|
112
|
+
]
|
|
113
|
+
if missing:
|
|
114
|
+
raise UnsupportedConstraint(
|
|
115
|
+
f"modelfill cannot generate {', '.join(missing)}; pass values for them"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _domain(annotation: Any) -> Domain | None:
|
|
120
|
+
"""The solver domain of a field type, or None for an external field."""
|
|
121
|
+
if annotation in PRIMITIVES:
|
|
122
|
+
return PRIMITIVES[annotation]
|
|
123
|
+
arguments = get_args(annotation)
|
|
124
|
+
if get_origin(annotation) in (Union, types.UnionType) and type(None) in arguments:
|
|
125
|
+
options = [option for option in arguments if option is not type(None)]
|
|
126
|
+
inner = _domain(options[0]) if len(options) == 1 else None
|
|
127
|
+
return None if inner is None else dataclasses.replace(inner, nullable=True)
|
|
128
|
+
if inspect.isclass(annotation) and issubclass(annotation, Enum) and plain_enum(annotation):
|
|
129
|
+
return Domain("enum", tuple(annotation)) if len(annotation) else None
|
|
130
|
+
if get_origin(annotation) is Literal:
|
|
131
|
+
values = tuple(choice for choice in arguments if choice is not None)
|
|
132
|
+
homogeneous = values and all(type(x) is type(values[0]) for x in values)
|
|
133
|
+
if homogeneous and all(encodable(value) for value in values):
|
|
134
|
+
base = _domain(type(values[0]))
|
|
135
|
+
if base is not None:
|
|
136
|
+
return Domain(base.kind, values, nullable=len(values) < len(arguments))
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_SYMBOLS = {Gt: ">", Ge: ">=", Lt: "<", Le: "<="}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _metadata_constraints(
|
|
144
|
+
name: str, domain: Domain, metadata: list[Any], unsupported: list[str]
|
|
145
|
+
) -> list[tuple[str, Expr]]:
|
|
146
|
+
"""A field's own constraints, as (label, rule) pairs."""
|
|
147
|
+
result: list[tuple[str, Expr]] = []
|
|
148
|
+
expression = var(name, domain)
|
|
149
|
+
for item in metadata:
|
|
150
|
+
if isinstance(item, Strict):
|
|
151
|
+
continue # All generated/fixed inputs already have their canonical type.
|
|
152
|
+
if isinstance(item, (Gt, Ge, Lt, Le)) and domain.kind in TIME_TYPES:
|
|
153
|
+
operation = type(item).__name__.lower()
|
|
154
|
+
bound = getattr(item, operation)
|
|
155
|
+
if type(bound) is not TIME_TYPES[domain.kind] or getattr(bound, "tzinfo", None):
|
|
156
|
+
unsupported.append(f"Field {name!r}: unsupported {domain.kind} bound {bound!r}")
|
|
157
|
+
continue
|
|
158
|
+
label = f"{name} {_SYMBOLS[type(item)]} {bound!r}"
|
|
159
|
+
result.append((label, node(operation, expression, const(bound))))
|
|
160
|
+
elif isinstance(item, MultipleOf) and domain.kind == "int":
|
|
161
|
+
step = item.multiple_of
|
|
162
|
+
if type(step) is not int or step < 1:
|
|
163
|
+
unsupported.append(f"Field {name!r}: unsupported multiple_of {step!r}")
|
|
164
|
+
continue
|
|
165
|
+
remainder = node("mod", expression, const(step), sort=INT)
|
|
166
|
+
result.append((f"{name} % {step} == 0", node("eq", remainder, const(0))))
|
|
167
|
+
elif isinstance(item, (Gt, Ge, Lt, Le)) and domain.kind == "int":
|
|
168
|
+
bound = getattr(item, type(item).__name__.lower())
|
|
169
|
+
if type(bound) not in (int, float) or (
|
|
170
|
+
type(bound) is float and not math.isfinite(bound)
|
|
171
|
+
):
|
|
172
|
+
unsupported.append(f"Field {name!r}: unsupported numeric bound {bound!r}")
|
|
173
|
+
continue
|
|
174
|
+
if isinstance(item, Gt):
|
|
175
|
+
operation, value = "ge", math.floor(bound) + 1
|
|
176
|
+
elif isinstance(item, Ge):
|
|
177
|
+
operation, value = "ge", math.ceil(bound)
|
|
178
|
+
elif isinstance(item, Lt):
|
|
179
|
+
operation, value = "le", math.ceil(bound) - 1
|
|
180
|
+
else:
|
|
181
|
+
operation, value = "le", math.floor(bound)
|
|
182
|
+
label = f"{name} {_SYMBOLS[type(item)]} {bound!r}"
|
|
183
|
+
result.append((label, node(operation, expression, const(value))))
|
|
184
|
+
elif isinstance(item, (MinLen, MaxLen)) and domain.kind == "str":
|
|
185
|
+
minimum = isinstance(item, MinLen)
|
|
186
|
+
value = item.min_length if minimum else item.max_length
|
|
187
|
+
if type(value) is not int:
|
|
188
|
+
unsupported.append(f"Field {name!r}: unsupported length bound {value!r}")
|
|
189
|
+
continue
|
|
190
|
+
symbol, operation = (">=", "ge") if minimum else ("<=", "le")
|
|
191
|
+
length = node("len", expression, sort=INT)
|
|
192
|
+
result.append((f"len({name}) {symbol} {value}", node(operation, length, const(value))))
|
|
193
|
+
else:
|
|
194
|
+
unsupported.append(f"Field {name!r}: unsupported metadata {item!r}")
|
|
195
|
+
if domain.nullable: # Pydantic applies these constraints to non-None values only.
|
|
196
|
+
return [(label, node("or", node("isnone", expression), rule)) for label, rule in result]
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _pydantic_validators(
|
|
201
|
+
model: type[BaseModel], names: tuple[str, ...], unsupported: list[str]
|
|
202
|
+
) -> tuple[ValidatorSpec, ...]:
|
|
203
|
+
"""Validators from Pydantic's private decorator registry, isolated here."""
|
|
204
|
+
decorators = getattr(model, "__pydantic_decorators__", None)
|
|
205
|
+
if decorators is None:
|
|
206
|
+
unsupported.append("Pydantic decorator registry is unavailable")
|
|
207
|
+
return ()
|
|
208
|
+
validators = []
|
|
209
|
+
for name, decorator in decorators.model_validators.items():
|
|
210
|
+
validators.append(ValidatorSpec(name, decorator.func, "model", decorator.info.mode))
|
|
211
|
+
for name, decorator in decorators.field_validators.items():
|
|
212
|
+
targets = decorator.info.fields
|
|
213
|
+
for index, field_name in enumerate(names):
|
|
214
|
+
if field_name in targets or "*" in targets:
|
|
215
|
+
validators.append(
|
|
216
|
+
ValidatorSpec(
|
|
217
|
+
name,
|
|
218
|
+
decorator.func,
|
|
219
|
+
"field",
|
|
220
|
+
decorator.info.mode,
|
|
221
|
+
field_name,
|
|
222
|
+
names[:index],
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
if decorators.validators or decorators.root_validators:
|
|
226
|
+
unsupported.append("Legacy Pydantic v1-style validators are unsupported")
|
|
227
|
+
return tuple(validators)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Configuration that cannot transform canonical values or bypass validation.
|
|
231
|
+
_SAFE_CONFIG = {
|
|
232
|
+
"title",
|
|
233
|
+
"model_title_generator",
|
|
234
|
+
"field_title_generator",
|
|
235
|
+
"extra",
|
|
236
|
+
"frozen",
|
|
237
|
+
"populate_by_name",
|
|
238
|
+
"validate_by_alias",
|
|
239
|
+
"validate_by_name",
|
|
240
|
+
"loc_by_alias",
|
|
241
|
+
"alias_generator",
|
|
242
|
+
"validate_assignment",
|
|
243
|
+
"arbitrary_types_allowed",
|
|
244
|
+
"from_attributes",
|
|
245
|
+
"ignored_types",
|
|
246
|
+
"json_schema_extra",
|
|
247
|
+
"json_encoders",
|
|
248
|
+
"strict",
|
|
249
|
+
"revalidate_instances",
|
|
250
|
+
"validate_default",
|
|
251
|
+
"protected_namespaces",
|
|
252
|
+
"hide_input_in_errors",
|
|
253
|
+
"defer_build",
|
|
254
|
+
"schema_generator",
|
|
255
|
+
"json_schema_serialization_defaults_required",
|
|
256
|
+
"json_schema_mode_override",
|
|
257
|
+
"validation_error_cause",
|
|
258
|
+
"use_attribute_docstrings",
|
|
259
|
+
"cache_strings",
|
|
260
|
+
"serialize_by_alias",
|
|
261
|
+
"url_preserve_empty_path",
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _pydantic(model: type[BaseModel]) -> Adapter:
|
|
266
|
+
unsupported: list[str] = []
|
|
267
|
+
constraints: list[tuple[str, Expr]] = []
|
|
268
|
+
fields = []
|
|
269
|
+
config = model.model_config
|
|
270
|
+
for key, value in config.items():
|
|
271
|
+
if key in ("str_min_length", "str_max_length") or key in _SAFE_CONFIG:
|
|
272
|
+
continue
|
|
273
|
+
if key in ("str_to_lower", "str_to_upper", "str_strip_whitespace", "use_enum_values"):
|
|
274
|
+
if not value:
|
|
275
|
+
continue
|
|
276
|
+
unsupported.append(f"Unsupported Pydantic configuration: {key}={value!r}")
|
|
277
|
+
if model.__init__ is not BaseModel.__init__:
|
|
278
|
+
unsupported.append("Custom Pydantic __init__ is unsupported")
|
|
279
|
+
if model.__new__ is not BaseModel.__new__:
|
|
280
|
+
unsupported.append("Custom Pydantic __new__ is unsupported")
|
|
281
|
+
for hook in ("__getattribute__", "__getattr__", "__setattr__"):
|
|
282
|
+
if getattr(model, hook, None) is not getattr(BaseModel, hook, None):
|
|
283
|
+
unsupported.append(f"Custom Pydantic {hook} is unsupported")
|
|
284
|
+
if type(model).__call__ is not type(BaseModel).__call__:
|
|
285
|
+
unsupported.append("Custom Pydantic metaclass __call__ is unsupported")
|
|
286
|
+
# Pydantic installs init_private_attributes for PrivateAttr; it never touches fields.
|
|
287
|
+
if model.model_post_init not in (BaseModel.model_post_init, init_private_attributes):
|
|
288
|
+
unsupported.append("Pydantic model_post_init is unsupported")
|
|
289
|
+
for parent in model.__mro__:
|
|
290
|
+
if "__get_pydantic_core_schema__" in parent.__dict__:
|
|
291
|
+
if parent is not BaseModel:
|
|
292
|
+
unsupported.append("Custom Pydantic core schema is unsupported")
|
|
293
|
+
break
|
|
294
|
+
validators = _pydantic_validators(model, tuple(model.model_fields), unsupported)
|
|
295
|
+
# A field validator could reject a default that the field's constraints accept.
|
|
296
|
+
validated = {validator.field_name for validator in validators if validator.field_name}
|
|
297
|
+
# Validation aliases do not apply when validate_by_alias=False.
|
|
298
|
+
by_alias = (
|
|
299
|
+
config.get("validate_by_alias", True)
|
|
300
|
+
if "validate_by_alias" in ConfigDict.__annotations__
|
|
301
|
+
else True
|
|
302
|
+
)
|
|
303
|
+
for name, info in model.model_fields.items():
|
|
304
|
+
domain = _domain(info.annotation)
|
|
305
|
+
alias = info.validation_alias
|
|
306
|
+
if alias is not None and not isinstance(alias, str):
|
|
307
|
+
raise UnsupportedConstraint(
|
|
308
|
+
f"Field {name!r}: complex validation aliases are unsupported"
|
|
309
|
+
)
|
|
310
|
+
input_name = alias if by_alias else name
|
|
311
|
+
factory = info.default_factory
|
|
312
|
+
if info.default_factory_takes_validated_data: # None without a factory.
|
|
313
|
+
unsupported.append(f"Field {name!r}: data-dependent default factory is unsupported")
|
|
314
|
+
factory = None
|
|
315
|
+
default = MISSING if info.default is PydanticUndefined else info.default
|
|
316
|
+
validates_default = (
|
|
317
|
+
info.validate_default
|
|
318
|
+
if info.validate_default is not None
|
|
319
|
+
else config.get("validate_default", False)
|
|
320
|
+
)
|
|
321
|
+
rules: list[tuple[str, Expr]] = []
|
|
322
|
+
if domain is not None: # Pydantic itself validates values of external fields.
|
|
323
|
+
metadata = list(info.metadata)
|
|
324
|
+
if domain.kind == "str" and domain.choices is None:
|
|
325
|
+
# A field's own length limit overrides the global configuration.
|
|
326
|
+
shortest, longest = config.get("str_min_length"), config.get("str_max_length")
|
|
327
|
+
if shortest is not None and not any(isinstance(m, MinLen) for m in metadata):
|
|
328
|
+
metadata.append(MinLen(shortest))
|
|
329
|
+
if longest is not None and not any(isinstance(m, MaxLen) for m in metadata):
|
|
330
|
+
metadata.append(MaxLen(longest))
|
|
331
|
+
rules = _metadata_constraints(name, domain, metadata, unsupported)
|
|
332
|
+
constraints.extend(rules)
|
|
333
|
+
# Pydantic skips field validation for an omitted default; that only matters
|
|
334
|
+
# when the validation would have rejected the default.
|
|
335
|
+
passes = (
|
|
336
|
+
domain is not None
|
|
337
|
+
and default is not MISSING
|
|
338
|
+
and name not in validated
|
|
339
|
+
and domain.accepts(default)
|
|
340
|
+
and encodable(default)
|
|
341
|
+
and all(evaluate(rule, {name: default}) for _, rule in rules)
|
|
342
|
+
)
|
|
343
|
+
unchecked = not info.is_required() and not validates_default and not passes
|
|
344
|
+
fields.append(
|
|
345
|
+
FieldSpec(
|
|
346
|
+
name,
|
|
347
|
+
domain,
|
|
348
|
+
default,
|
|
349
|
+
factory,
|
|
350
|
+
input_name,
|
|
351
|
+
unchecked,
|
|
352
|
+
info.is_required(),
|
|
353
|
+
info.annotation,
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
inputs = [field.input_name or field.name for field in fields]
|
|
357
|
+
if len(set(inputs)) != len(inputs):
|
|
358
|
+
raise UnsupportedConstraint("Multiple fields share the same validation alias")
|
|
359
|
+
return Adapter(model, tuple(fields), tuple(constraints), validators, tuple(unsupported))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _dataclass(model: type[DataclassInstance]) -> Adapter:
|
|
363
|
+
unsupported: list[str] = []
|
|
364
|
+
try:
|
|
365
|
+
annotations = get_type_hints(model)
|
|
366
|
+
except (NameError, TypeError) as exc:
|
|
367
|
+
raise UnsupportedConstraint(f"Cannot resolve dataclass annotations: {exc}") from exc
|
|
368
|
+
if any(isinstance(annotation, dataclasses.InitVar) for annotation in annotations.values()):
|
|
369
|
+
raise UnsupportedConstraint("Dataclass InitVar fields are unsupported")
|
|
370
|
+
initializer = model.__init__
|
|
371
|
+
if not hasattr(initializer, "__code__") or initializer.__code__.co_filename != "<string>":
|
|
372
|
+
unsupported.append("Custom dataclass __init__ is unsupported")
|
|
373
|
+
if model.__new__ is not object.__new__:
|
|
374
|
+
unsupported.append("Custom dataclass __new__ is unsupported")
|
|
375
|
+
for hook in ("__getattribute__", "__getattr__"):
|
|
376
|
+
if getattr(model, hook, None) is not getattr(object, hook, None):
|
|
377
|
+
unsupported.append(f"Custom dataclass {hook} is unsupported")
|
|
378
|
+
if type(model).__call__ is not type.__call__:
|
|
379
|
+
unsupported.append("Custom dataclass metaclass __call__ is unsupported")
|
|
380
|
+
frozen = model.__dataclass_params__.frozen # ty: ignore[unresolved-attribute]
|
|
381
|
+
if model.__setattr__ is not object.__setattr__ and not frozen:
|
|
382
|
+
unsupported.append("Custom dataclass __setattr__ is unsupported")
|
|
383
|
+
fields = []
|
|
384
|
+
for info in dataclasses.fields(model):
|
|
385
|
+
if not info.init:
|
|
386
|
+
raise UnsupportedConstraint(f"Dataclass init=False field {info.name!r} is unsupported")
|
|
387
|
+
annotation = annotations[info.name]
|
|
388
|
+
domain = _domain(annotation)
|
|
389
|
+
factory = None if info.default_factory is MISSING else info.default_factory
|
|
390
|
+
required = info.default is MISSING and factory is None
|
|
391
|
+
fields.append(
|
|
392
|
+
FieldSpec(
|
|
393
|
+
info.name, domain, info.default, factory, required=required, annotation=annotation
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
descriptor = inspect.getattr_static(model, info.name, None)
|
|
397
|
+
if hasattr(descriptor, "__set__") and not isinstance(
|
|
398
|
+
descriptor, types.MemberDescriptorType
|
|
399
|
+
):
|
|
400
|
+
unsupported.append(f"Dataclass field {info.name!r} uses a custom descriptor")
|
|
401
|
+
post_init = getattr(model, "__post_init__", None)
|
|
402
|
+
validators = (
|
|
403
|
+
(ValidatorSpec("__post_init__", post_init, "dataclass", "after"),)
|
|
404
|
+
if post_init is not None
|
|
405
|
+
else ()
|
|
406
|
+
)
|
|
407
|
+
return Adapter(model, tuple(fields), (), validators, tuple(unsupported))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def introspect(model: type) -> Adapter:
|
|
411
|
+
"""Discover supported domains and all known validation hooks conservatively."""
|
|
412
|
+
if not inspect.isclass(model):
|
|
413
|
+
raise TypeError("modelfill expects a model class, not an instance")
|
|
414
|
+
if issubclass(model, BaseModel):
|
|
415
|
+
return _pydantic(model)
|
|
416
|
+
if dataclasses.is_dataclass(model):
|
|
417
|
+
if hasattr(model, "__pydantic_validator__"):
|
|
418
|
+
raise UnsupportedConstraint(
|
|
419
|
+
"Pydantic dataclasses are unsupported; use a standard dataclass"
|
|
420
|
+
)
|
|
421
|
+
return _dataclass(model)
|
|
422
|
+
raise TypeError("modelfill supports Pydantic BaseModel and standard dataclass classes")
|