panther 3.8.1__tar.gz → 3.9.0__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.
- {panther-3.8.1 → panther-3.9.0}/PKG-INFO +1 -1
- {panther-3.8.1 → panther-3.9.0}/panther/__init__.py +1 -1
- {panther-3.8.1 → panther-3.9.0}/panther/cli/template.py +2 -0
- {panther-3.8.1 → panther-3.9.0}/panther/main.py +1 -0
- panther-3.9.0/panther/serializer.py +122 -0
- {panther-3.8.1 → panther-3.9.0}/panther.egg-info/PKG-INFO +1 -1
- {panther-3.8.1 → panther-3.9.0}/panther.egg-info/SOURCES.txt +1 -1
- panther-3.9.0/tests/test_serializer.py +317 -0
- panther-3.8.1/panther/serializer.py +0 -47
- panther-3.8.1/tests/test_model_serializer.py +0 -170
- {panther-3.8.1 → panther-3.9.0}/LICENSE +0 -0
- {panther-3.8.1 → panther-3.9.0}/README.md +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/_load_configs.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/_utils.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/app.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/authentications.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/background_tasks.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/base_request.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/base_websocket.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/caching.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/cli/__init__.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/cli/create_command.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/cli/main.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/cli/monitor_command.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/cli/run_command.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/cli/utils.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/configs.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/db/__init__.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/db/connection.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/db/models.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/db/queries/__init__.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/db/queries/mongodb_queries.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/db/queries/pantherdb_queries.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/db/queries/queries.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/db/utils.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/exceptions.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/file_handler.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/logging.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/middlewares/__init__.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/middlewares/base.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/middlewares/db.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/middlewares/redis.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/monitoring.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/panel/__init__.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/panel/apis.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/panel/urls.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/panel/utils.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/permissions.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/request.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/response.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/routings.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/status.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/test.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/throttling.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/utils.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther/websocket.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther.egg-info/dependency_links.txt +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther.egg-info/entry_points.txt +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther.egg-info/requires.txt +0 -0
- {panther-3.8.1 → panther-3.9.0}/panther.egg-info/top_level.txt +0 -0
- {panther-3.8.1 → panther-3.9.0}/pyproject.toml +0 -0
- {panther-3.8.1 → panther-3.9.0}/setup.cfg +0 -0
- {panther-3.8.1 → panther-3.9.0}/setup.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_authentication.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_background_tasks.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_caching.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_cli.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_database.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_mongodb.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_multipart.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_panel_apis.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_request.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_routing.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_run.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_simple_responses.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_status.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_utils.py +0 -0
- {panther-3.8.1 → panther-3.9.0}/tests/test_websockets.py +0 -0
@@ -99,6 +99,7 @@ requirements = """panther==%s
|
|
99
99
|
|
100
100
|
TEMPLATE = {
|
101
101
|
'app': {
|
102
|
+
'__init__.py': '',
|
102
103
|
'apis.py': apis_py,
|
103
104
|
'models.py': models_py,
|
104
105
|
'serializers.py': serializers_py,
|
@@ -106,6 +107,7 @@ TEMPLATE = {
|
|
106
107
|
'urls.py': app_urls_py,
|
107
108
|
},
|
108
109
|
'core': {
|
110
|
+
'__init__.py': '',
|
109
111
|
'configs.py': configs_py,
|
110
112
|
'urls.py': urls_py,
|
111
113
|
},
|
@@ -0,0 +1,122 @@
|
|
1
|
+
import typing
|
2
|
+
|
3
|
+
from pydantic import create_model, BaseModel
|
4
|
+
from pydantic.fields import FieldInfo
|
5
|
+
from pydantic_core._pydantic_core import PydanticUndefined
|
6
|
+
|
7
|
+
from panther.db import Model
|
8
|
+
|
9
|
+
|
10
|
+
class MetaModelSerializer:
|
11
|
+
KNOWN_CONFIGS = ['model', 'fields', 'required_fields']
|
12
|
+
|
13
|
+
def __new__(
|
14
|
+
cls,
|
15
|
+
cls_name: str,
|
16
|
+
bases: tuple[type[typing.Any], ...],
|
17
|
+
namespace: dict[str, typing.Any],
|
18
|
+
**kwargs
|
19
|
+
):
|
20
|
+
if cls_name == 'ModelSerializer':
|
21
|
+
cls.model_serializer = type(cls_name, (), namespace)
|
22
|
+
return super().__new__(cls)
|
23
|
+
|
24
|
+
# 1. Initial Check
|
25
|
+
cls.check_config(cls_name=cls_name, namespace=namespace)
|
26
|
+
config = namespace.pop('Config')
|
27
|
+
|
28
|
+
# 2. Collect `Fields`
|
29
|
+
field_definitions = cls.collect_fields(config=config, namespace=namespace)
|
30
|
+
|
31
|
+
# 3. Collect `pydantic.model_config`
|
32
|
+
model_config = cls.collect_model_config(config=config, namespace=namespace)
|
33
|
+
namespace |= {'model_config': model_config}
|
34
|
+
|
35
|
+
# 4. Create a serializer
|
36
|
+
return create_model(
|
37
|
+
__model_name=cls_name,
|
38
|
+
__module__=namespace['__module__'],
|
39
|
+
__validators__=namespace,
|
40
|
+
__base__=(cls.model_serializer, BaseModel),
|
41
|
+
__doc__=namespace.get('__doc__'),
|
42
|
+
model=(typing.ClassVar, config.model),
|
43
|
+
**field_definitions
|
44
|
+
)
|
45
|
+
|
46
|
+
@classmethod
|
47
|
+
def check_config(cls, cls_name: str, namespace: dict) -> None:
|
48
|
+
module = namespace['__module__']
|
49
|
+
address = f'{module}.{cls_name}'
|
50
|
+
|
51
|
+
# Check `Config`
|
52
|
+
if (config := namespace.get('Config')) is None:
|
53
|
+
msg = f'`class Config` is required in {address}.'
|
54
|
+
raise AttributeError(msg) from None
|
55
|
+
|
56
|
+
# Check `model`
|
57
|
+
if (model := getattr(config, 'model', None)) is None:
|
58
|
+
msg = f'`{cls_name}.Config.model` is required.'
|
59
|
+
raise AttributeError(msg) from None
|
60
|
+
|
61
|
+
# Check `model` type
|
62
|
+
try:
|
63
|
+
if not issubclass(model, Model):
|
64
|
+
msg = f'`{cls_name}.Config.model` is not subclass of `panther.db.Model`.'
|
65
|
+
raise AttributeError(msg) from None
|
66
|
+
except TypeError:
|
67
|
+
msg = f'`{cls_name}.Config.model` is not subclass of `panther.db.Model`.'
|
68
|
+
raise AttributeError(msg) from None
|
69
|
+
|
70
|
+
# Check `fields`
|
71
|
+
if (fields := getattr(config, 'fields', None)) is None:
|
72
|
+
msg = f'`{cls_name}.Config.fields` is required.'
|
73
|
+
raise AttributeError(msg) from None
|
74
|
+
|
75
|
+
for field_name in fields:
|
76
|
+
if field_name not in model.model_fields:
|
77
|
+
msg = f'`{cls_name}.Config.fields.{field_name}` is not valid.'
|
78
|
+
raise AttributeError(msg) from None
|
79
|
+
|
80
|
+
# Check `required_fields`
|
81
|
+
if not hasattr(config, 'required_fields'):
|
82
|
+
config.required_fields = []
|
83
|
+
|
84
|
+
for required in config.required_fields:
|
85
|
+
if required not in config.fields:
|
86
|
+
msg = f'`{cls_name}.Config.required_fields.{required}` should be in `Config.fields` too.'
|
87
|
+
raise AttributeError(msg) from None
|
88
|
+
|
89
|
+
@classmethod
|
90
|
+
def collect_fields(cls, config: typing.Callable, namespace: dict) -> dict:
|
91
|
+
field_definitions = {}
|
92
|
+
|
93
|
+
# Define `fields`
|
94
|
+
for field_name in config.fields:
|
95
|
+
field_definitions[field_name] = (
|
96
|
+
config.model.model_fields[field_name].annotation,
|
97
|
+
config.model.model_fields[field_name]
|
98
|
+
)
|
99
|
+
|
100
|
+
# Apply `required_fields`
|
101
|
+
for required in config.required_fields:
|
102
|
+
field_definitions[required][1].default = PydanticUndefined
|
103
|
+
|
104
|
+
# Collect and Override `Class Fields`
|
105
|
+
for key, value in namespace.pop('__annotations__', {}).items():
|
106
|
+
field_info = namespace.pop(key, FieldInfo(required=True))
|
107
|
+
field_info.annotation = value
|
108
|
+
field_definitions[key] = (value, field_info)
|
109
|
+
|
110
|
+
return field_definitions
|
111
|
+
|
112
|
+
@classmethod
|
113
|
+
def collect_model_config(cls, config: typing.Callable, namespace: dict) -> dict:
|
114
|
+
return {
|
115
|
+
attr: getattr(config, attr) for attr in dir(config)
|
116
|
+
if not attr.startswith('__') and attr not in cls.KNOWN_CONFIGS
|
117
|
+
} | namespace.pop('model_config', {})
|
118
|
+
|
119
|
+
|
120
|
+
class ModelSerializer(metaclass=MetaModelSerializer):
|
121
|
+
def create(self) -> type[Model]:
|
122
|
+
return self.model.insert_one(self.model_dump())
|
@@ -61,13 +61,13 @@ tests/test_background_tasks.py
|
|
61
61
|
tests/test_caching.py
|
62
62
|
tests/test_cli.py
|
63
63
|
tests/test_database.py
|
64
|
-
tests/test_model_serializer.py
|
65
64
|
tests/test_mongodb.py
|
66
65
|
tests/test_multipart.py
|
67
66
|
tests/test_panel_apis.py
|
68
67
|
tests/test_request.py
|
69
68
|
tests/test_routing.py
|
70
69
|
tests/test_run.py
|
70
|
+
tests/test_serializer.py
|
71
71
|
tests/test_simple_responses.py
|
72
72
|
tests/test_status.py
|
73
73
|
tests/test_utils.py
|
@@ -0,0 +1,317 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from unittest import TestCase
|
3
|
+
|
4
|
+
from pydantic import Field, ConfigDict
|
5
|
+
from pydantic import field_validator
|
6
|
+
|
7
|
+
from panther import Panther
|
8
|
+
from panther.app import API
|
9
|
+
from panther.db import Model
|
10
|
+
from panther.request import Request
|
11
|
+
from panther.serializer import ModelSerializer
|
12
|
+
from panther.test import APIClient
|
13
|
+
|
14
|
+
|
15
|
+
class Book(Model):
|
16
|
+
name: str
|
17
|
+
author: str = Field('default_author')
|
18
|
+
pages_count: int = Field(0)
|
19
|
+
|
20
|
+
|
21
|
+
class NotRequiredFieldsSerializer(ModelSerializer):
|
22
|
+
class Config:
|
23
|
+
model = Book
|
24
|
+
fields = ['author', 'pages_count']
|
25
|
+
|
26
|
+
|
27
|
+
class RequiredFieldsSerializer(ModelSerializer):
|
28
|
+
class Config:
|
29
|
+
model = Book
|
30
|
+
fields = ['name', 'author', 'pages_count']
|
31
|
+
|
32
|
+
|
33
|
+
class OnlyRequiredFieldsSerializer(ModelSerializer):
|
34
|
+
class Config:
|
35
|
+
model = Book
|
36
|
+
fields = ['name', 'author', 'pages_count']
|
37
|
+
required_fields = ['author', 'pages_count']
|
38
|
+
|
39
|
+
|
40
|
+
class WithValidatorsSerializer(ModelSerializer):
|
41
|
+
class Config:
|
42
|
+
model = Book
|
43
|
+
fields = ['name', 'author', 'pages_count']
|
44
|
+
required_fields = ['author', 'pages_count']
|
45
|
+
|
46
|
+
@field_validator('name', 'author', 'pages_count')
|
47
|
+
def validate(cls, field):
|
48
|
+
return 'validated'
|
49
|
+
|
50
|
+
|
51
|
+
class WithClassFieldsSerializer(ModelSerializer):
|
52
|
+
age: int = Field(10)
|
53
|
+
|
54
|
+
class Config:
|
55
|
+
model = Book
|
56
|
+
fields = ['name', 'author', 'pages_count']
|
57
|
+
required_fields = ['author', 'pages_count']
|
58
|
+
|
59
|
+
|
60
|
+
@API(input_model=NotRequiredFieldsSerializer)
|
61
|
+
async def not_required(request: Request):
|
62
|
+
return request.validated_data
|
63
|
+
|
64
|
+
|
65
|
+
@API(input_model=RequiredFieldsSerializer)
|
66
|
+
async def required(request: Request):
|
67
|
+
return request.validated_data
|
68
|
+
|
69
|
+
|
70
|
+
@API(input_model=OnlyRequiredFieldsSerializer)
|
71
|
+
async def only_required(request: Request):
|
72
|
+
return request.validated_data
|
73
|
+
|
74
|
+
|
75
|
+
@API(input_model=WithValidatorsSerializer)
|
76
|
+
async def with_validators(request: Request):
|
77
|
+
return request.validated_data
|
78
|
+
|
79
|
+
|
80
|
+
@API(input_model=WithClassFieldsSerializer)
|
81
|
+
async def with_class_fields(request: Request):
|
82
|
+
return request.validated_data
|
83
|
+
|
84
|
+
|
85
|
+
urls = {
|
86
|
+
'not-required': not_required,
|
87
|
+
'required': required,
|
88
|
+
'only-required': only_required,
|
89
|
+
'with-validators': with_validators,
|
90
|
+
'class-fields': with_class_fields,
|
91
|
+
}
|
92
|
+
|
93
|
+
|
94
|
+
class TestModelSerializer(TestCase):
|
95
|
+
DB_PATH = 'test.pdb'
|
96
|
+
|
97
|
+
@classmethod
|
98
|
+
def setUpClass(cls) -> None:
|
99
|
+
global MIDDLEWARES
|
100
|
+
MIDDLEWARES = [
|
101
|
+
('panther.middlewares.db.DatabaseMiddleware', {'url': f'pantherdb://{cls.DB_PATH}'}),
|
102
|
+
]
|
103
|
+
app = Panther(__name__, configs=__name__, urls=urls)
|
104
|
+
cls.client = APIClient(app=app)
|
105
|
+
|
106
|
+
def tearDown(self) -> None:
|
107
|
+
Path(self.DB_PATH).unlink(missing_ok=True)
|
108
|
+
|
109
|
+
# # # Class Usage
|
110
|
+
|
111
|
+
def test_not_required_fields_empty_response(self):
|
112
|
+
payload = {}
|
113
|
+
res = self.client.post('not-required', payload=payload)
|
114
|
+
assert res.status_code == 200
|
115
|
+
assert res.data == {'author': 'default_author', 'pages_count': 0}
|
116
|
+
|
117
|
+
def test_not_required_fields_full_response(self):
|
118
|
+
payload = {
|
119
|
+
'author': 'ali',
|
120
|
+
'pages_count': '12'
|
121
|
+
}
|
122
|
+
res = self.client.post('not-required', payload=payload)
|
123
|
+
assert res.status_code == 200
|
124
|
+
assert res.data == {'author': 'ali', 'pages_count': 12}
|
125
|
+
|
126
|
+
def test_required_fields_error(self):
|
127
|
+
payload = {}
|
128
|
+
res = self.client.post('required', payload=payload)
|
129
|
+
assert res.status_code == 400
|
130
|
+
assert res.data == {'name': 'Field required'}
|
131
|
+
|
132
|
+
def test_required_fields_success(self):
|
133
|
+
payload = {
|
134
|
+
'name': 'how to code',
|
135
|
+
'author': 'ali',
|
136
|
+
'pages_count': '12'
|
137
|
+
}
|
138
|
+
res = self.client.post('required', payload=payload)
|
139
|
+
assert res.status_code == 200
|
140
|
+
assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12}
|
141
|
+
|
142
|
+
def test_only_required_fields_error(self):
|
143
|
+
payload = {}
|
144
|
+
res = self.client.post('only-required', payload=payload)
|
145
|
+
assert res.status_code == 400
|
146
|
+
assert res.data == {'name': 'Field required', 'author': 'Field required', 'pages_count': 'Field required'}
|
147
|
+
|
148
|
+
def test_only_required_fields_success(self):
|
149
|
+
payload = {
|
150
|
+
'name': 'how to code',
|
151
|
+
'author': 'ali',
|
152
|
+
'pages_count': '12'
|
153
|
+
}
|
154
|
+
res = self.client.post('only-required', payload=payload)
|
155
|
+
assert res.status_code == 200
|
156
|
+
assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12}
|
157
|
+
|
158
|
+
def test_with_validators(self):
|
159
|
+
payload = {
|
160
|
+
'name': 'how to code',
|
161
|
+
'author': 'ali',
|
162
|
+
'pages_count': '12'
|
163
|
+
}
|
164
|
+
res = self.client.post('with-validators', payload=payload)
|
165
|
+
assert res.status_code == 200
|
166
|
+
assert res.data == {'name': 'validated', 'author': 'validated', 'pages_count': 'validated'}
|
167
|
+
|
168
|
+
def test_with_class_fields_success(self):
|
169
|
+
# Test Default Value
|
170
|
+
payload1 = {
|
171
|
+
'name': 'how to code',
|
172
|
+
'author': 'ali',
|
173
|
+
'pages_count': '12'
|
174
|
+
}
|
175
|
+
res = self.client.post('class-fields', payload=payload1)
|
176
|
+
assert res.status_code == 200
|
177
|
+
assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12, 'age': 10}
|
178
|
+
|
179
|
+
# Test Validation
|
180
|
+
payload2 = {
|
181
|
+
'name': 'how to code',
|
182
|
+
'author': 'ali',
|
183
|
+
'pages_count': '12',
|
184
|
+
'age': 30
|
185
|
+
}
|
186
|
+
res = self.client.post('class-fields', payload=payload2)
|
187
|
+
assert res.status_code == 200
|
188
|
+
assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12, 'age': 30}
|
189
|
+
|
190
|
+
# # # Class Definition
|
191
|
+
|
192
|
+
def test_define_class_without_meta(self):
|
193
|
+
try:
|
194
|
+
class Serializer0(ModelSerializer):
|
195
|
+
pass
|
196
|
+
except Exception as e:
|
197
|
+
assert isinstance(e, AttributeError)
|
198
|
+
assert e.args[0] == '`class Config` is required in tests.test_serializer.Serializer0.'
|
199
|
+
else:
|
200
|
+
assert False
|
201
|
+
|
202
|
+
def test_define_class_without_model(self):
|
203
|
+
try:
|
204
|
+
class Serializer1(ModelSerializer):
|
205
|
+
class Config:
|
206
|
+
pass
|
207
|
+
except Exception as e:
|
208
|
+
assert isinstance(e, AttributeError)
|
209
|
+
assert e.args[0] == '`Serializer1.Config.model` is required.'
|
210
|
+
else:
|
211
|
+
assert False
|
212
|
+
|
213
|
+
def test_define_class_without_fields(self):
|
214
|
+
try:
|
215
|
+
class Serializer2(ModelSerializer):
|
216
|
+
class Config:
|
217
|
+
model = Book
|
218
|
+
except Exception as e:
|
219
|
+
assert isinstance(e, AttributeError)
|
220
|
+
assert e.args[0] == '`Serializer2.Config.fields` is required.'
|
221
|
+
else:
|
222
|
+
assert False
|
223
|
+
|
224
|
+
def test_define_class_with_invalid_fields(self):
|
225
|
+
try:
|
226
|
+
class Serializer3(ModelSerializer):
|
227
|
+
class Config:
|
228
|
+
model = Book
|
229
|
+
fields = ['ok', 'no']
|
230
|
+
except Exception as e:
|
231
|
+
assert isinstance(e, AttributeError)
|
232
|
+
assert e.args[0] == '`Serializer3.Config.fields.ok` is not valid.'
|
233
|
+
else:
|
234
|
+
assert False
|
235
|
+
|
236
|
+
def test_define_class_with_invalid_required_fields(self):
|
237
|
+
try:
|
238
|
+
class Serializer4(ModelSerializer):
|
239
|
+
class Config:
|
240
|
+
model = Book
|
241
|
+
fields = ['name', 'author']
|
242
|
+
required_fields = ['pages_count']
|
243
|
+
except Exception as e:
|
244
|
+
assert isinstance(e, AttributeError)
|
245
|
+
assert e.args[0] == '`Serializer4.Config.required_fields.pages_count` should be in `Config.fields` too.'
|
246
|
+
else:
|
247
|
+
assert False
|
248
|
+
|
249
|
+
def test_define_class_with_invalid_model(self):
|
250
|
+
try:
|
251
|
+
class Serializer5(ModelSerializer):
|
252
|
+
class Config:
|
253
|
+
model = ModelSerializer
|
254
|
+
fields = ['name', 'author', 'pages_count']
|
255
|
+
except Exception as e:
|
256
|
+
assert isinstance(e, AttributeError)
|
257
|
+
assert e.args[0] == '`Serializer5.Config.model` is not subclass of `panther.db.Model`.'
|
258
|
+
else:
|
259
|
+
assert False
|
260
|
+
|
261
|
+
# # # Serializer Usage
|
262
|
+
def test_with_simple_model_config(self):
|
263
|
+
class Serializer(ModelSerializer):
|
264
|
+
model_config = ConfigDict(str_to_upper=True)
|
265
|
+
|
266
|
+
class Config:
|
267
|
+
model = Book
|
268
|
+
fields = ['name', 'author', 'pages_count']
|
269
|
+
|
270
|
+
serialized = Serializer(name='book', author='AliRn', pages_count='12')
|
271
|
+
assert serialized.name == 'BOOK'
|
272
|
+
assert serialized.author == 'ALIRN'
|
273
|
+
assert serialized.pages_count == 12
|
274
|
+
|
275
|
+
def test_with_inner_model_config(self):
|
276
|
+
class Serializer(ModelSerializer):
|
277
|
+
class Config:
|
278
|
+
str_to_upper = True
|
279
|
+
model = Book
|
280
|
+
fields = ['name', 'author', 'pages_count']
|
281
|
+
|
282
|
+
serialized = Serializer(name='book', author='AliRn', pages_count='12')
|
283
|
+
assert serialized.name == 'BOOK'
|
284
|
+
assert serialized.author == 'ALIRN'
|
285
|
+
assert serialized.pages_count == 12
|
286
|
+
|
287
|
+
def test_with_dual_model_config(self):
|
288
|
+
class Serializer(ModelSerializer):
|
289
|
+
model_config = ConfigDict(str_to_upper=False)
|
290
|
+
|
291
|
+
class Config:
|
292
|
+
str_to_upper = True
|
293
|
+
model = Book
|
294
|
+
fields = ['name', 'author', 'pages_count']
|
295
|
+
|
296
|
+
serialized = Serializer(name='book', author='AliRn', pages_count='12')
|
297
|
+
assert serialized.name == 'book'
|
298
|
+
assert serialized.author == 'AliRn'
|
299
|
+
assert serialized.pages_count == 12
|
300
|
+
|
301
|
+
def test_serializer_doc(self):
|
302
|
+
class Serializer1(ModelSerializer):
|
303
|
+
"""Hello I'm Doc"""
|
304
|
+
class Config:
|
305
|
+
model = Book
|
306
|
+
fields = ['name', 'author', 'pages_count']
|
307
|
+
|
308
|
+
serialized = Serializer1(name='book', author='AliRn', pages_count='12')
|
309
|
+
assert serialized.__doc__ == 'Hello I\'m Doc'
|
310
|
+
|
311
|
+
class Serializer2(ModelSerializer):
|
312
|
+
class Config:
|
313
|
+
model = Book
|
314
|
+
fields = ['name', 'author', 'pages_count']
|
315
|
+
|
316
|
+
serialized = Serializer2(name='book', author='AliRn', pages_count='12')
|
317
|
+
assert serialized.__doc__ is None
|
@@ -1,47 +0,0 @@
|
|
1
|
-
from pydantic import create_model
|
2
|
-
from pydantic_core._pydantic_core import PydanticUndefined
|
3
|
-
|
4
|
-
|
5
|
-
class ModelSerializer:
|
6
|
-
def __new__(cls, *args, model=None, **kwargs):
|
7
|
-
# Check `metaclass`
|
8
|
-
if len(args) == 0:
|
9
|
-
address = f'{cls.__module__}.{cls.__name__}'
|
10
|
-
msg = f"you should not inherit the 'ModelSerializer', you should use it as 'metaclass' -> {address}"
|
11
|
-
raise TypeError(msg)
|
12
|
-
|
13
|
-
model_name = args[0]
|
14
|
-
data = args[2]
|
15
|
-
address = f'{data["__module__"]}.{model_name}'
|
16
|
-
|
17
|
-
# Check `model`
|
18
|
-
if model is None:
|
19
|
-
msg = f"'model' required while using 'ModelSerializer' metaclass -> {address}"
|
20
|
-
raise AttributeError(msg)
|
21
|
-
# Check `fields`
|
22
|
-
if 'fields' not in data:
|
23
|
-
msg = f"'fields' required while using 'ModelSerializer' metaclass. -> {address}"
|
24
|
-
raise AttributeError(msg) from None
|
25
|
-
|
26
|
-
model_fields = model.model_fields
|
27
|
-
field_definitions = {}
|
28
|
-
|
29
|
-
# Collect `fields`
|
30
|
-
for field_name in data['fields']:
|
31
|
-
if field_name not in model_fields:
|
32
|
-
msg = f"'{field_name}' is not in '{model.__name__}' -> {address}"
|
33
|
-
raise AttributeError(msg) from None
|
34
|
-
field_definitions[field_name] = (model_fields[field_name].annotation, model_fields[field_name])
|
35
|
-
|
36
|
-
# Change `required_fields
|
37
|
-
for required in data.get('required_fields', []):
|
38
|
-
if required not in field_definitions:
|
39
|
-
msg = f"'{required}' is in 'required_fields' but not in 'fields' -> {address}"
|
40
|
-
raise AttributeError(msg) from None
|
41
|
-
field_definitions[required][1].default = PydanticUndefined
|
42
|
-
|
43
|
-
# Create Model
|
44
|
-
return create_model(
|
45
|
-
__model_name=model_name,
|
46
|
-
**field_definitions
|
47
|
-
)
|
@@ -1,170 +0,0 @@
|
|
1
|
-
from pathlib import Path
|
2
|
-
from unittest import TestCase
|
3
|
-
|
4
|
-
from pydantic import Field
|
5
|
-
|
6
|
-
from panther import Panther
|
7
|
-
from panther.app import API
|
8
|
-
from panther.db import Model
|
9
|
-
from panther.request import Request
|
10
|
-
from panther.serializer import ModelSerializer
|
11
|
-
from panther.test import APIClient
|
12
|
-
|
13
|
-
|
14
|
-
class Book(Model):
|
15
|
-
name: str
|
16
|
-
author: str = Field('default_author')
|
17
|
-
pages_count: int = Field(0)
|
18
|
-
|
19
|
-
|
20
|
-
class NotRequiredFieldsSerializer(metaclass=ModelSerializer, model=Book):
|
21
|
-
fields = ['author', 'pages_count']
|
22
|
-
|
23
|
-
|
24
|
-
class RequiredFieldsSerializer(metaclass=ModelSerializer, model=Book):
|
25
|
-
fields = ['name', 'author', 'pages_count']
|
26
|
-
|
27
|
-
|
28
|
-
class OnlyRequiredFieldsSerializer(metaclass=ModelSerializer, model=Book):
|
29
|
-
fields = ['name', 'author', 'pages_count']
|
30
|
-
required_fields = ['author', 'pages_count']
|
31
|
-
|
32
|
-
|
33
|
-
@API(input_model=NotRequiredFieldsSerializer)
|
34
|
-
async def not_required(request: Request):
|
35
|
-
return request.validated_data
|
36
|
-
|
37
|
-
|
38
|
-
@API(input_model=RequiredFieldsSerializer)
|
39
|
-
async def required(request: Request):
|
40
|
-
return request.validated_data
|
41
|
-
|
42
|
-
|
43
|
-
@API(input_model=OnlyRequiredFieldsSerializer)
|
44
|
-
async def only_required(request: Request):
|
45
|
-
return request.validated_data
|
46
|
-
|
47
|
-
|
48
|
-
urls = {
|
49
|
-
'not-required': not_required,
|
50
|
-
'required': required,
|
51
|
-
'only-required': only_required,
|
52
|
-
}
|
53
|
-
|
54
|
-
|
55
|
-
class TestModelSerializer(TestCase):
|
56
|
-
DB_PATH = 'test.pdb'
|
57
|
-
|
58
|
-
@classmethod
|
59
|
-
def setUpClass(cls) -> None:
|
60
|
-
global MIDDLEWARES
|
61
|
-
MIDDLEWARES = [
|
62
|
-
('panther.middlewares.db.DatabaseMiddleware', {'url': f'pantherdb://{cls.DB_PATH}'}),
|
63
|
-
]
|
64
|
-
app = Panther(__name__, configs=__name__, urls=urls)
|
65
|
-
cls.client = APIClient(app=app)
|
66
|
-
|
67
|
-
def tearDown(self) -> None:
|
68
|
-
Path(self.DB_PATH).unlink(missing_ok=True)
|
69
|
-
|
70
|
-
def test_not_required_fields_empty_response(self):
|
71
|
-
payload = {}
|
72
|
-
res = self.client.post('not-required', payload=payload)
|
73
|
-
assert res.status_code == 200
|
74
|
-
assert res.data == {'author': 'default_author', 'pages_count': 0}
|
75
|
-
|
76
|
-
def test_not_required_fields_full_response(self):
|
77
|
-
payload = {
|
78
|
-
'author': 'ali',
|
79
|
-
'pages_count': '12'
|
80
|
-
}
|
81
|
-
res = self.client.post('not-required', payload=payload)
|
82
|
-
assert res.status_code == 200
|
83
|
-
assert res.data == {'author': 'ali', 'pages_count': 12}
|
84
|
-
|
85
|
-
def test_required_fields_error(self):
|
86
|
-
payload = {}
|
87
|
-
res = self.client.post('required', payload=payload)
|
88
|
-
assert res.status_code == 400
|
89
|
-
assert res.data == {'name': 'Field required'}
|
90
|
-
|
91
|
-
def test_required_fields_success(self):
|
92
|
-
payload = {
|
93
|
-
'name': 'how to code',
|
94
|
-
'author': 'ali',
|
95
|
-
'pages_count': '12'
|
96
|
-
}
|
97
|
-
res = self.client.post('required', payload=payload)
|
98
|
-
assert res.status_code == 200
|
99
|
-
assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12}
|
100
|
-
|
101
|
-
def test_only_required_fields_error(self):
|
102
|
-
payload = {}
|
103
|
-
res = self.client.post('only-required', payload=payload)
|
104
|
-
assert res.status_code == 400
|
105
|
-
assert res.data == {'name': 'Field required', 'author': 'Field required', 'pages_count': 'Field required'}
|
106
|
-
|
107
|
-
def test_only_required_fields_success(self):
|
108
|
-
payload = {
|
109
|
-
'name': 'how to code',
|
110
|
-
'author': 'ali',
|
111
|
-
'pages_count': '12'
|
112
|
-
}
|
113
|
-
res = self.client.post('only-required', payload=payload)
|
114
|
-
assert res.status_code == 200
|
115
|
-
assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12}
|
116
|
-
|
117
|
-
def test_define_class_without_fields(self):
|
118
|
-
try:
|
119
|
-
class Serializer1(metaclass=ModelSerializer, model=Book):
|
120
|
-
pass
|
121
|
-
except Exception as e:
|
122
|
-
assert isinstance(e, AttributeError)
|
123
|
-
assert e.args[0] == "'fields' required while using 'ModelSerializer' metaclass. -> tests.test_model_serializer.Serializer1"
|
124
|
-
else:
|
125
|
-
assert False
|
126
|
-
|
127
|
-
def test_define_class_with_invalid_fields(self):
|
128
|
-
try:
|
129
|
-
class Serializer2(metaclass=ModelSerializer, model=Book):
|
130
|
-
fields = ['ok', 'no']
|
131
|
-
except Exception as e:
|
132
|
-
assert isinstance(e, AttributeError)
|
133
|
-
assert e.args[0] == "'ok' is not in 'Book' -> tests.test_model_serializer.Serializer2"
|
134
|
-
else:
|
135
|
-
assert False
|
136
|
-
|
137
|
-
def test_define_class_with_invalid_required_fields(self):
|
138
|
-
try:
|
139
|
-
class Serializer3(metaclass=ModelSerializer, model=Book):
|
140
|
-
fields = ['name', 'author']
|
141
|
-
required_fields = ['pages_count']
|
142
|
-
except Exception as e:
|
143
|
-
assert isinstance(e, AttributeError)
|
144
|
-
assert e.args[0] == "'pages_count' is in 'required_fields' but not in 'fields' -> tests.test_model_serializer.Serializer3"
|
145
|
-
else:
|
146
|
-
assert False
|
147
|
-
|
148
|
-
def test_define_class_without_model(self):
|
149
|
-
try:
|
150
|
-
class Serializer4(metaclass=ModelSerializer):
|
151
|
-
fields = ['name', 'author']
|
152
|
-
required_fields = ['pages_count']
|
153
|
-
except Exception as e:
|
154
|
-
assert isinstance(e, AttributeError)
|
155
|
-
assert e.args[0] == "'model' required while using 'ModelSerializer' metaclass -> tests.test_model_serializer.Serializer4"
|
156
|
-
else:
|
157
|
-
assert False
|
158
|
-
|
159
|
-
def test_define_class_without_metaclass(self):
|
160
|
-
class Serializer5(ModelSerializer):
|
161
|
-
fields = ['name', 'author']
|
162
|
-
required_fields = ['pages_count']
|
163
|
-
|
164
|
-
try:
|
165
|
-
Serializer5(name='alice', author='bob')
|
166
|
-
except Exception as e:
|
167
|
-
assert isinstance(e, TypeError)
|
168
|
-
assert e.args[0] == "you should not inherit the 'ModelSerializer', you should use it as 'metaclass' -> tests.test_model_serializer.Serializer5"
|
169
|
-
else:
|
170
|
-
assert False
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|