fastschema 0.1.3__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.
- fastschema/__init__.py +19 -0
- fastschema/core/__init__.py +13 -0
- fastschema/core/field_config.py +49 -0
- fastschema/core/metaclass.py +96 -0
- fastschema/core/model_factory.py +189 -0
- fastschema/core/route_base.py +70 -0
- fastschema/core/route_decorator.py +70 -0
- fastschema/core/route_field.py +135 -0
- fastschema/core/self_derived.py +27 -0
- fastschema-0.1.3.dist-info/METADATA +874 -0
- fastschema-0.1.3.dist-info/RECORD +32 -0
- fastschema-0.1.3.dist-info/WHEEL +5 -0
- fastschema-0.1.3.dist-info/licenses/LICENSE +201 -0
- fastschema-0.1.3.dist-info/top_level.txt +1 -0
- modelrouter/__init__.py +19 -0
- modelrouter/core/__init__.py +13 -0
- modelrouter/core/field_config.py +49 -0
- modelrouter/core/metaclass.py +96 -0
- modelrouter/core/model_factory.py +189 -0
- modelrouter/core/route_base.py +70 -0
- modelrouter/core/route_decorator.py +70 -0
- modelrouter/core/route_field.py +135 -0
- modelrouter/core/self_derived.py +27 -0
- routex/__init__.py +19 -0
- routex/core/__init__.py +13 -0
- routex/core/field_config.py +49 -0
- routex/core/metaclass.py +96 -0
- routex/core/model_factory.py +189 -0
- routex/core/route_base.py +70 -0
- routex/core/route_decorator.py +70 -0
- routex/core/route_field.py +135 -0
- routex/core/self_derived.py +27 -0
fastschema/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
framework - Class-based routing with dynamic Pydantic model generation for FastAPI.
|
|
3
|
+
|
|
4
|
+
Routes defined inside the class with @route decorator.
|
|
5
|
+
Typed input via UserRoute.schema() — request body auto-validated.
|
|
6
|
+
SelfDerivedModel for bulk operations — derive schemas from own fields.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .core import (
|
|
10
|
+
FieldConfig, Chain, RouteField, FieldInfo, SelfDerivedModel,
|
|
11
|
+
RouteBase, ModelFactory, RouteMetaclass,
|
|
12
|
+
route, route_factory,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
'FieldConfig', 'Chain', 'RouteField', 'FieldInfo', 'SelfDerivedModel',
|
|
17
|
+
'RouteBase', 'ModelFactory', 'RouteMetaclass',
|
|
18
|
+
'route', 'route_factory',
|
|
19
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .field_config import FieldConfig, Chain
|
|
2
|
+
from .route_field import RouteField, FieldInfo
|
|
3
|
+
from .self_derived import SelfDerivedModel
|
|
4
|
+
from .route_base import RouteBase
|
|
5
|
+
from .model_factory import ModelFactory
|
|
6
|
+
from .metaclass import RouteMetaclass
|
|
7
|
+
from .route_decorator import route, route_factory
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'FieldConfig', 'Chain', 'RouteField', 'FieldInfo', 'SelfDerivedModel',
|
|
11
|
+
'RouteBase', 'ModelFactory', 'RouteMetaclass',
|
|
12
|
+
'route', 'route_factory',
|
|
13
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""FieldConfig - Base class for schema type configurations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_UNSET = object()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Chain:
|
|
11
|
+
"""Compose multiple functions to run sequentially on a field value."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, *funcs: Callable):
|
|
14
|
+
self.funcs = funcs
|
|
15
|
+
|
|
16
|
+
def __call__(self, v: Any) -> Any:
|
|
17
|
+
result = v
|
|
18
|
+
for func in self.funcs:
|
|
19
|
+
result = func(result)
|
|
20
|
+
return result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FieldConfig:
|
|
24
|
+
"""Extend to define schema types."""
|
|
25
|
+
|
|
26
|
+
required: bool = False
|
|
27
|
+
default: Any = _UNSET
|
|
28
|
+
default_factory: Callable | None = None
|
|
29
|
+
alias: str | None = None
|
|
30
|
+
description: str | None = None
|
|
31
|
+
type_override: type | None = None
|
|
32
|
+
exclude: bool = False
|
|
33
|
+
frozen: bool = False
|
|
34
|
+
apply_func: Callable | None = None
|
|
35
|
+
before: bool = True # True = before validators, False = after validators
|
|
36
|
+
metadata: dict | None = None
|
|
37
|
+
|
|
38
|
+
def __init__(self, **overrides):
|
|
39
|
+
for key, value in overrides.items():
|
|
40
|
+
if hasattr(self, key) or key in self.__class__.__dict__:
|
|
41
|
+
setattr(self, key, value)
|
|
42
|
+
|
|
43
|
+
def __repr__(self):
|
|
44
|
+
attrs = {k: v for k, v in self.__dict__.items() if v is not _UNSET and v is not None and v is not False}
|
|
45
|
+
return f"{self.__name__}({attrs})"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def __name__(self):
|
|
49
|
+
return self.__class__.__name__
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""RouteMetaclass - Collects fields and discovers schema types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import sys
|
|
5
|
+
import typing
|
|
6
|
+
from typing import get_type_hints, ClassVar, get_origin, get_args
|
|
7
|
+
from .route_field import RouteField, FieldInfo
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from sqlmodel.main import SQLModelMetaclass as _BaseMetaclass
|
|
11
|
+
except ImportError:
|
|
12
|
+
from pydantic._internal._model_construction import ModelMetaclass as _BaseMetaclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve_annotations(cls: type) -> dict[str, type]:
|
|
16
|
+
try:
|
|
17
|
+
module = sys.modules.get(cls.__module__)
|
|
18
|
+
if not module:
|
|
19
|
+
return {}
|
|
20
|
+
return get_type_hints(cls, localns=vars(module))
|
|
21
|
+
except Exception:
|
|
22
|
+
return getattr(cls, '__annotations__', {})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _has_pydantic_base(bases: tuple) -> bool:
|
|
26
|
+
for base in bases:
|
|
27
|
+
if getattr(base, '__pydantic_complete__', False):
|
|
28
|
+
return True
|
|
29
|
+
if getattr(base, '__pydantic_validator__', None) is not None:
|
|
30
|
+
return True
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_classvar(annotation) -> bool:
|
|
35
|
+
origin = get_origin(annotation)
|
|
36
|
+
if origin is ClassVar:
|
|
37
|
+
return True
|
|
38
|
+
if isinstance(annotation, str):
|
|
39
|
+
return 'ClassVar' in annotation
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _unwrap_classvar(annotation):
|
|
44
|
+
if _is_classvar(annotation):
|
|
45
|
+
args = get_args(annotation)
|
|
46
|
+
if args:
|
|
47
|
+
return args[0]
|
|
48
|
+
return str
|
|
49
|
+
return annotation
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RouteMetaclass(_BaseMetaclass):
|
|
53
|
+
def __new__(mcs, cls_name, bases, namespace, **kwargs):
|
|
54
|
+
if _has_pydantic_base(bases):
|
|
55
|
+
cls = super().__new__(mcs, cls_name, bases, namespace, **kwargs)
|
|
56
|
+
else:
|
|
57
|
+
cls = type.__new__(mcs, cls_name, bases, namespace)
|
|
58
|
+
|
|
59
|
+
fields = {}
|
|
60
|
+
for base in bases:
|
|
61
|
+
bf = getattr(base, '_fields', None)
|
|
62
|
+
if bf:
|
|
63
|
+
fields.update(bf)
|
|
64
|
+
|
|
65
|
+
resolved = _resolve_annotations(cls)
|
|
66
|
+
|
|
67
|
+
# Collect RouteField entries
|
|
68
|
+
for attr_name, annotation in resolved.items():
|
|
69
|
+
attr_value = namespace.get(attr_name)
|
|
70
|
+
if isinstance(attr_value, RouteField):
|
|
71
|
+
# Unwrap ClassVar[str] -> str for the schema
|
|
72
|
+
field_type = _unwrap_classvar(annotation) if _is_classvar(annotation) else annotation
|
|
73
|
+
fields[attr_name] = attr_value.to_field_info(attr_name, field_type)
|
|
74
|
+
|
|
75
|
+
# Collect ClassVar fields (not RouteField, but user wants them in schemas)
|
|
76
|
+
for attr_name, annotation in resolved.items():
|
|
77
|
+
if attr_name in fields:
|
|
78
|
+
continue
|
|
79
|
+
if _is_classvar(annotation) and attr_name in namespace:
|
|
80
|
+
inner_type = _unwrap_classvar(annotation)
|
|
81
|
+
field_info = FieldInfo(
|
|
82
|
+
name=attr_name,
|
|
83
|
+
annotation=inner_type,
|
|
84
|
+
default=namespace.get(attr_name, None),
|
|
85
|
+
configs={},
|
|
86
|
+
)
|
|
87
|
+
fields[attr_name] = field_info
|
|
88
|
+
|
|
89
|
+
cls._fields = fields
|
|
90
|
+
|
|
91
|
+
schema_types = set()
|
|
92
|
+
for info in fields.values():
|
|
93
|
+
schema_types.update(info.configs.keys())
|
|
94
|
+
cls._schema_types = schema_types
|
|
95
|
+
|
|
96
|
+
return cls
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""ModelFactory - Generates Pydantic models from FieldInfo + schema type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from types import NoneType
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from pydantic import BaseModel, Field, create_model, ConfigDict
|
|
7
|
+
from pydantic import field_validator as pv_field_validator
|
|
8
|
+
from pydantic import model_validator as pv_model_validator
|
|
9
|
+
from .route_field import FieldInfo
|
|
10
|
+
from .self_derived import SelfDerivedModel
|
|
11
|
+
from .field_config import _UNSET
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelFactory:
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def create(fields, schema_type, model_name="GeneratedModel",
|
|
18
|
+
forbid_extra=True, include_fields=None, exclude_fields=None,
|
|
19
|
+
as_literal=False, route_cls=None) -> type[BaseModel]:
|
|
20
|
+
skip = set(exclude_fields or [])
|
|
21
|
+
only = set(include_fields) if include_fields else None
|
|
22
|
+
|
|
23
|
+
if as_literal:
|
|
24
|
+
names = [n for n, info in fields.items()
|
|
25
|
+
if n not in skip and (only is None or n in only)
|
|
26
|
+
and info.has_config(schema_type)
|
|
27
|
+
and not info.get_config(schema_type).exclude]
|
|
28
|
+
return Literal[tuple(names)]
|
|
29
|
+
|
|
30
|
+
model_fields = {}
|
|
31
|
+
apply_funcs = {}
|
|
32
|
+
for name, info in fields.items():
|
|
33
|
+
if name in skip or (only is not None and name not in only):
|
|
34
|
+
continue
|
|
35
|
+
config = info.get_config(schema_type)
|
|
36
|
+
if config is None or config.exclude:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
ftype = config.type_override or info.annotation
|
|
40
|
+
|
|
41
|
+
if isinstance(ftype, str):
|
|
42
|
+
import typing
|
|
43
|
+
ftype = typing._eval_type(typing.ForwardRef(ftype), globals(), None)
|
|
44
|
+
|
|
45
|
+
fdefault = ModelFactory._resolve_default(config, info)
|
|
46
|
+
|
|
47
|
+
# Resolve SelfDerivedModel
|
|
48
|
+
if isinstance(fdefault, SelfDerivedModel) and route_cls:
|
|
49
|
+
ftype, fdefault = ModelFactory._resolve_self_derived(fdefault, route_cls)
|
|
50
|
+
|
|
51
|
+
if fdefault is not None and fdefault is not ...:
|
|
52
|
+
if NoneType not in (getattr(ftype, '__args__', ()) or ()):
|
|
53
|
+
ftype = ftype | None
|
|
54
|
+
|
|
55
|
+
# Start with RouteField's Pydantic Field kwargs
|
|
56
|
+
fk = dict(info.field_info_kwargs) if info.field_info_kwargs else {}
|
|
57
|
+
|
|
58
|
+
# Remove 'default' from kwargs — we pass it as positional arg
|
|
59
|
+
fk.pop('default', None)
|
|
60
|
+
fk.pop('default_factory', None)
|
|
61
|
+
|
|
62
|
+
# Config-level overrides take precedence
|
|
63
|
+
if info.alias and 'alias' not in fk:
|
|
64
|
+
fk['alias'] = info.alias
|
|
65
|
+
desc = config.description or info.description
|
|
66
|
+
if desc and 'description' not in fk:
|
|
67
|
+
fk['description'] = desc
|
|
68
|
+
if config.frozen and 'frozen' not in fk:
|
|
69
|
+
fk['frozen'] = True
|
|
70
|
+
if config.metadata:
|
|
71
|
+
fk.update(config.metadata)
|
|
72
|
+
|
|
73
|
+
# Pass constraint kwargs (max_length, min_length, gt, etc.) from RouteField
|
|
74
|
+
field_metadata = info.metadata if hasattr(info, 'metadata') and info.metadata else None
|
|
75
|
+
if field_metadata and isinstance(field_metadata, dict):
|
|
76
|
+
fk.update(field_metadata)
|
|
77
|
+
|
|
78
|
+
model_fields[name] = (ftype, Field(fdefault, **fk))
|
|
79
|
+
|
|
80
|
+
if config.apply_func is not None:
|
|
81
|
+
func = config.apply_func
|
|
82
|
+
if hasattr(func, '__func__'):
|
|
83
|
+
func = func.__func__
|
|
84
|
+
apply_funcs[name] = (func, 'before' if config.before else 'after')
|
|
85
|
+
|
|
86
|
+
validator_base = ModelFactory._create_validator_base(route_cls, fields, schema_type, apply_funcs)
|
|
87
|
+
model_module = route_cls.__module__ if route_cls else __name__
|
|
88
|
+
|
|
89
|
+
if validator_base:
|
|
90
|
+
model = create_model(
|
|
91
|
+
model_name,
|
|
92
|
+
__base__=validator_base,
|
|
93
|
+
__config__=ConfigDict(extra='forbid' if forbid_extra else 'ignore'),
|
|
94
|
+
__module__=model_module,
|
|
95
|
+
**model_fields,
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
model = create_model(
|
|
99
|
+
model_name,
|
|
100
|
+
__config__=ConfigDict(extra='forbid' if forbid_extra else 'ignore'),
|
|
101
|
+
__module__=model_module,
|
|
102
|
+
**model_fields,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
model.model_rebuild(force=True)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
return model
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _create_validator_base(route_cls, fields, schema_type, apply_funcs=None):
|
|
114
|
+
"""Create a base class with validators from route_cls and apply_func."""
|
|
115
|
+
apply_funcs = apply_funcs or {}
|
|
116
|
+
namespace = {}
|
|
117
|
+
|
|
118
|
+
# Collect validators from route_cls
|
|
119
|
+
if route_cls and getattr(route_cls, '__pydantic_complete__', False):
|
|
120
|
+
decorators = getattr(route_cls, '__pydantic_decorators__', None)
|
|
121
|
+
if decorators:
|
|
122
|
+
for name, dec in decorators.field_validators.items():
|
|
123
|
+
func = getattr(route_cls, name, None)
|
|
124
|
+
if func is None:
|
|
125
|
+
continue
|
|
126
|
+
raw_func = getattr(func, '__func__', func)
|
|
127
|
+
mode = dec.info.mode or 'before'
|
|
128
|
+
for field_name in dec.info.fields:
|
|
129
|
+
validator_name = f'_validator_{field_name}_{mode}'
|
|
130
|
+
namespace[validator_name] = pv_field_validator(field_name, mode=mode, check_fields=False)(raw_func)
|
|
131
|
+
|
|
132
|
+
for name, dec in decorators.model_validators.items():
|
|
133
|
+
if name == 'fill_back_refs':
|
|
134
|
+
continue
|
|
135
|
+
func = getattr(route_cls, name, None)
|
|
136
|
+
if func is None:
|
|
137
|
+
continue
|
|
138
|
+
raw_func = getattr(func, '__func__', func)
|
|
139
|
+
namespace[f'_model_validator_{name}'] = pv_model_validator(mode=dec.info.mode)(raw_func)
|
|
140
|
+
|
|
141
|
+
# Create validators from apply_func
|
|
142
|
+
for field_name, (func, mode) in apply_funcs.items():
|
|
143
|
+
validator_name = f'_apply_func_{field_name}_{mode}'
|
|
144
|
+
def _make_validator(f):
|
|
145
|
+
def validator(cls, v):
|
|
146
|
+
return f(v)
|
|
147
|
+
return validator
|
|
148
|
+
v = _make_validator(func)
|
|
149
|
+
v.__qualname__ = validator_name
|
|
150
|
+
namespace[validator_name] = pv_field_validator(field_name, mode=mode, check_fields=False)(v)
|
|
151
|
+
|
|
152
|
+
if namespace:
|
|
153
|
+
base_name = f'{route_cls.__name__}Validators' if route_cls else 'Validators'
|
|
154
|
+
return type(base_name, (BaseModel,), namespace)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _resolve_default(config, info):
|
|
159
|
+
if config.default is not _UNSET:
|
|
160
|
+
return config.default
|
|
161
|
+
if config.default_factory is not None:
|
|
162
|
+
return config.default_factory
|
|
163
|
+
if info.default is not _UNSET:
|
|
164
|
+
return info.default
|
|
165
|
+
if info.default_factory is not None:
|
|
166
|
+
return info.default_factory
|
|
167
|
+
if config.required:
|
|
168
|
+
return ...
|
|
169
|
+
return ...
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _resolve_self_derived(sdm, route_cls):
|
|
173
|
+
derived = route_cls.schema(
|
|
174
|
+
sdm.schema,
|
|
175
|
+
include_fields=sdm.include_fields,
|
|
176
|
+
exclude_fields=sdm.exclude_fields,
|
|
177
|
+
)
|
|
178
|
+
field_type = list[derived]
|
|
179
|
+
default = [] if not sdm.is_optional else None
|
|
180
|
+
return field_type, default
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def field_names(fields, schema_type, include=None, exclude=None):
|
|
184
|
+
skip = set(exclude or [])
|
|
185
|
+
only = set(include) if include else None
|
|
186
|
+
return [n for n, info in fields.items()
|
|
187
|
+
if n not in skip and (only is None or n in only)
|
|
188
|
+
and info.has_config(schema_type)
|
|
189
|
+
and not info.get_config(schema_type).exclude]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""RouteBase - Base class with schema generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
from .metaclass import RouteMetaclass
|
|
6
|
+
from .route_field import FieldInfo, RouteField
|
|
7
|
+
from .field_config import FieldConfig
|
|
8
|
+
from .model_factory import ModelFactory
|
|
9
|
+
from .self_derived import SelfDerivedModel
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RouteBase(BaseModel,metaclass=RouteMetaclass):
|
|
14
|
+
"""Base class. Routes defined inside with @route decorator.
|
|
15
|
+
|
|
16
|
+
class UserRoute(RouteBase):
|
|
17
|
+
name: str = RouteField(add=Add(), edit=Edit())
|
|
18
|
+
|
|
19
|
+
@route(path="/users", method="POST")
|
|
20
|
+
async def create_user(cls, request, data: UserRoute.schema("add")):
|
|
21
|
+
...
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
_fields: ClassVar[dict[str, FieldInfo]] = {}
|
|
25
|
+
_schema_types: ClassVar[set[str]] = set()
|
|
26
|
+
_route_group: ClassVar[str] = ""
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def schema(cls, schema_type: str, *, name: str | None = None,
|
|
30
|
+
include_fields: list[str] | None = None,
|
|
31
|
+
exclude_fields: list[str] | None = None,
|
|
32
|
+
forbid_extra: bool = True, as_literal: bool = False) -> type[BaseModel]:
|
|
33
|
+
"""Generate a Pydantic model for the given schema type."""
|
|
34
|
+
if schema_type not in cls._schema_types:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Unknown schema type '{schema_type}'. Available: {sorted(cls._schema_types)}"
|
|
37
|
+
)
|
|
38
|
+
model_name = name or f"{cls.__name__}_{schema_type.title()}"
|
|
39
|
+
return ModelFactory.create(
|
|
40
|
+
fields=cls._fields, schema_type=schema_type,
|
|
41
|
+
model_name=model_name, forbid_extra=forbid_extra,
|
|
42
|
+
include_fields=include_fields, exclude_fields=exclude_fields,
|
|
43
|
+
as_literal=as_literal, route_cls=cls,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def schema_fields(cls, schema_type: str) -> list[str]:
|
|
48
|
+
return ModelFactory.field_names(cls._fields, schema_type)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def all_fields(cls) -> dict[str, FieldInfo]:
|
|
52
|
+
return dict(cls._fields)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def field_names(cls) -> list[str]:
|
|
56
|
+
return list(cls._fields.keys())
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def schema_types(cls) -> list[str]:
|
|
60
|
+
return sorted(cls._schema_types)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_schema(cls, data):
|
|
64
|
+
"""Create an instance from a schema model without re-validating nested models."""
|
|
65
|
+
return cls.model_validate(data, from_attributes=True)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def field_config_for(cls, field_name: str, schema_type: str) -> FieldConfig | None:
|
|
69
|
+
info = cls._fields.get(field_name)
|
|
70
|
+
return info.get_config(schema_type) if info else None
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""route decorator and route_factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Callable, Literal
|
|
5
|
+
from fastapi import APIRouter, status
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _RouteInfo:
|
|
9
|
+
__slots__ = ('path', 'method', 'name', 'description', 'status_code', 'tags')
|
|
10
|
+
|
|
11
|
+
def __init__(self, path, method, name, description, status_code, tags):
|
|
12
|
+
self.path = path
|
|
13
|
+
self.method = method
|
|
14
|
+
self.name = name
|
|
15
|
+
self.description = description
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.tags = tags
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def route(
|
|
21
|
+
path: str,
|
|
22
|
+
method: Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] = 'GET',
|
|
23
|
+
name: str | None = None,
|
|
24
|
+
description: str | None = None,
|
|
25
|
+
status_code: int = status.HTTP_200_OK,
|
|
26
|
+
tags: list[str] | None = None,
|
|
27
|
+
) -> Callable:
|
|
28
|
+
"""Decorator to mark a method as a route endpoint."""
|
|
29
|
+
def decorator(func: Callable) -> Callable:
|
|
30
|
+
func._route_info = _RouteInfo(
|
|
31
|
+
path=path, method=method,
|
|
32
|
+
name=name or func.__name__,
|
|
33
|
+
description=description,
|
|
34
|
+
status_code=status_code,
|
|
35
|
+
tags=tags or [],
|
|
36
|
+
)
|
|
37
|
+
return func
|
|
38
|
+
return decorator
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def route_factory(*route_classes: type) -> APIRouter:
|
|
42
|
+
"""Collect all @route methods from Route classes into a FastAPI APIRouter."""
|
|
43
|
+
router = APIRouter()
|
|
44
|
+
|
|
45
|
+
for cls in route_classes:
|
|
46
|
+
default_tags = [getattr(cls, '_route_group', None) or cls.__name__]
|
|
47
|
+
|
|
48
|
+
for attr_name in dir(cls):
|
|
49
|
+
attr = getattr(cls, attr_name, None)
|
|
50
|
+
if attr is None:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
func = getattr(attr, '__func__', attr)
|
|
54
|
+
info = getattr(func, '_route_info', None)
|
|
55
|
+
if info is None:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
tags = info.tags if info.tags else default_tags
|
|
59
|
+
|
|
60
|
+
router.add_api_route(
|
|
61
|
+
path=info.path,
|
|
62
|
+
endpoint=attr,
|
|
63
|
+
methods=[info.method],
|
|
64
|
+
name=info.name,
|
|
65
|
+
description=info.description,
|
|
66
|
+
status_code=info.status_code,
|
|
67
|
+
tags=tags,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return router
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""RouteField and FieldInfo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
from pydantic.fields import FieldInfo as PydanticFieldInfo
|
|
6
|
+
from .field_config import FieldConfig, _UNSET
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Pydantic FieldInfo attribute names (to separate from schema_configs)
|
|
10
|
+
_PYDANTIC_FIELD_ATTRS = frozenset(PydanticFieldInfo.__slots__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FieldInfo:
|
|
14
|
+
__slots__ = ('name', 'annotation', 'default', 'default_factory', 'configs',
|
|
15
|
+
'db_field', 'alias', 'description', 'field_info_kwargs', 'metadata')
|
|
16
|
+
|
|
17
|
+
def __init__(self, name, annotation, default=_UNSET, default_factory=None,
|
|
18
|
+
configs=None, db_field=None, alias=None, description=None,
|
|
19
|
+
field_info_kwargs=None, metadata=None):
|
|
20
|
+
self.name = name
|
|
21
|
+
self.annotation = annotation
|
|
22
|
+
self.default = default
|
|
23
|
+
self.default_factory = default_factory
|
|
24
|
+
self.configs = configs or {}
|
|
25
|
+
self.db_field = db_field or alias or name
|
|
26
|
+
self.alias = alias
|
|
27
|
+
self.description = description
|
|
28
|
+
self.field_info_kwargs = field_info_kwargs or {}
|
|
29
|
+
self.metadata = metadata
|
|
30
|
+
|
|
31
|
+
def get_config(self, schema_type: str) -> FieldConfig | None:
|
|
32
|
+
return self.configs.get(schema_type)
|
|
33
|
+
|
|
34
|
+
def has_config(self, schema_type: str) -> bool:
|
|
35
|
+
return schema_type in self.configs
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RouteField(PydanticFieldInfo):
|
|
39
|
+
"""Declarative field with per-schema configs.
|
|
40
|
+
|
|
41
|
+
Inherits from Pydantic's FieldInfo, so it works in both RouteBase and BaseModel:
|
|
42
|
+
|
|
43
|
+
# In RouteBase — schema configs work
|
|
44
|
+
class UserRoute(RouteBase):
|
|
45
|
+
title: str = RouteField(add=Add(), edit=Edit())
|
|
46
|
+
|
|
47
|
+
# In BaseModel — Pydantic Field params work
|
|
48
|
+
class MyModel(BaseModel):
|
|
49
|
+
title: str = RouteField(alias="x", description="test")
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, default: Any = _UNSET, *, default_factory: Callable | None = None,
|
|
53
|
+
alias: str | None = None, validation_alias: str | None = None,
|
|
54
|
+
serialization_alias: str | None = None,
|
|
55
|
+
description: str | None = None, title: str | None = None,
|
|
56
|
+
exclude: bool = False, frozen: bool = False,
|
|
57
|
+
deprecated: str | None = None,
|
|
58
|
+
json_schema_extra: dict | None = None,
|
|
59
|
+
validate_default: bool = False,
|
|
60
|
+
repr: bool = True,
|
|
61
|
+
**kwargs: Any):
|
|
62
|
+
# Extract schema_configs (FieldConfig instances) from all kwargs
|
|
63
|
+
self.schema_configs = {k: v for k, v in kwargs.items() if isinstance(v, FieldConfig)}
|
|
64
|
+
|
|
65
|
+
# Build kwargs for Pydantic FieldInfo
|
|
66
|
+
# These are the params Pydantic FieldInfo stores as direct attributes
|
|
67
|
+
pydantic_kwargs = {}
|
|
68
|
+
if default is not _UNSET:
|
|
69
|
+
pydantic_kwargs['default'] = default
|
|
70
|
+
if default_factory is not None:
|
|
71
|
+
pydantic_kwargs['default_factory'] = default_factory
|
|
72
|
+
if alias is not None:
|
|
73
|
+
pydantic_kwargs['alias'] = alias
|
|
74
|
+
if validation_alias is not None:
|
|
75
|
+
pydantic_kwargs['validation_alias'] = validation_alias
|
|
76
|
+
if serialization_alias is not None:
|
|
77
|
+
pydantic_kwargs['serialization_alias'] = serialization_alias
|
|
78
|
+
if description is not None:
|
|
79
|
+
pydantic_kwargs['description'] = description
|
|
80
|
+
if title is not None:
|
|
81
|
+
pydantic_kwargs['title'] = title
|
|
82
|
+
if exclude:
|
|
83
|
+
pydantic_kwargs['exclude'] = exclude
|
|
84
|
+
if frozen:
|
|
85
|
+
pydantic_kwargs['frozen'] = frozen
|
|
86
|
+
if deprecated is not None:
|
|
87
|
+
pydantic_kwargs['deprecated'] = deprecated
|
|
88
|
+
if json_schema_extra is not None:
|
|
89
|
+
pydantic_kwargs['json_schema_extra'] = json_schema_extra
|
|
90
|
+
if validate_default:
|
|
91
|
+
pydantic_kwargs['validate_default'] = validate_default
|
|
92
|
+
if not repr:
|
|
93
|
+
pydantic_kwargs['repr'] = repr
|
|
94
|
+
|
|
95
|
+
# Pass constraint kwargs (min_length, max_length, gt, ge, lt, le, pattern, etc.)
|
|
96
|
+
# These are stored in metadata by Pydantic
|
|
97
|
+
for k, v in kwargs.items():
|
|
98
|
+
if k not in self.schema_configs and k not in pydantic_kwargs:
|
|
99
|
+
pydantic_kwargs[k] = v
|
|
100
|
+
|
|
101
|
+
super().__init__(**pydantic_kwargs)
|
|
102
|
+
|
|
103
|
+
def to_field_info(self, name: str, annotation: type) -> FieldInfo:
|
|
104
|
+
return FieldInfo(
|
|
105
|
+
name=name, annotation=annotation,
|
|
106
|
+
default=self.default, default_factory=self.default_factory,
|
|
107
|
+
configs=self.schema_configs,
|
|
108
|
+
alias=getattr(self, 'alias', None),
|
|
109
|
+
description=getattr(self, 'description', None),
|
|
110
|
+
field_info_kwargs=self._field_info_kwargs(),
|
|
111
|
+
metadata=self._constraint_kwargs(),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _field_info_kwargs(self) -> dict:
|
|
115
|
+
kwargs = {}
|
|
116
|
+
# Only include public FieldInfo attributes, skip internal ones
|
|
117
|
+
_skip = {'_original_assignment', '_attributes_set', '_original_annotation',
|
|
118
|
+
'_qualifiers', '_complete', '_final', 'metadata'}
|
|
119
|
+
for attr in _PYDANTIC_FIELD_ATTRS:
|
|
120
|
+
if attr in _skip:
|
|
121
|
+
continue
|
|
122
|
+
val = getattr(self, attr, None)
|
|
123
|
+
if val is not None and val is not False and val != []:
|
|
124
|
+
kwargs[attr] = val
|
|
125
|
+
return kwargs
|
|
126
|
+
|
|
127
|
+
def _constraint_kwargs(self) -> dict:
|
|
128
|
+
"""Return constraint kwargs (max_length, min_length, gt, ge, lt, le, etc.) for Field()."""
|
|
129
|
+
constraints = {}
|
|
130
|
+
for item in (self.metadata or []):
|
|
131
|
+
for attr in ('max_length', 'min_length', 'gt', 'ge', 'lt', 'le',
|
|
132
|
+
'multiple_of', 'pattern'):
|
|
133
|
+
if hasattr(item, attr):
|
|
134
|
+
constraints[attr] = getattr(item, attr)
|
|
135
|
+
return constraints
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""SelfDerivedModel - Derive a field's schema from the route's own fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SelfDerivedModel:
|
|
7
|
+
"""Metadata for a field whose schema is derived from the route's own fields.
|
|
8
|
+
|
|
9
|
+
class UserRoute(RouteBase):
|
|
10
|
+
name: str = RouteField(add=Add(), edit=Edit())
|
|
11
|
+
items: list = RouteField(
|
|
12
|
+
bulk_add=BulkAddConfig(
|
|
13
|
+
default=SelfDerivedModel(schema='add', exclude_fields=['email'])
|
|
14
|
+
)
|
|
15
|
+
)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, schema: str, is_optional: bool = True, format: str = 'model',
|
|
19
|
+
include_fields: list[str] | None = None, exclude_fields: list[str] | None = None):
|
|
20
|
+
self.schema = schema
|
|
21
|
+
self.is_optional = is_optional
|
|
22
|
+
self.format = format
|
|
23
|
+
self.include_fields = include_fields
|
|
24
|
+
self.exclude_fields = exclude_fields
|
|
25
|
+
|
|
26
|
+
def __repr__(self):
|
|
27
|
+
return f"SelfDerivedModel(schema={self.schema!r}, include={self.include_fields}, exclude={self.exclude_fields})"
|