Fast-Controller 0.0.0.dev2__tar.gz → 0.2.0b0__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.0.0.dev2 → fast_controller-0.2.0b0}/PKG-INFO +1 -1
- fast_controller-0.2.0b0/fast_controller/__init__.py +272 -0
- fast_controller-0.2.0b0/fast_controller/resource.py +107 -0
- fast_controller-0.2.0b0/fast_controller/util.py +36 -0
- {fast_controller-0.0.0.dev2 → fast_controller-0.2.0b0}/pyproject.toml +1 -1
- fast_controller-0.2.0b0/tests/test_resource.py +84 -0
- fast_controller-0.2.0b0/tests/test_util.py +27 -0
- fast_controller-0.0.0.dev2/fast_controller/__init__.py +0 -237
- fast_controller-0.0.0.dev2/fast_controller/resource.py +0 -80
- fast_controller-0.0.0.dev2/fast_controller/util.py +0 -19
- fast_controller-0.0.0.dev2/tests/test_resource.py +0 -0
- fast_controller-0.0.0.dev2/tests/test_util.py +0 -0
- {fast_controller-0.0.0.dev2 → fast_controller-0.2.0b0}/README.md +0 -0
- {fast_controller-0.0.0.dev2 → fast_controller-0.2.0b0}/tests/test_controller.py +0 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from enum import Enum, auto
|
|
3
|
+
from typing import Optional, Callable
|
|
4
|
+
|
|
5
|
+
from daomodel import DAOModel
|
|
6
|
+
from daomodel.dao import NotFound
|
|
7
|
+
from daomodel.db import DAOFactory
|
|
8
|
+
from daomodel.transaction import Conflict
|
|
9
|
+
from fastapi import FastAPI, APIRouter, Request, Response, Depends, Path, Body, Query, Header
|
|
10
|
+
from fastapi.responses import JSONResponse
|
|
11
|
+
from sqlalchemy import Engine
|
|
12
|
+
from sqlalchemy.orm import sessionmaker
|
|
13
|
+
from sqlmodel import SQLModel
|
|
14
|
+
|
|
15
|
+
from fast_controller.resource import Resource
|
|
16
|
+
from fast_controller.util import docstring_format, InvalidInput
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Action(Enum):
|
|
20
|
+
VIEW = auto()
|
|
21
|
+
SEARCH = auto()
|
|
22
|
+
CREATE = auto()
|
|
23
|
+
UPSERT = auto()
|
|
24
|
+
MODIFY = auto()
|
|
25
|
+
RENAME = auto()
|
|
26
|
+
DELETE = auto()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Controller:
|
|
30
|
+
def __init__(self,
|
|
31
|
+
prefix: Optional[str] = '',
|
|
32
|
+
app: Optional[FastAPI] = None,
|
|
33
|
+
engine: Optional[Engine] = None) -> None:
|
|
34
|
+
self.prefix = prefix
|
|
35
|
+
self.app = None
|
|
36
|
+
self.engine = None
|
|
37
|
+
self.models = None
|
|
38
|
+
if app is not None and engine is not None:
|
|
39
|
+
self.init_app(app, engine)
|
|
40
|
+
self.daos = Depends(self.dao_generator)
|
|
41
|
+
|
|
42
|
+
def init_app(self, app: FastAPI, engine: Engine) -> None:
|
|
43
|
+
self.app = app
|
|
44
|
+
self.engine = engine
|
|
45
|
+
|
|
46
|
+
@app.exception_handler(InvalidInput)
|
|
47
|
+
async def not_found_handler(request: Request, exc: InvalidInput):
|
|
48
|
+
return JSONResponse(status_code=400, content={"detail": exc.detail})
|
|
49
|
+
|
|
50
|
+
@app.exception_handler(NotFound)
|
|
51
|
+
async def not_found_handler(request: Request, exc: NotFound):
|
|
52
|
+
return JSONResponse(status_code=404, content={"detail": exc.detail})
|
|
53
|
+
|
|
54
|
+
@app.exception_handler(Conflict)
|
|
55
|
+
async def not_found_handler(request: Request, exc: Conflict):
|
|
56
|
+
return JSONResponse(status_code=409, content={"detail": exc.detail})
|
|
57
|
+
|
|
58
|
+
def dao_generator(self) -> DAOFactory:
|
|
59
|
+
"""Yields a DAOFactory."""
|
|
60
|
+
with DAOFactory(sessionmaker(bind=self.engine)) as daos:
|
|
61
|
+
yield daos
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def dao_context(self):
|
|
65
|
+
yield from self.dao_generator()
|
|
66
|
+
|
|
67
|
+
def dependencies_for(self, resource: type[Resource], action: Action) -> list[Depends]:
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
def register_resource(self,
|
|
71
|
+
resource: type[Resource],
|
|
72
|
+
skip: Optional[set[Action]] = None,
|
|
73
|
+
additional_endpoints: Optional[Callable] = None) -> None:
|
|
74
|
+
api_router = APIRouter(
|
|
75
|
+
prefix=self.prefix + resource.get_resource_path(),
|
|
76
|
+
tags=[resource.doc_name()])
|
|
77
|
+
self._register_resource_endpoints(api_router, resource, skip)
|
|
78
|
+
if additional_endpoints:
|
|
79
|
+
additional_endpoints(api_router, self)
|
|
80
|
+
self.app.include_router(api_router)
|
|
81
|
+
|
|
82
|
+
def _register_resource_endpoints(self,
|
|
83
|
+
router: APIRouter,
|
|
84
|
+
resource: type[Resource],
|
|
85
|
+
skip: Optional[set[Action]] = None) -> None:
|
|
86
|
+
if skip is None:
|
|
87
|
+
skip = set()
|
|
88
|
+
if Action.SEARCH not in skip:
|
|
89
|
+
self._register_search_endpoint(router, resource)
|
|
90
|
+
if Action.CREATE not in skip:
|
|
91
|
+
self._register_create_endpoint(router, resource)
|
|
92
|
+
if Action.UPSERT not in skip:
|
|
93
|
+
self._register_update_endpoint(router, resource)
|
|
94
|
+
|
|
95
|
+
pk = [p.name for p in resource.get_pk()]
|
|
96
|
+
path = "/".join([""] + ["{" + p + "}" for p in pk])
|
|
97
|
+
|
|
98
|
+
# Caveat: Only up to 2 columns are supported within a primary key.
|
|
99
|
+
# This allows us to avoid resorting to exec() while **kwargs is unsupported for Path variables
|
|
100
|
+
if len(pk) == 1:
|
|
101
|
+
if Action.VIEW not in skip:
|
|
102
|
+
self._register_view_endpoint(router, resource, path, pk)
|
|
103
|
+
|
|
104
|
+
# Caveat: Rename action is only supported for resources with a single column primary key
|
|
105
|
+
if Action.RENAME not in skip:
|
|
106
|
+
self._register_rename_endpoint(router, resource, path, pk)
|
|
107
|
+
|
|
108
|
+
# Caveat: Modify action is only supported for resources with a single column primary key
|
|
109
|
+
# Use Upsert instead for multi-column PK resources
|
|
110
|
+
if Action.MODIFY not in skip:
|
|
111
|
+
self._register_modify_endpoint(router, resource, path, pk)
|
|
112
|
+
|
|
113
|
+
# Caveat: Delete action is only supported for resources with a single column primary key
|
|
114
|
+
if Action.DELETE not in skip:
|
|
115
|
+
self._register_delete_endpoint(router, resource, path, pk)
|
|
116
|
+
elif len(pk) == 2:
|
|
117
|
+
if Action.VIEW not in skip:
|
|
118
|
+
self._register_view_endpoint_dual_pk(router, resource, path, pk)
|
|
119
|
+
|
|
120
|
+
def _register_search_endpoint(self, router: APIRouter, resource: type[Resource]):
|
|
121
|
+
@router.get(
|
|
122
|
+
"/",
|
|
123
|
+
response_model=list[resource.get_output_schema()],
|
|
124
|
+
dependencies=self.dependencies_for(resource, Action.SEARCH))
|
|
125
|
+
@docstring_format(resource=resource.doc_name())
|
|
126
|
+
def search(response: Response,
|
|
127
|
+
filters: resource.get_search_schema() = Query(),
|
|
128
|
+
x_page: Optional[int] = Header(default=None, gt=0),
|
|
129
|
+
x_per_page: Optional[int] = Header(default=None, gt=0),
|
|
130
|
+
daos: DAOFactory = self.daos) -> list[DAOModel]:
|
|
131
|
+
"""Searches for {resource} by criteria"""
|
|
132
|
+
results = daos[resource].find(x_page, x_per_page, **filters.model_dump(exclude_unset=True))
|
|
133
|
+
response.headers["x-total-count"] = str(results.total)
|
|
134
|
+
response.headers["x-page"] = str(results.page)
|
|
135
|
+
response.headers["x-per-page"] = str(results.per_page)
|
|
136
|
+
return results
|
|
137
|
+
|
|
138
|
+
def _register_create_endpoint(self, router: APIRouter, resource: type[Resource]):
|
|
139
|
+
@router.post(
|
|
140
|
+
"/",
|
|
141
|
+
response_model=resource.get_detailed_output_schema(),
|
|
142
|
+
status_code=201,
|
|
143
|
+
dependencies=self.dependencies_for(resource, Action.CREATE))
|
|
144
|
+
@docstring_format(resource=resource.doc_name())
|
|
145
|
+
def create(model: resource.get_input_schema(),
|
|
146
|
+
daos: DAOFactory = self.daos) -> DAOModel:
|
|
147
|
+
"""Creates a new {resource}"""
|
|
148
|
+
return daos[resource].create_with(**model.model_dump(exclude_unset=True))
|
|
149
|
+
|
|
150
|
+
def _register_update_endpoint(self, router: APIRouter, resource: type[Resource]):
|
|
151
|
+
@router.put(
|
|
152
|
+
"/",
|
|
153
|
+
response_model=resource.get_detailed_output_schema(),
|
|
154
|
+
dependencies=self.dependencies_for(resource, Action.UPSERT))
|
|
155
|
+
@docstring_format(resource=resource.doc_name())
|
|
156
|
+
def upsert(model: resource.get_input_schema(),
|
|
157
|
+
daos: DAOFactory = self.daos) -> SQLModel:
|
|
158
|
+
"""Creates/modifies a {resource}"""
|
|
159
|
+
daos[resource].upsert(model)
|
|
160
|
+
return model
|
|
161
|
+
|
|
162
|
+
def _register_view_endpoint(self,
|
|
163
|
+
router: APIRouter,
|
|
164
|
+
resource: type[Resource],
|
|
165
|
+
path: str,
|
|
166
|
+
pk: list[str]):
|
|
167
|
+
@router.get(
|
|
168
|
+
path,
|
|
169
|
+
response_model=resource.get_detailed_output_schema(),
|
|
170
|
+
dependencies=self.dependencies_for(resource, Action.VIEW))
|
|
171
|
+
@docstring_format(resource=resource.doc_name())
|
|
172
|
+
def view(pk0=Path(alias=pk[0]),
|
|
173
|
+
daos: DAOFactory = self.daos) -> DAOModel:
|
|
174
|
+
"""Retrieves a detailed view of a {resource}"""
|
|
175
|
+
return daos[resource].get(pk0)
|
|
176
|
+
|
|
177
|
+
def _register_view_endpoint_dual_pk(self,
|
|
178
|
+
router: APIRouter,
|
|
179
|
+
resource: type[Resource],
|
|
180
|
+
path: str,
|
|
181
|
+
pk: list[str]):
|
|
182
|
+
@router.get(
|
|
183
|
+
path,
|
|
184
|
+
response_model=resource.get_detailed_output_schema(),
|
|
185
|
+
dependencies=self.dependencies_for(resource, Action.VIEW))
|
|
186
|
+
@docstring_format(resource=resource.doc_name())
|
|
187
|
+
def view(pk0=Path(alias=pk[0]),
|
|
188
|
+
pk1=Path(alias=pk[1]),
|
|
189
|
+
daos: DAOFactory = self.daos) -> DAOModel:
|
|
190
|
+
"""Retrieves a detailed view of a {resource}"""
|
|
191
|
+
return daos[resource].get(pk0, pk1)
|
|
192
|
+
|
|
193
|
+
def _register_rename_endpoint(self,
|
|
194
|
+
router: APIRouter,
|
|
195
|
+
resource: type[Resource],
|
|
196
|
+
path: str,
|
|
197
|
+
pk: list[str]):
|
|
198
|
+
@router.post(
|
|
199
|
+
f'{path}/rename',
|
|
200
|
+
response_model=resource.get_detailed_output_schema(),
|
|
201
|
+
dependencies=self.dependencies_for(resource, Action.RENAME))
|
|
202
|
+
@docstring_format(resource=resource.doc_name())
|
|
203
|
+
def rename(pk0=Path(alias=pk[0]),
|
|
204
|
+
new_id=Body(alias=pk[0]),
|
|
205
|
+
daos: DAOFactory = self.daos) -> DAOModel:
|
|
206
|
+
"""Renames a {resource}"""
|
|
207
|
+
dao = daos[resource]
|
|
208
|
+
current = dao.get(pk0)
|
|
209
|
+
dao.rename(current, dao.get(new_id))
|
|
210
|
+
return current
|
|
211
|
+
|
|
212
|
+
def _register_merge_endpoint(self,
|
|
213
|
+
router: APIRouter,
|
|
214
|
+
resource: type[Resource],
|
|
215
|
+
path: str,
|
|
216
|
+
pk: list[str]):
|
|
217
|
+
@router.post(
|
|
218
|
+
f'{path}/merge',
|
|
219
|
+
response_model=resource.get_detailed_output_schema(),
|
|
220
|
+
dependencies=self.dependencies_for(resource, Action.RENAME))
|
|
221
|
+
@docstring_format(resource=resource.doc_name())
|
|
222
|
+
def merge(pk0=Path(alias=pk[0]),
|
|
223
|
+
target_id=Body(alias=pk[0]),
|
|
224
|
+
daos: DAOFactory = self.daos) -> DAOModel:
|
|
225
|
+
source = daos[resource].get(pk0)
|
|
226
|
+
# for model in all_models(self.engine):
|
|
227
|
+
# for column in model.get_references_of(resource):
|
|
228
|
+
#daos[type[model]].find(column.name=)
|
|
229
|
+
# if fk.column.table.name == target_table_name and fk.column.name in target_column_values:
|
|
230
|
+
# print(f"Foreign key in table {table.name} references the column '{fk.column.name}' in {target_table.name}")
|
|
231
|
+
# # Retrieve rows in this table that reference the target row
|
|
232
|
+
# conn = engine.connect()
|
|
233
|
+
# condition = (table.c[fk.parent.name] == target_column_values[fk.column.name])
|
|
234
|
+
# result = conn.execute(table.select().where(condition))
|
|
235
|
+
# referencing_rows.extend(result.fetchall())
|
|
236
|
+
# conn.close()
|
|
237
|
+
#
|
|
238
|
+
# return referencing_rows
|
|
239
|
+
|
|
240
|
+
def _register_modify_endpoint(self,
|
|
241
|
+
router: APIRouter,
|
|
242
|
+
resource: type[Resource],
|
|
243
|
+
path: str,
|
|
244
|
+
pk: list[str]):
|
|
245
|
+
@router.put(
|
|
246
|
+
path,
|
|
247
|
+
response_model=resource.get_detailed_output_schema(),
|
|
248
|
+
dependencies=self.dependencies_for(resource, Action.MODIFY))
|
|
249
|
+
@docstring_format(resource=resource.doc_name())
|
|
250
|
+
def update(model: resource.get_update_schema(), # TODO - Remove PK from input schema
|
|
251
|
+
pk0=Path(alias=pk[0]),
|
|
252
|
+
daos: DAOFactory = self.daos) -> DAOModel:
|
|
253
|
+
"""Creates/modifies a {resource}"""
|
|
254
|
+
result = daos[resource].get(pk0)
|
|
255
|
+
result.set_values(**model.model_dump(exclude_unset=True))
|
|
256
|
+
daos[resource].commit(result)
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
def _register_delete_endpoint(self,
|
|
260
|
+
router: APIRouter,
|
|
261
|
+
resource: type[Resource],
|
|
262
|
+
path: str,
|
|
263
|
+
pk: list[str]):
|
|
264
|
+
@router.delete(
|
|
265
|
+
path,
|
|
266
|
+
status_code=204,
|
|
267
|
+
dependencies=self.dependencies_for(resource, Action.DELETE))
|
|
268
|
+
@docstring_format(resource=resource.doc_name())
|
|
269
|
+
def delete(pk0=Path(alias=pk[0]),
|
|
270
|
+
daos: DAOFactory = self.daos) -> None:
|
|
271
|
+
"""Deletes a {resource}"""
|
|
272
|
+
daos[resource].remove(pk0)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from inspect import isclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from daomodel import DAOModel
|
|
5
|
+
from pydantic import create_model
|
|
6
|
+
from sqlmodel import SQLModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def either(preferred: Any, default: type[SQLModel]) -> type[SQLModel]:
|
|
10
|
+
"""
|
|
11
|
+
Returns the preferred type if present, otherwise the default type.
|
|
12
|
+
|
|
13
|
+
:param preferred: The type to return if not None
|
|
14
|
+
:param default: The type to return if the preferred is not a model
|
|
15
|
+
:return: either the preferred type or the default type
|
|
16
|
+
"""
|
|
17
|
+
return preferred if isclass(preferred) and issubclass(preferred, SQLModel) else default
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Resource(DAOModel):
|
|
21
|
+
__abstract__ = True
|
|
22
|
+
_default_schema: type[SQLModel]
|
|
23
|
+
_input_schema: type[SQLModel]
|
|
24
|
+
_update_schema: type[SQLModel]
|
|
25
|
+
_output_schema: type[SQLModel]
|
|
26
|
+
_detailed_output_schema: type[SQLModel]
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def get_resource_path(cls) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Returns the URI path to this resource as defined by the 'path' class variable.
|
|
32
|
+
A default value of `/api/{resource_name} is returned unless overridden.
|
|
33
|
+
|
|
34
|
+
:return: The URI path to be used for this Resource
|
|
35
|
+
"""
|
|
36
|
+
return "/api/" + cls.normalized_name()
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def validate(cls, column_name, value):
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def get_search_schema(cls) -> type[SQLModel]:
|
|
44
|
+
"""Returns an SQLModel representing the searchable fields"""
|
|
45
|
+
def get_field_name(field) -> str:
|
|
46
|
+
"""Constructs the field's name, optionally prepending the table name."""
|
|
47
|
+
field_name = field.name
|
|
48
|
+
if hasattr(field, 'class_') and field.class_ is not cls and hasattr(field, 'table') and field.table.name:
|
|
49
|
+
field_name = f'{field.table.name}_{field_name}'
|
|
50
|
+
return field_name
|
|
51
|
+
def get_field_type(field) -> type:
|
|
52
|
+
field_type = field.type
|
|
53
|
+
if hasattr(field_type, 'impl'):
|
|
54
|
+
field_type = field_type.impl
|
|
55
|
+
return field_type.python_type
|
|
56
|
+
fields = [field[-1] if isinstance(field, tuple) else field for field in cls.get_searchable_properties()]
|
|
57
|
+
field_types = {
|
|
58
|
+
get_field_name(field): (get_field_type(field), None) for field in fields
|
|
59
|
+
}
|
|
60
|
+
return create_model(
|
|
61
|
+
f'{cls.doc_name()}SearchSchema',
|
|
62
|
+
**field_types
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def get_base(cls) -> type[SQLModel]:
|
|
67
|
+
return cls
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def set_default_schema(cls, schema: type[SQLModel]) -> None:
|
|
71
|
+
cls._default_schema = schema
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def get_default_schema(cls) -> type[SQLModel]:
|
|
75
|
+
return either(cls._default_schema, cls)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def set_input_schema(cls, schema: type[SQLModel]) -> None:
|
|
79
|
+
cls._input_schema = schema
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def get_input_schema(cls) -> type[SQLModel]:
|
|
83
|
+
return either(cls._input_schema, cls.get_default_schema())
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def set_update_schema(cls, schema: type[SQLModel]) -> None:
|
|
87
|
+
cls._update_schema = schema
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def get_update_schema(cls) -> type[SQLModel]:
|
|
91
|
+
return either(cls._update_schema, cls.get_input_schema())
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def set_output_schema(cls, schema: type[SQLModel]) -> None:
|
|
95
|
+
cls._output_schema = schema
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def get_output_schema(cls) -> type[SQLModel]:
|
|
99
|
+
return either(cls._output_schema, cls.get_default_schema())
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def set_detailed_output_schema(cls, schema: type[SQLModel]) -> None:
|
|
103
|
+
cls._detailed_output_schema = schema
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def get_detailed_output_schema(cls) -> type[SQLModel]:
|
|
107
|
+
return either(cls._detailed_output_schema, cls.get_output_schema())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import Callable, get_type_hints, Optional
|
|
2
|
+
|
|
3
|
+
from sqlmodel import SQLModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InvalidInput(Exception):
|
|
7
|
+
"""Indicates that the user provided bad input."""
|
|
8
|
+
def __init__(self, detail: str):
|
|
9
|
+
self.detail = detail
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def docstring_format(**kwargs):
|
|
13
|
+
"""
|
|
14
|
+
A decorator that formats the docstring of a function with specified values.
|
|
15
|
+
|
|
16
|
+
:param kwargs: The values to inject into the docstring
|
|
17
|
+
"""
|
|
18
|
+
def decorator(func: Callable):
|
|
19
|
+
func.__doc__ = func.__doc__.format(**kwargs)
|
|
20
|
+
return func
|
|
21
|
+
return decorator
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# TODO: Determine bast way to add test coverage
|
|
25
|
+
def all_optional(superclass: type[SQLModel]):
|
|
26
|
+
"""Creates a new SQLModel for the specified class but having no required fields.
|
|
27
|
+
|
|
28
|
+
:param superclass: The SQLModel of which to make all fields Optional
|
|
29
|
+
:return: The newly wrapped Model
|
|
30
|
+
"""
|
|
31
|
+
class OptionalModel(superclass):
|
|
32
|
+
pass
|
|
33
|
+
for field, field_type in get_type_hints(OptionalModel).items():
|
|
34
|
+
if not isinstance(field_type, type(Optional)):
|
|
35
|
+
OptionalModel.__annotations__[field] = Optional[field_type]
|
|
36
|
+
return OptionalModel
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from daomodel import DAOModel
|
|
6
|
+
from daomodel.fields import Unsearchable, Identifier, ReferenceTo
|
|
7
|
+
from sqlmodel import SQLModel
|
|
8
|
+
|
|
9
|
+
from fast_controller import Resource, Controller
|
|
10
|
+
from fast_controller.resource import either
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Preferred(SQLModel):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Default(SQLModel):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.parametrize("preferred, default, expected", [
|
|
22
|
+
(Preferred, Default, Preferred),
|
|
23
|
+
(SQLModel, Default, SQLModel),
|
|
24
|
+
(DAOModel, Default, DAOModel),
|
|
25
|
+
(Resource, Default, Resource),
|
|
26
|
+
(None, Default, Default),
|
|
27
|
+
(1, Default, Default),
|
|
28
|
+
("test", Default, Default),
|
|
29
|
+
(Controller, Default, Default)
|
|
30
|
+
])
|
|
31
|
+
def test_either(preferred: Any, default: type[SQLModel], expected: type[SQLModel]):
|
|
32
|
+
assert either(preferred, default) == expected
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_get_path():
|
|
36
|
+
class C(Resource):
|
|
37
|
+
pass
|
|
38
|
+
assert C.get_resource_path() == "/api/c"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Author(Resource, table=True):
|
|
42
|
+
name: Identifier[str]
|
|
43
|
+
bio: Optional[str]
|
|
44
|
+
active: bool = True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Book(Resource, table=True):
|
|
48
|
+
title: Identifier[str]
|
|
49
|
+
author_name: Author
|
|
50
|
+
description: Unsearchable[Optional[str]]
|
|
51
|
+
page_count: Optional[int]
|
|
52
|
+
publication_date: Optional[datetime]
|
|
53
|
+
publisher_name: str = ReferenceTo('publisher.name')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Publisher(Resource, table=True):
|
|
57
|
+
name: Identifier[str]
|
|
58
|
+
|
|
59
|
+
class Meta:
|
|
60
|
+
searchable_relations = {
|
|
61
|
+
Book.title, Book.page_count, Book.publication_date,
|
|
62
|
+
(Book, Author.name),
|
|
63
|
+
(Book, Author.active)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_get_search_schema():
|
|
68
|
+
actual = Publisher.get_search_schema()
|
|
69
|
+
assert actual.__name__ == 'PublisherSearchSchema'
|
|
70
|
+
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
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# TODO - Not convinced on this design for schema definitions
|
|
83
|
+
def test_get_base_and_schemas():
|
|
84
|
+
pass
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
from fast_controller import docstring_format
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@docstring_format(key="value")
|
|
7
|
+
def test_docstring_format():
|
|
8
|
+
"""{key}"""
|
|
9
|
+
assert inspect.getdoc(test_docstring_format) == "value"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@docstring_format(key="value")
|
|
13
|
+
def test_docstring_format__empty():
|
|
14
|
+
""""""
|
|
15
|
+
assert inspect.getdoc(test_docstring_format__empty) == ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@docstring_format(key="value")
|
|
19
|
+
def test_docstring_format__multiple_values():
|
|
20
|
+
"""{key}1, {key}2"""
|
|
21
|
+
assert inspect.getdoc(test_docstring_format__multiple_values) == "value1, value2"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@docstring_format(key1="value1", key2="value2")
|
|
25
|
+
def test_docstring_format__multiple_keys():
|
|
26
|
+
"""{key1}, {key2}"""
|
|
27
|
+
assert inspect.getdoc(test_docstring_format__multiple_keys) == "value1, value2"
|
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
from enum import Enum, auto
|
|
2
|
-
from typing import Optional, Callable, Annotated
|
|
3
|
-
|
|
4
|
-
from daomodel import DAOModel
|
|
5
|
-
from daomodel.dao import NotFound
|
|
6
|
-
from daomodel.db import create_engine, DAOFactory
|
|
7
|
-
from daomodel.util import names_of
|
|
8
|
-
from fastapi import FastAPI, APIRouter, Depends, Path, Body, Query
|
|
9
|
-
from fastapi.openapi.models import Response
|
|
10
|
-
|
|
11
|
-
from fast_controller.resource import Resource
|
|
12
|
-
from fast_controller.util import docstring_format, paginated
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
SessionLocal = create_engine()
|
|
16
|
-
def get_daos() -> DAOFactory:
|
|
17
|
-
"""Yields a DAOFactory."""
|
|
18
|
-
with DAOFactory(SessionLocal) as daos:
|
|
19
|
-
yield daos
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class Action(Enum):
|
|
23
|
-
VIEW = auto()
|
|
24
|
-
SEARCH = auto()
|
|
25
|
-
CREATE = auto()
|
|
26
|
-
UPSERT = auto()
|
|
27
|
-
MODIFY = auto()
|
|
28
|
-
RENAME = auto()
|
|
29
|
-
DELETE = auto()
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class Controller:
|
|
33
|
-
def __init__(self, app: Optional[FastAPI] = None):
|
|
34
|
-
self.app = app
|
|
35
|
-
|
|
36
|
-
def init_app(self, app: FastAPI) -> None:
|
|
37
|
-
self.app = app
|
|
38
|
-
|
|
39
|
-
# todo : define both ways to define apis
|
|
40
|
-
@classmethod
|
|
41
|
-
def create_api_group(cls,
|
|
42
|
-
resource: Optional[type[Resource]] = None,
|
|
43
|
-
prefix: Optional[str] = None,
|
|
44
|
-
skip: Optional[set[Action]] = None) -> APIRouter:
|
|
45
|
-
api_router = APIRouter(prefix=prefix if prefix else resource.get_path())
|
|
46
|
-
if resource:
|
|
47
|
-
cls.__register_resource_endpoints(api_router, resource, skip)
|
|
48
|
-
return api_router
|
|
49
|
-
|
|
50
|
-
def register_resource(self,
|
|
51
|
-
resource: type[Resource],
|
|
52
|
-
skip: Optional[set[Action]] = None,
|
|
53
|
-
additional_endpoints: Optional[Callable] = None) -> None:
|
|
54
|
-
if skip is None:
|
|
55
|
-
skip = set()
|
|
56
|
-
api_router = APIRouter(prefix=resource.get_path(), tags=[resource.doc_name()])
|
|
57
|
-
self.__register_resource_endpoints(api_router, resource, skip)
|
|
58
|
-
if additional_endpoints:
|
|
59
|
-
additional_endpoints(api_router)
|
|
60
|
-
self.app.include_router(api_router)
|
|
61
|
-
|
|
62
|
-
@classmethod
|
|
63
|
-
def __register_resource_endpoints(cls, router: APIRouter, resource: type[Resource], skip) -> None:
|
|
64
|
-
if Action.SEARCH not in skip:
|
|
65
|
-
@router.get("/", response_model=list[resource.get_output_schema()])
|
|
66
|
-
@docstring_format(resource=resource.doc_name())
|
|
67
|
-
def search(response: Response,
|
|
68
|
-
filters: Annotated[paginated(resource.get_search_schema()), Query()],
|
|
69
|
-
daos: DAOFactory = Depends(get_daos)) -> list[DAOModel]:
|
|
70
|
-
"""Searches for {resource} by criteria"""
|
|
71
|
-
results = daos[resource].find(**filters.model_dump())
|
|
72
|
-
response.headers["x-total-count"] = str(results.total)
|
|
73
|
-
response.headers["x-page"] = str(results.page)
|
|
74
|
-
response.headers["x-per-page"] = str(results.per_page)
|
|
75
|
-
return results
|
|
76
|
-
|
|
77
|
-
if Action.CREATE not in skip:
|
|
78
|
-
@router.post("/", response_model=resource.get_detailed_output_schema(), status_code=201)
|
|
79
|
-
@docstring_format(resource=resource.doc_name())
|
|
80
|
-
def create(data: Annotated[resource.get_input_schema(), Query()],
|
|
81
|
-
daos: DAOFactory = Depends(get_daos)) -> DAOModel:
|
|
82
|
-
"""Creates a new {resource}"""
|
|
83
|
-
return daos[resource].create_with(**data.model_dump())
|
|
84
|
-
|
|
85
|
-
if Action.UPSERT not in skip:
|
|
86
|
-
@router.put("/", response_model=resource.get_detailed_output_schema())
|
|
87
|
-
@docstring_format(resource=resource.doc_name())
|
|
88
|
-
def upsert(data: Annotated[resource.get_input_schema(), Query()],
|
|
89
|
-
daos: DAOFactory = Depends(get_daos)) -> DAOModel:
|
|
90
|
-
"""Creates/modifies a {resource}"""
|
|
91
|
-
return daos[resource].upsert(**data.model_dump())
|
|
92
|
-
|
|
93
|
-
pk = [p.name for p in resource.get_pk()]
|
|
94
|
-
path = "/".join([""] + ["{" + p + "}" for p in pk])
|
|
95
|
-
|
|
96
|
-
# Caveat: Only up to 5 columns are supported within a primary key.
|
|
97
|
-
# This allows us to avoid resorting to exec() while **kwargs is unsupported for Path variables
|
|
98
|
-
match len(pk):
|
|
99
|
-
case 1:
|
|
100
|
-
if Action.VIEW not in skip:
|
|
101
|
-
@router.get(path, response_model=resource.get_detailed_output_schema())
|
|
102
|
-
@docstring_format(resource=resource.doc_name())
|
|
103
|
-
def view(pk0 = Path(alias=pk[0]),
|
|
104
|
-
daos: DAOFactory = Depends(get_daos)) -> DAOModel:
|
|
105
|
-
"""Retrieves a detailed view of a {resource}"""
|
|
106
|
-
return daos[resource].get(pk0)
|
|
107
|
-
|
|
108
|
-
# Caveat: Rename action is only supported for resources with a single column primary key
|
|
109
|
-
if Action.RENAME not in skip:
|
|
110
|
-
@router.post(path, response_model=resource.get_detailed_output_schema())
|
|
111
|
-
@docstring_format(resource=resource.doc_name())
|
|
112
|
-
def rename(pk0 = Path(alias=pk[0]),
|
|
113
|
-
new_id = Body(alias=pk[0]),
|
|
114
|
-
daos: DAOFactory = Depends(get_daos)) -> DAOModel:
|
|
115
|
-
"""Renames a {resource}"""
|
|
116
|
-
current = daos[resource].get(pk0)
|
|
117
|
-
try:
|
|
118
|
-
existing = daos[resource].get(new_id)
|
|
119
|
-
# todo Set FKs
|
|
120
|
-
for fk in resource.get_fks():
|
|
121
|
-
fk = new_id
|
|
122
|
-
daos[resource].remove_model(current, commit=False)
|
|
123
|
-
current = existing
|
|
124
|
-
except NotFound:
|
|
125
|
-
for p in names_of(current.get_pk()):
|
|
126
|
-
setattr(current, p, new_id)
|
|
127
|
-
# todo Set entire PK
|
|
128
|
-
return daos[resource].update_model(current)
|
|
129
|
-
|
|
130
|
-
# Caveat: Modify action is only supported for resources with a single column primary key
|
|
131
|
-
# Use Upsert instead for multi-column PK resources
|
|
132
|
-
if Action.MODIFY not in skip:
|
|
133
|
-
@router.put(path, response_model=resource.get_detailed_output_schema())
|
|
134
|
-
@docstring_format(resource=resource.doc_name())
|
|
135
|
-
def update(data: Annotated[resource.get_update_schema(), Body()], # TODO - Remove PK from input schema
|
|
136
|
-
pk0 = Path(alias=pk[0]),
|
|
137
|
-
daos: DAOFactory = Depends(get_daos)) -> DAOModel:
|
|
138
|
-
"""Creates/modifies a {resource}"""
|
|
139
|
-
result = daos[resource].get(pk0)
|
|
140
|
-
result.copy_values(**data.model_dump())
|
|
141
|
-
return daos[resource].update(**result.model_dump())
|
|
142
|
-
|
|
143
|
-
if Action.DELETE not in skip:
|
|
144
|
-
@router.delete(path, status_code=204)
|
|
145
|
-
@docstring_format(resource=resource.doc_name())
|
|
146
|
-
def delete(pk0 = Path(alias=pk[0]),
|
|
147
|
-
daos: DAOFactory = Depends(get_daos)) -> None:
|
|
148
|
-
"""Deletes a {resource}"""
|
|
149
|
-
daos[resource].remove(pk0)
|
|
150
|
-
|
|
151
|
-
case 2:
|
|
152
|
-
if Action.VIEW not in skip:
|
|
153
|
-
@router.get(path, response_model=resource.get_detailed_output_schema())
|
|
154
|
-
@docstring_format(resource=resource.doc_name())
|
|
155
|
-
def view(pk0 = Path(alias=pk[0]),
|
|
156
|
-
pk1 = Path(alias=pk[1]),
|
|
157
|
-
daos: DAOFactory = Depends(get_daos)) -> DAOModel:
|
|
158
|
-
"""Retrieves a detailed view of a {resource}"""
|
|
159
|
-
return daos[resource].get(pk0, pk1)
|
|
160
|
-
|
|
161
|
-
if Action.DELETE not in skip:
|
|
162
|
-
@router.delete(path, status_code=204)
|
|
163
|
-
@docstring_format(resource=resource.doc_name())
|
|
164
|
-
def delete(pk0 = Path(alias=pk[0]),
|
|
165
|
-
pk1 = Path(alias=pk[1]),
|
|
166
|
-
daos: DAOFactory = Depends(get_daos)) -> None:
|
|
167
|
-
"""Deletes a {resource}"""
|
|
168
|
-
daos[resource].remove(pk0, pk1)
|
|
169
|
-
|
|
170
|
-
case 3:
|
|
171
|
-
if Action.VIEW not in skip:
|
|
172
|
-
@router.get(path, response_model=resource.get_detailed_output_schema())
|
|
173
|
-
@docstring_format(resource=resource.doc_name())
|
|
174
|
-
def view(pk0 = Path(alias=pk[0]),
|
|
175
|
-
pk1 = Path(alias=pk[1]),
|
|
176
|
-
pk2 = Path(alias=pk[2]),
|
|
177
|
-
daos: DAOFactory = Depends(get_daos)) -> DAOModel:
|
|
178
|
-
"""Retrieves a detailed view of a {resource}"""
|
|
179
|
-
return daos[resource].get(pk0, pk1, pk2)
|
|
180
|
-
|
|
181
|
-
if Action.DELETE not in skip:
|
|
182
|
-
@router.delete(path, status_code=204)
|
|
183
|
-
@docstring_format(resource=resource.doc_name())
|
|
184
|
-
def delete(pk0 = Path(alias=pk[0]),
|
|
185
|
-
pk1 = Path(alias=pk[1]),
|
|
186
|
-
pk2 = Path(alias=pk[2]),
|
|
187
|
-
daos: DAOFactory = Depends(get_daos)) -> None:
|
|
188
|
-
"""Deletes a {resource}"""
|
|
189
|
-
daos[resource].remove(pk0, pk1, pk2)
|
|
190
|
-
|
|
191
|
-
case 4:
|
|
192
|
-
if Action.VIEW not in skip:
|
|
193
|
-
@router.get(path, response_model=resource.get_detailed_output_schema())
|
|
194
|
-
@docstring_format(resource=resource.doc_name())
|
|
195
|
-
def view(pk0 = Path(alias=pk[0]),
|
|
196
|
-
pk1 = Path(alias=pk[1]),
|
|
197
|
-
pk2 = Path(alias=pk[2]),
|
|
198
|
-
pk3 = Path(alias=pk[3]),
|
|
199
|
-
daos: DAOFactory = Depends(get_daos)) -> DAOModel:
|
|
200
|
-
"""Retrieves a detailed view of a {resource}"""
|
|
201
|
-
return daos[resource].get(pk0, pk1, pk2, pk3)
|
|
202
|
-
|
|
203
|
-
if Action.DELETE not in skip:
|
|
204
|
-
@router.delete(path, status_code=204)
|
|
205
|
-
@docstring_format(resource=resource.doc_name())
|
|
206
|
-
def delete(pk0 = Path(alias=pk[0]),
|
|
207
|
-
pk1 = Path(alias=pk[1]),
|
|
208
|
-
pk2 = Path(alias=pk[2]),
|
|
209
|
-
pk3 = Path(alias=pk[3]),
|
|
210
|
-
daos: DAOFactory = Depends(get_daos)) -> None:
|
|
211
|
-
"""Deletes a {resource}"""
|
|
212
|
-
daos[resource].remove(pk0, pk1, pk2, pk3)
|
|
213
|
-
|
|
214
|
-
case 5:
|
|
215
|
-
if Action.VIEW not in skip:
|
|
216
|
-
@router.get(path, response_model=resource.get_detailed_output_schema())
|
|
217
|
-
@docstring_format(resource=resource.doc_name())
|
|
218
|
-
def view(pk0 = Path(alias=pk[0]),
|
|
219
|
-
pk1 = Path(alias=pk[1]),
|
|
220
|
-
pk2 = Path(alias=pk[2]),
|
|
221
|
-
pk3 = Path(alias=pk[3]),
|
|
222
|
-
pk4 = Path(alias=pk[4]),
|
|
223
|
-
daos: DAOFactory = Depends(get_daos)) -> DAOModel:
|
|
224
|
-
"""Retrieves a detailed view of a {resource}"""
|
|
225
|
-
return daos[resource].get(pk0, pk1, pk2, pk3, pk4)
|
|
226
|
-
|
|
227
|
-
if Action.DELETE not in skip:
|
|
228
|
-
@router.delete(path, status_code=204)
|
|
229
|
-
@docstring_format(resource=resource.doc_name())
|
|
230
|
-
def delete(pk0 = Path(alias=pk[0]),
|
|
231
|
-
pk1 = Path(alias=pk[1]),
|
|
232
|
-
pk2 = Path(alias=pk[2]),
|
|
233
|
-
pk3 = Path(alias=pk[3]),
|
|
234
|
-
pk4 = Path(alias=pk[4]),
|
|
235
|
-
daos: DAOFactory = Depends(get_daos)) -> None:
|
|
236
|
-
"""Deletes a {resource}"""
|
|
237
|
-
daos[resource].remove(pk0, pk1, pk2, pk3, pk4)
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
from inspect import isclass
|
|
2
|
-
from typing import ClassVar
|
|
3
|
-
|
|
4
|
-
from daomodel import DAOModel
|
|
5
|
-
from sqlmodel import SQLModel
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def either(preferred, default):
|
|
9
|
-
return preferred if isclass(preferred) and issubclass(preferred, SQLModel) else default
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class Resource(DAOModel):
|
|
13
|
-
__abstract__ = True
|
|
14
|
-
_default_schema: type[SQLModel]
|
|
15
|
-
_search_schema: type[SQLModel]
|
|
16
|
-
_input_schema: type[SQLModel]
|
|
17
|
-
_update_schema: type[SQLModel]
|
|
18
|
-
_output_schema: type[SQLModel]
|
|
19
|
-
_detailed_output_schema: type[SQLModel]
|
|
20
|
-
path: ClassVar[str]
|
|
21
|
-
|
|
22
|
-
@classmethod
|
|
23
|
-
def get_path(cls) -> str:
|
|
24
|
-
return getattr(cls, "path", "/api/" + cls.normalized_name())
|
|
25
|
-
|
|
26
|
-
@classmethod
|
|
27
|
-
def validate(cls, column_name, value):
|
|
28
|
-
return True
|
|
29
|
-
|
|
30
|
-
@classmethod
|
|
31
|
-
def get_base(cls) -> type[SQLModel]:
|
|
32
|
-
return cls
|
|
33
|
-
|
|
34
|
-
@classmethod
|
|
35
|
-
def set_default_schema(cls, schema: type[SQLModel]) -> None:
|
|
36
|
-
cls._default_schema = schema
|
|
37
|
-
|
|
38
|
-
@classmethod
|
|
39
|
-
def get_default_schema(cls) -> type[SQLModel]:
|
|
40
|
-
return cls._default_schema
|
|
41
|
-
|
|
42
|
-
@classmethod
|
|
43
|
-
def set_search_schema(cls, schema: type[SQLModel]) -> None:
|
|
44
|
-
cls._search_schema = schema
|
|
45
|
-
|
|
46
|
-
@classmethod
|
|
47
|
-
def get_search_schema(cls) -> type[SQLModel]:
|
|
48
|
-
return either(cls._search_schema, cls.get_default_schema())
|
|
49
|
-
|
|
50
|
-
@classmethod
|
|
51
|
-
def set_input_schema(cls, schema: type[SQLModel]) -> None:
|
|
52
|
-
cls._input_schema = schema
|
|
53
|
-
|
|
54
|
-
@classmethod
|
|
55
|
-
def get_input_schema(cls) -> type[SQLModel]:
|
|
56
|
-
return either(cls._input_schema, cls.get_default_schema())
|
|
57
|
-
|
|
58
|
-
@classmethod
|
|
59
|
-
def set_update_schema(cls, schema: type[SQLModel]) -> None:
|
|
60
|
-
cls._update_schema = schema
|
|
61
|
-
|
|
62
|
-
@classmethod
|
|
63
|
-
def get_update_schema(cls) -> type[SQLModel]:
|
|
64
|
-
return either(cls._update_schema, cls.get_default_schema())
|
|
65
|
-
|
|
66
|
-
@classmethod
|
|
67
|
-
def set_output_schema(cls, schema: type[SQLModel]) -> None:
|
|
68
|
-
cls._output_schema = schema
|
|
69
|
-
|
|
70
|
-
@classmethod
|
|
71
|
-
def get_output_schema(cls) -> type[SQLModel]:
|
|
72
|
-
return either(cls._output_schema, cls.get_default_schema())
|
|
73
|
-
|
|
74
|
-
@classmethod
|
|
75
|
-
def set_detailed_output_schema(cls, schema: type[SQLModel]) -> None:
|
|
76
|
-
cls._detailed_output_schema = schema
|
|
77
|
-
|
|
78
|
-
@classmethod
|
|
79
|
-
def get_detailed_output_schema(cls) -> type[SQLModel]:
|
|
80
|
-
return either(cls._detailed_output_schema, cls.get_output_schema())
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
from typing import Callable, Optional
|
|
2
|
-
|
|
3
|
-
from sqlmodel import Field, SQLModel
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def docstring_format(**kwargs):
|
|
7
|
-
def decorator(func: Callable):
|
|
8
|
-
func.__doc__ = func.__doc__.format(**kwargs)
|
|
9
|
-
return func
|
|
10
|
-
return decorator
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def paginated(superclass: type[SQLModel]):
|
|
14
|
-
class OptionalPK(superclass, table=True): # table=True is a hack to make PK optional
|
|
15
|
-
pass
|
|
16
|
-
class Paginated(OptionalPK):
|
|
17
|
-
page: Optional[int] = Field(default=None, gt=0)
|
|
18
|
-
per_page: Optional[int] = Field(default=None, gt=0)
|
|
19
|
-
return Paginated
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|