Fast-Controller 0.0.0.dev2__tar.gz → 0.1.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.1.0b0}/PKG-INFO +1 -1
- {fast_controller-0.0.0.dev2 → fast_controller-0.1.0b0}/fast_controller/__init__.py +14 -8
- {fast_controller-0.0.0.dev2 → fast_controller-0.1.0b0}/fast_controller/resource.py +19 -7
- fast_controller-0.1.0b0/fast_controller/util.py +30 -0
- {fast_controller-0.0.0.dev2 → fast_controller-0.1.0b0}/pyproject.toml +1 -1
- fast_controller-0.1.0b0/tests/test_resource.py +41 -0
- fast_controller-0.1.0b0/tests/test_util.py +27 -0
- 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.1.0b0}/README.md +0 -0
- {fast_controller-0.0.0.dev2 → fast_controller-0.1.0b0}/tests/test_controller.py +0 -0
|
@@ -5,11 +5,11 @@ from daomodel import DAOModel
|
|
|
5
5
|
from daomodel.dao import NotFound
|
|
6
6
|
from daomodel.db import create_engine, DAOFactory
|
|
7
7
|
from daomodel.util import names_of
|
|
8
|
-
from fastapi import FastAPI, APIRouter, Depends, Path, Body, Query
|
|
8
|
+
from fastapi import FastAPI, APIRouter, Depends, Path, Body, Query, Header
|
|
9
9
|
from fastapi.openapi.models import Response
|
|
10
10
|
|
|
11
11
|
from fast_controller.resource import Resource
|
|
12
|
-
from fast_controller.util import docstring_format
|
|
12
|
+
from fast_controller.util import docstring_format
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
SessionLocal = create_engine()
|
|
@@ -42,7 +42,8 @@ class Controller:
|
|
|
42
42
|
resource: Optional[type[Resource]] = None,
|
|
43
43
|
prefix: Optional[str] = None,
|
|
44
44
|
skip: Optional[set[Action]] = None) -> APIRouter:
|
|
45
|
-
api_router = APIRouter(prefix=prefix if prefix else resource.
|
|
45
|
+
api_router = APIRouter(prefix=prefix if prefix else resource.get_resource_path(),
|
|
46
|
+
tags=[resource.doc_name()] if resource else None)
|
|
46
47
|
if resource:
|
|
47
48
|
cls.__register_resource_endpoints(api_router, resource, skip)
|
|
48
49
|
return api_router
|
|
@@ -51,21 +52,26 @@ class Controller:
|
|
|
51
52
|
resource: type[Resource],
|
|
52
53
|
skip: Optional[set[Action]] = None,
|
|
53
54
|
additional_endpoints: Optional[Callable] = None) -> None:
|
|
54
|
-
|
|
55
|
-
skip = set()
|
|
56
|
-
api_router = APIRouter(prefix=resource.get_path(), tags=[resource.doc_name()])
|
|
55
|
+
api_router = APIRouter(prefix=resource.get_resource_path(), tags=[resource.doc_name()])
|
|
57
56
|
self.__register_resource_endpoints(api_router, resource, skip)
|
|
58
57
|
if additional_endpoints:
|
|
59
58
|
additional_endpoints(api_router)
|
|
60
59
|
self.app.include_router(api_router)
|
|
61
60
|
|
|
62
61
|
@classmethod
|
|
63
|
-
def __register_resource_endpoints(cls,
|
|
62
|
+
def __register_resource_endpoints(cls,
|
|
63
|
+
router: APIRouter,
|
|
64
|
+
resource: type[Resource],
|
|
65
|
+
skip: Optional[set[Action]] = None) -> None:
|
|
66
|
+
if skip is None:
|
|
67
|
+
skip = set()
|
|
64
68
|
if Action.SEARCH not in skip:
|
|
65
69
|
@router.get("/", response_model=list[resource.get_output_schema()])
|
|
66
70
|
@docstring_format(resource=resource.doc_name())
|
|
67
71
|
def search(response: Response,
|
|
68
|
-
filters: Annotated[
|
|
72
|
+
filters: Annotated[resource.get_search_schema(), Query()],
|
|
73
|
+
x_page: Optional[int] = Header(default=None, gt=0),
|
|
74
|
+
x_per_page: Optional[int] = Header(default=None, gt=0),
|
|
69
75
|
daos: DAOFactory = Depends(get_daos)) -> list[DAOModel]:
|
|
70
76
|
"""Searches for {resource} by criteria"""
|
|
71
77
|
results = daos[resource].find(**filters.model_dump())
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
from inspect import isclass
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Any
|
|
3
3
|
|
|
4
4
|
from daomodel import DAOModel
|
|
5
5
|
from sqlmodel import SQLModel
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def either(preferred, default):
|
|
8
|
+
def either(preferred: Any, default: type[SQLModel]) -> type[SQLModel]:
|
|
9
|
+
"""
|
|
10
|
+
Returns the preferred type if present, otherwise the default type.
|
|
11
|
+
|
|
12
|
+
:param preferred: The type to return if not None
|
|
13
|
+
:param default: The type to return if the preferred is not a model
|
|
14
|
+
:return: either the preferred type or the default type
|
|
15
|
+
"""
|
|
9
16
|
return preferred if isclass(preferred) and issubclass(preferred, SQLModel) else default
|
|
10
17
|
|
|
11
18
|
|
|
@@ -17,11 +24,16 @@ class Resource(DAOModel):
|
|
|
17
24
|
_update_schema: type[SQLModel]
|
|
18
25
|
_output_schema: type[SQLModel]
|
|
19
26
|
_detailed_output_schema: type[SQLModel]
|
|
20
|
-
path: ClassVar[str]
|
|
21
27
|
|
|
22
28
|
@classmethod
|
|
23
|
-
def
|
|
24
|
-
|
|
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()
|
|
25
37
|
|
|
26
38
|
@classmethod
|
|
27
39
|
def validate(cls, column_name, value):
|
|
@@ -37,7 +49,7 @@ class Resource(DAOModel):
|
|
|
37
49
|
|
|
38
50
|
@classmethod
|
|
39
51
|
def get_default_schema(cls) -> type[SQLModel]:
|
|
40
|
-
return cls._default_schema
|
|
52
|
+
return either(cls._default_schema, cls)
|
|
41
53
|
|
|
42
54
|
@classmethod
|
|
43
55
|
def set_search_schema(cls, schema: type[SQLModel]) -> None:
|
|
@@ -61,7 +73,7 @@ class Resource(DAOModel):
|
|
|
61
73
|
|
|
62
74
|
@classmethod
|
|
63
75
|
def get_update_schema(cls) -> type[SQLModel]:
|
|
64
|
-
return either(cls._update_schema, cls.
|
|
76
|
+
return either(cls._update_schema, cls.get_input_schema())
|
|
65
77
|
|
|
66
78
|
@classmethod
|
|
67
79
|
def set_output_schema(cls, schema: type[SQLModel]) -> None:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Callable, get_type_hints, Optional
|
|
2
|
+
|
|
3
|
+
from sqlmodel import SQLModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def docstring_format(**kwargs):
|
|
7
|
+
"""
|
|
8
|
+
A decorator that formats the docstring of a function with specified values.
|
|
9
|
+
|
|
10
|
+
:param kwargs: The values to inject into the docstring
|
|
11
|
+
"""
|
|
12
|
+
def decorator(func: Callable):
|
|
13
|
+
func.__doc__ = func.__doc__.format(**kwargs)
|
|
14
|
+
return func
|
|
15
|
+
return decorator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# TODO: Determine bast way to add test coverage
|
|
19
|
+
def all_optional(superclass: type[SQLModel]):
|
|
20
|
+
"""Creates a new SQLModel for the specified class but having no required fields.
|
|
21
|
+
|
|
22
|
+
:param superclass: The SQLModel of which to make all fields Optional
|
|
23
|
+
:return: The newly wrapped Model
|
|
24
|
+
"""
|
|
25
|
+
class OptionalModel(superclass):
|
|
26
|
+
pass
|
|
27
|
+
for field, field_type in get_type_hints(OptionalModel).items():
|
|
28
|
+
if not isinstance(field_type, type(Optional)):
|
|
29
|
+
OptionalModel.__annotations__[field] = Optional[field_type]
|
|
30
|
+
return OptionalModel
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from daomodel import DAOModel
|
|
5
|
+
from sqlmodel import SQLModel
|
|
6
|
+
|
|
7
|
+
from fast_controller import Resource, Controller
|
|
8
|
+
from fast_controller.resource import either
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Preferred(SQLModel):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Default(SQLModel):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.parametrize("preferred, default, expected", [
|
|
20
|
+
(Preferred, Default, Preferred),
|
|
21
|
+
(SQLModel, Default, SQLModel),
|
|
22
|
+
(DAOModel, Default, DAOModel),
|
|
23
|
+
(Resource, Default, Resource),
|
|
24
|
+
(None, Default, Default),
|
|
25
|
+
(1, Default, Default),
|
|
26
|
+
("test", Default, Default),
|
|
27
|
+
(Controller, Default, Default)
|
|
28
|
+
])
|
|
29
|
+
def test_either(preferred: Any, default: type[SQLModel], expected: type[SQLModel]):
|
|
30
|
+
assert either(preferred, default) == expected
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_get_path():
|
|
34
|
+
class C(Resource):
|
|
35
|
+
pass
|
|
36
|
+
assert C.get_resource_path() == "/api/c"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# TODO - Not convinced on this design for schema definitions
|
|
40
|
+
def test_get_base_and_schemas():
|
|
41
|
+
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,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
|