Fast-Controller 0.0.0.dev0__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.
@@ -0,0 +1,237 @@
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)
@@ -0,0 +1,80 @@
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())
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.1
2
+ Name: Fast-Controller
3
+ Version: 0.0.0.dev0
4
+ Summary: The fastest way to a turn your models into a full ReST API
5
+ Keywords: controller,base,rest,api,backend
6
+ Author-Email: Cody M Sommer <bassmastacod@gmail.com>
7
+ License: MIT
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3.7
10
+ Classifier: Programming Language :: Python :: 3.8
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Development Status :: 2 - Pre-Alpha
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Typing :: Typed
21
+ Project-URL: Repository, https://github.com/BassMastaCod/Fast-Controller.git
22
+ Project-URL: Issues, https://github.com/BassMastaCod/Fast-Controller/issues
23
+ Requires-Python: >=3.7
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Fast-Controller
27
+ A fast solution to creating a ReST backend for your Python models.
28
+
29
+ Turn your models into _Resources_ and give them a controller layer.
30
+ Provides standard functionality with limited effort using
31
+ [DAOModel](https://pypi.org/project/DAOModel/)
32
+ and [FastAPI](https://fastapi.tiangolo.com/).
33
+
34
+ ## Supported Actions
35
+ * `search`
36
+ * `create`
37
+ * `upsert`
38
+ * `view`
39
+ * `rename`
40
+ * `modify`
41
+ * `delete`
42
+
43
+ ## Features
44
+ * Expandable controllers so you can add endpoints for additional functionality
45
+ * Built-in validation
46
+ * Ability to pick and choose which actions to support for each resource
47
+
48
+ ## Usage
49
+ ...
50
+
51
+ ## Additional Functionality
52
+ ...
53
+
54
+ ## Caveats
55
+ ...
@@ -0,0 +1,7 @@
1
+ fast_controller-0.0.0.dev0.dist-info/METADATA,sha256=bY1DF17_Iw3ZpOHsGBuDLgVoUy5SbgiHPcRVvlXlEV0,1740
2
+ fast_controller-0.0.0.dev0.dist-info/WHEEL,sha256=thaaA2w1JzcGC48WYufAs8nrYZjJm8LqNfnXFOFyCC4,90
3
+ fast_controller-0.0.0.dev0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
+ fast_controller/__init__.py,sha256=dTd799KT00KJ4TFlb0LXQ5eFL3jFnWsWUAYcH4-3K_4,11860
5
+ fast_controller/resource.py,sha256=DfI10MIy4tq6hbxwzW0HPcOFI6cOU8TpPnfM3xYKYkg,2339
6
+ fast_controller/util.py,sha256=XcnnvnCyajF-gakfy_H1BCiFUqKGRxaoCrv75Uaxyu0,556
7
+ fast_controller-0.0.0.dev0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.4.3)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+
3
+ [gui_scripts]
4
+