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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: Fast-Controller
3
- Version: 0.4.2b0
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, to_condition_operator
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
- filters = {col: to_condition_operator(val) for col, val in filters.model_dump(exclude_unset=True)}
68
- results = daos[resource].find(x_page, x_per_page, x_order, x_duplicate, x_unique, **filters)
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 typing import Callable, get_type_hints, Optional
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 to_condition_operator(value: str) -> ConditionOperator:
86
- """Maps a value with a potential operator prefix (e.g. 'lt:', 'contains:') to a ConditionOperator.
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
- :param value: The query value with optional operator prefix
89
- :return: The ConditionOperator defined by the prefix
90
- """
91
- op, part = None, None
92
- if ':' in value:
93
- op, value = value.split(':', 1)
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.2b0"
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
- actual_fields = {k: v.annotation for k, v in actual.model_fields.items()}
78
- expected_fields = {k: v.annotation for k, v in Expected.model_fields.items()}
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, to_condition_operator
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"