Fast-Controller 0.4.2b0__tar.gz → 0.4.4b0__tar.gz
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.
- {fast_controller-0.4.2b0 → fast_controller-0.4.4b0}/PKG-INFO +2 -1
- {fast_controller-0.4.2b0 → fast_controller-0.4.4b0}/fast_controller/__init__.py +3 -3
- {fast_controller-0.4.2b0 → fast_controller-0.4.4b0}/fast_controller/resource.py +2 -5
- fast_controller-0.4.4b0/fast_controller/schema.py +113 -0
- {fast_controller-0.4.2b0 → fast_controller-0.4.4b0}/fast_controller/util.py +27 -36
- {fast_controller-0.4.2b0 → fast_controller-0.4.4b0}/pyproject.toml +2 -1
- {fast_controller-0.4.2b0 → fast_controller-0.4.4b0}/tests/test_resource.py +9 -9
- {fast_controller-0.4.2b0 → fast_controller-0.4.4b0}/tests/test_util.py +1 -70
- {fast_controller-0.4.2b0 → fast_controller-0.4.4b0}/README.md +0 -0
- {fast_controller-0.4.2b0 → fast_controller-0.4.4b0}/tests/test_controller.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: Fast-Controller
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4b0
|
|
4
4
|
Summary: The fastest way to a turn your models into a full ReST API
|
|
5
5
|
Keywords: controller,base,rest,api,backend
|
|
6
6
|
Author-Email: Cody M Sommer <bassmastacod@gmail.com>
|
|
@@ -25,6 +25,7 @@ Requires-Dist: fastapi
|
|
|
25
25
|
Requires-Dist: daomodel
|
|
26
26
|
Requires-Dist: SQLModel
|
|
27
27
|
Requires-Dist: inflect
|
|
28
|
+
Requires-Dist: str-case-util
|
|
28
29
|
Description-Content-Type: text/markdown
|
|
29
30
|
|
|
30
31
|
# Fast-Controller
|
|
@@ -14,7 +14,7 @@ from sqlalchemy.orm import sessionmaker
|
|
|
14
14
|
from sqlmodel import SQLModel
|
|
15
15
|
|
|
16
16
|
from fast_controller.resource import Resource, get_field_type
|
|
17
|
-
from fast_controller.util import docstring_format, InvalidInput, expose_path_params, extract_values, inflect
|
|
17
|
+
from fast_controller.util import docstring_format, InvalidInput, expose_path_params, extract_values, inflect
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class Action(Enum):
|
|
@@ -64,8 +64,8 @@ def _register_search_endpoint(controller, router: APIRouter, resource: type[Reso
|
|
|
64
64
|
x_unique: Optional[str] = Header(default=None), # TODO: move to filters
|
|
65
65
|
daos: DAOFactory = controller.daos) -> list[DAOModel]:
|
|
66
66
|
"""Searches for {resource} by criteria"""
|
|
67
|
-
|
|
68
|
-
results = daos[resource].find(x_page, x_per_page, x_order, x_duplicate, x_unique, **
|
|
67
|
+
provided_filters = filters.model_dump(exclude_unset=True)
|
|
68
|
+
results = daos[resource].find(x_page, x_per_page, x_order, x_duplicate, x_unique, **provided_filters)
|
|
69
69
|
response.headers["x-total-count"] = str(results.total)
|
|
70
70
|
response.headers["x-page"] = str(results.page)
|
|
71
71
|
response.headers["x-per-page"] = str(results.per_page)
|
|
@@ -2,6 +2,7 @@ from inspect import isclass
|
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
4
|
from daomodel import DAOModel
|
|
5
|
+
from daomodel.search_util import ConditionOperator
|
|
5
6
|
from pydantic import create_model, BaseModel
|
|
6
7
|
from str_case_util import Case
|
|
7
8
|
|
|
@@ -70,7 +71,7 @@ class Resource(DAOModel):
|
|
|
70
71
|
return field_name
|
|
71
72
|
fields = [field[-1] if isinstance(field, tuple) else field for field in cls.get_searchable_properties()]
|
|
72
73
|
field_types = {
|
|
73
|
-
get_field_name(field): (get_field_type(field), None) for field in fields
|
|
74
|
+
get_field_name(field): (ConditionOperator[get_field_type(field)], None) for field in fields
|
|
74
75
|
}
|
|
75
76
|
return create_model(
|
|
76
77
|
f'{cls.doc_name()}SearchSchema',
|
|
@@ -128,7 +129,3 @@ class Resource(DAOModel):
|
|
|
128
129
|
@classmethod
|
|
129
130
|
def get_detailed_output_schema(cls) -> type[BaseModel]:
|
|
130
131
|
return either(cls._detailed_output_schema, cls.get_output_schema())
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
class Schema(BaseModel):
|
|
134
|
-
pass
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from typing import Mapping
|
|
2
|
+
|
|
3
|
+
from daomodel import DAOModel
|
|
4
|
+
from str_case_util import Case
|
|
5
|
+
|
|
6
|
+
from fast_controller import Resource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Schema(DAOModel):
|
|
10
|
+
"""Base class for schemas, to act as a label."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def schemas(**kwargs):
|
|
15
|
+
"""Applies multiple schema decorators at once.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
class UserInput(Schema):
|
|
19
|
+
name: Identifier[str]
|
|
20
|
+
email: Optional[str]
|
|
21
|
+
password: str
|
|
22
|
+
|
|
23
|
+
@schemas(
|
|
24
|
+
input=UserInput,
|
|
25
|
+
detailed_output={
|
|
26
|
+
'name': str,
|
|
27
|
+
'email': Optional[str],
|
|
28
|
+
'last_seen': datetime
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
class User(Resource):
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
Each keyword corresponds to a schema role:
|
|
35
|
+
default, input, update, output, detailed_output
|
|
36
|
+
|
|
37
|
+
Values may be:
|
|
38
|
+
- A Schema subclass
|
|
39
|
+
- An inline schema definition: {field_name: type}
|
|
40
|
+
|
|
41
|
+
Raises: AttributeError if applied to a class that does not define the
|
|
42
|
+
expected setter method (e.g., not a Resource subclass or invalid schema role).
|
|
43
|
+
"""
|
|
44
|
+
def decorator(cls):
|
|
45
|
+
for schema_role, schema in kwargs.items():
|
|
46
|
+
setter_name = f'set_{schema_role}_schema'
|
|
47
|
+
if not hasattr(cls, setter_name):
|
|
48
|
+
raise AttributeError(
|
|
49
|
+
f'{cls.__name__} does not support schema role "{schema_role}". '
|
|
50
|
+
f'Expected one of: default, input, update, output, detailed_output.'
|
|
51
|
+
)
|
|
52
|
+
decorator_fn = _schema_decorator_factory(schema_role)
|
|
53
|
+
cls = decorator_fn(schema)(cls)
|
|
54
|
+
return cls
|
|
55
|
+
return decorator
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
InlineSchema = Mapping[str, type]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_schema(schema: type[Schema]|InlineSchema, resource: type[Resource], suffix: str) -> type[Schema]:
|
|
62
|
+
if isinstance(schema, Mapping):
|
|
63
|
+
fields = {name: typ for name, typ in schema.items()}
|
|
64
|
+
schema = type(
|
|
65
|
+
f'{resource.__name__}{suffix}',
|
|
66
|
+
(Schema,),
|
|
67
|
+
{
|
|
68
|
+
'__annotations__': fields,
|
|
69
|
+
'__module__': resource.__module__,
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
return schema
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _schema_decorator_factory(schema_role: str):
|
|
76
|
+
setter_name = f"set_{schema_role}_schema"
|
|
77
|
+
suffix = Case.CAPITAL_CAMEL_CASE.format(schema_role)
|
|
78
|
+
|
|
79
|
+
def decorator(schema=None, **inline_fields):
|
|
80
|
+
if inline_fields:
|
|
81
|
+
if schema is not None:
|
|
82
|
+
raise TypeError(
|
|
83
|
+
f"Schema decorator for role '{schema_role}' received both "
|
|
84
|
+
f"a schema argument and inline fields. Use one or the other."
|
|
85
|
+
)
|
|
86
|
+
schema = inline_fields
|
|
87
|
+
|
|
88
|
+
if schema is None:
|
|
89
|
+
raise TypeError(
|
|
90
|
+
f"Schema decorator for role '{schema_role}' requires either "
|
|
91
|
+
f"a Schema subclass or inline field definitions."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def wrapper(cls):
|
|
95
|
+
if not hasattr(cls, setter_name):
|
|
96
|
+
raise AttributeError(
|
|
97
|
+
f'Cannot apply schema role "{schema_role}" to {cls.__name__}. '
|
|
98
|
+
f'This decorator may only be used on Resource subclasses. '
|
|
99
|
+
f'Expected method "{setter_name}" to exist.'
|
|
100
|
+
)
|
|
101
|
+
setter = getattr(cls, setter_name)
|
|
102
|
+
setter(_resolve_schema(schema, cls, suffix))
|
|
103
|
+
return cls
|
|
104
|
+
|
|
105
|
+
return wrapper
|
|
106
|
+
return decorator
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
default_schema = _schema_decorator_factory('default')
|
|
110
|
+
input_schema = _schema_decorator_factory('input')
|
|
111
|
+
update_schema = _schema_decorator_factory('update')
|
|
112
|
+
output_schema = _schema_decorator_factory('output')
|
|
113
|
+
detailed_output_schema = _schema_decorator_factory('detailed_output')
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import inspect
|
|
2
|
-
from
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from typing import Callable, get_type_hints
|
|
3
4
|
from warnings import deprecated
|
|
4
5
|
|
|
5
6
|
import inflect as _inflect
|
|
6
7
|
from daomodel.search_util import *
|
|
8
|
+
from fastapi import Response
|
|
7
9
|
from sqlmodel import SQLModel
|
|
8
10
|
|
|
9
11
|
|
|
@@ -82,39 +84,28 @@ def extract_values(kwargs: dict, field_names: list[str]) -> list:
|
|
|
82
84
|
return [kwargs[field] for field in field_names]
|
|
83
85
|
|
|
84
86
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
+
def cache_control(value: str):
|
|
88
|
+
def decorator(func):
|
|
89
|
+
if inspect.iscoroutinefunction(func):
|
|
90
|
+
@wraps(func)
|
|
91
|
+
async def wrapper(*args, **kwargs):
|
|
92
|
+
response = await func(*args, **kwargs)
|
|
93
|
+
if isinstance(response, Response):
|
|
94
|
+
response.headers['Cache-Control'] = value
|
|
95
|
+
return response
|
|
96
|
+
return wrapper
|
|
97
|
+
else:
|
|
98
|
+
@wraps(func)
|
|
99
|
+
def wrapper(*args, **kwargs):
|
|
100
|
+
response = func(*args, **kwargs)
|
|
101
|
+
if isinstance(response, Response):
|
|
102
|
+
response.headers['Cache-Control'] = value
|
|
103
|
+
return response
|
|
104
|
+
return wrapper
|
|
87
105
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if '_' in op:
|
|
95
|
-
part, op = op.split('_', 1)
|
|
96
|
-
match op: # TODO: support contains, starts, and ends
|
|
97
|
-
case 'lt':
|
|
98
|
-
return LessThan(value, _part=part)
|
|
99
|
-
case 'le':
|
|
100
|
-
return LessThanEqualTo(value, _part=part)
|
|
101
|
-
case 'gt':
|
|
102
|
-
return GreaterThan(value, _part=part)
|
|
103
|
-
case 'ge':
|
|
104
|
-
return GreaterThanEqualTo(value, _part=part)
|
|
105
|
-
case 'between':
|
|
106
|
-
return Between(*value.split('|', 1), _part=part)
|
|
107
|
-
case 'anyof':
|
|
108
|
-
return AnyOf(*value.split('|'), _part=part)
|
|
109
|
-
case 'noneof':
|
|
110
|
-
return NoneOf(*value.split('|'), _part=part)
|
|
111
|
-
case 'is':
|
|
112
|
-
match value:
|
|
113
|
-
case 'set':
|
|
114
|
-
return IsSet()
|
|
115
|
-
case 'notset':
|
|
116
|
-
return NotSet()
|
|
117
|
-
case _:
|
|
118
|
-
return Equals(value, _part=part)
|
|
119
|
-
case _:
|
|
120
|
-
return Equals(value, _part=part)
|
|
106
|
+
return decorator
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
immutable = cache_control('public, max-age=31536000, immutable')
|
|
110
|
+
no_cache = cache_control('no-store')
|
|
111
|
+
cache_1h = cache_control('public, max-age=3600')
|
|
@@ -25,6 +25,7 @@ dependencies = [
|
|
|
25
25
|
"daomodel",
|
|
26
26
|
"SQLModel",
|
|
27
27
|
"inflect",
|
|
28
|
+
"str-case-util",
|
|
28
29
|
]
|
|
29
30
|
classifiers = [
|
|
30
31
|
"License :: OSI Approved :: MIT License",
|
|
@@ -41,7 +42,7 @@ classifiers = [
|
|
|
41
42
|
"Topic :: Software Development :: Libraries",
|
|
42
43
|
"Typing :: Typed",
|
|
43
44
|
]
|
|
44
|
-
version = "0.4.
|
|
45
|
+
version = "0.4.4b0"
|
|
45
46
|
|
|
46
47
|
[project.license]
|
|
47
48
|
text = "MIT"
|
|
@@ -4,6 +4,7 @@ from typing import Any, Optional
|
|
|
4
4
|
import pytest
|
|
5
5
|
from daomodel import DAOModel
|
|
6
6
|
from daomodel.fields import Unsearchable, Identifier, ReferenceTo
|
|
7
|
+
from daomodel.search_util import ConditionOperator
|
|
7
8
|
from sqlmodel import SQLModel
|
|
8
9
|
|
|
9
10
|
from fast_controller import Resource, Controller
|
|
@@ -68,15 +69,14 @@ def test_get_search_schema():
|
|
|
68
69
|
actual = Publisher.get_search_schema()
|
|
69
70
|
assert actual.__name__ == 'PublisherSearchSchema'
|
|
70
71
|
class Expected(SQLModel):
|
|
71
|
-
name: str
|
|
72
|
-
book_title: str
|
|
73
|
-
book_page_count: int
|
|
74
|
-
book_publication_date: datetime
|
|
75
|
-
author_name: str
|
|
76
|
-
author_active: bool
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
assert actual_fields == expected_fields
|
|
72
|
+
name: ConditionOperator[str]
|
|
73
|
+
book_title: ConditionOperator[str]
|
|
74
|
+
book_page_count: ConditionOperator[int]
|
|
75
|
+
book_publication_date: ConditionOperator[datetime]
|
|
76
|
+
author_name: ConditionOperator[str]
|
|
77
|
+
author_active: ConditionOperator[bool]
|
|
78
|
+
assert {k: str(v.annotation) for k, v in actual.model_fields.items()} == \
|
|
79
|
+
{k: str(v.annotation) for k, v in Expected.model_fields.items()}
|
|
80
80
|
|
|
81
81
|
|
|
82
82
|
# TODO - Not convinced on this design for schema definitions
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
from unittest.mock import Mock
|
|
3
3
|
|
|
4
|
-
from daomodel.search_util import *
|
|
5
|
-
|
|
6
4
|
from fast_controller import docstring_format
|
|
7
|
-
from fast_controller.util import expose_path_params, extract_values
|
|
5
|
+
from fast_controller.util import expose_path_params, extract_values
|
|
8
6
|
|
|
9
7
|
|
|
10
8
|
@docstring_format(key="value")
|
|
@@ -184,70 +182,3 @@ def test_extract_values__different_types():
|
|
|
184
182
|
expected = ["string_value", 123, True, [1, 2, 3], None]
|
|
185
183
|
|
|
186
184
|
assert extract_values(kwargs, field_names) == expected
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def test_to_condition_operator__equals():
|
|
190
|
-
op = to_condition_operator("value")
|
|
191
|
-
assert isinstance(op, Equals)
|
|
192
|
-
assert op.values == ("value",)
|
|
193
|
-
|
|
194
|
-
op = to_condition_operator("is:value")
|
|
195
|
-
assert isinstance(op, Equals)
|
|
196
|
-
assert op.values == ("value",)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def test_to_condition_operator__lt():
|
|
200
|
-
op = to_condition_operator("lt:5")
|
|
201
|
-
assert isinstance(op, LessThan)
|
|
202
|
-
assert op.values == ("5",)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def test_to_condition_operator__le():
|
|
206
|
-
op = to_condition_operator("le:5")
|
|
207
|
-
assert isinstance(op, LessThanEqualTo)
|
|
208
|
-
assert op.values == ("5",)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def test_to_condition_operator__gt():
|
|
212
|
-
op = to_condition_operator("gt:5")
|
|
213
|
-
assert isinstance(op, GreaterThan)
|
|
214
|
-
assert op.values == ("5",)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def test_to_condition_operator__ge():
|
|
218
|
-
op = to_condition_operator("ge:5")
|
|
219
|
-
assert isinstance(op, GreaterThanEqualTo)
|
|
220
|
-
assert op.values == ("5",)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def test_to_condition_operator__between():
|
|
224
|
-
op = to_condition_operator("between:1|5")
|
|
225
|
-
assert isinstance(op, Between)
|
|
226
|
-
assert op.values == ("1", "5")
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def test_to_condition_operator__anyof():
|
|
230
|
-
op = to_condition_operator("anyof:1|2|3")
|
|
231
|
-
assert isinstance(op, AnyOf)
|
|
232
|
-
assert op.values == ("1", "2", "3")
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def test_to_condition_operator__noneof():
|
|
236
|
-
op = to_condition_operator("noneof:1|2|3")
|
|
237
|
-
assert isinstance(op, NoneOf)
|
|
238
|
-
assert op.values == ("1", "2", "3")
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def test_to_condition_operator__isset():
|
|
242
|
-
assert isinstance(to_condition_operator("is:set"), IsSet)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def test_to_condition_operator__notset():
|
|
246
|
-
assert isinstance(to_condition_operator("is:notset"), NotSet)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def test_to_condition_operator__part():
|
|
250
|
-
op = to_condition_operator("part_is:value")
|
|
251
|
-
assert isinstance(op, Equals)
|
|
252
|
-
assert op.values == ("value",)
|
|
253
|
-
assert op.part == "part"
|
|
File without changes
|
|
File without changes
|