toms-fast 0.2.1__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.
- toms_fast-0.2.1.dist-info/METADATA +467 -0
- toms_fast-0.2.1.dist-info/RECORD +60 -0
- toms_fast-0.2.1.dist-info/WHEEL +4 -0
- toms_fast-0.2.1.dist-info/entry_points.txt +2 -0
- tomskit/__init__.py +0 -0
- tomskit/celery/README.md +693 -0
- tomskit/celery/__init__.py +4 -0
- tomskit/celery/celery.py +306 -0
- tomskit/celery/config.py +377 -0
- tomskit/cli/__init__.py +207 -0
- tomskit/cli/__main__.py +8 -0
- tomskit/cli/scaffold.py +123 -0
- tomskit/cli/templates/__init__.py +42 -0
- tomskit/cli/templates/base.py +348 -0
- tomskit/cli/templates/celery.py +101 -0
- tomskit/cli/templates/extensions.py +213 -0
- tomskit/cli/templates/fastapi.py +400 -0
- tomskit/cli/templates/migrations.py +281 -0
- tomskit/cli/templates_config.py +122 -0
- tomskit/logger/README.md +466 -0
- tomskit/logger/__init__.py +4 -0
- tomskit/logger/config.py +106 -0
- tomskit/logger/logger.py +290 -0
- tomskit/py.typed +0 -0
- tomskit/redis/README.md +462 -0
- tomskit/redis/__init__.py +6 -0
- tomskit/redis/config.py +85 -0
- tomskit/redis/redis_pool.py +87 -0
- tomskit/redis/redis_sync.py +66 -0
- tomskit/server/__init__.py +47 -0
- tomskit/server/config.py +117 -0
- tomskit/server/exceptions.py +412 -0
- tomskit/server/middleware.py +371 -0
- tomskit/server/parser.py +312 -0
- tomskit/server/resource.py +464 -0
- tomskit/server/server.py +276 -0
- tomskit/server/type.py +263 -0
- tomskit/sqlalchemy/README.md +590 -0
- tomskit/sqlalchemy/__init__.py +20 -0
- tomskit/sqlalchemy/config.py +125 -0
- tomskit/sqlalchemy/database.py +125 -0
- tomskit/sqlalchemy/pagination.py +359 -0
- tomskit/sqlalchemy/property.py +19 -0
- tomskit/sqlalchemy/sqlalchemy.py +131 -0
- tomskit/sqlalchemy/types.py +32 -0
- tomskit/task/README.md +67 -0
- tomskit/task/__init__.py +4 -0
- tomskit/task/task_manager.py +124 -0
- tomskit/tools/README.md +63 -0
- tomskit/tools/__init__.py +18 -0
- tomskit/tools/config.py +70 -0
- tomskit/tools/warnings.py +37 -0
- tomskit/tools/woker.py +81 -0
- tomskit/utils/README.md +666 -0
- tomskit/utils/README_SERIALIZER.md +644 -0
- tomskit/utils/__init__.py +35 -0
- tomskit/utils/fields.py +434 -0
- tomskit/utils/marshal_utils.py +137 -0
- tomskit/utils/response_utils.py +13 -0
- tomskit/utils/serializers.py +447 -0
tomskit/server/server.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
import warnings
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from starlette.types import ASGIApp
|
|
9
|
+
|
|
10
|
+
from tomskit.server.config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CurrentApp:
|
|
14
|
+
__app_ctx: ContextVar[Optional["FastApp"]] = ContextVar('tomskit.current_app.context', default=None)
|
|
15
|
+
|
|
16
|
+
def set_app(self, app_instance: "FastApp"):
|
|
17
|
+
self.__app_ctx.set(app_instance)
|
|
18
|
+
|
|
19
|
+
def _get_app(self) -> "FastApp":
|
|
20
|
+
"""Get the current application instance, raising error if not set."""
|
|
21
|
+
if (app := self.__app_ctx.get()) is None:
|
|
22
|
+
raise RuntimeError("No application instance is currently set")
|
|
23
|
+
return app
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def app(self) -> "FastApp":
|
|
27
|
+
"""Get the current application instance."""
|
|
28
|
+
return self._get_app()
|
|
29
|
+
|
|
30
|
+
def __call__(self) -> "FastApp":
|
|
31
|
+
"""Get the current application instance when called."""
|
|
32
|
+
return self._get_app()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def config(self):
|
|
36
|
+
"""Get the configuration from the current application instance."""
|
|
37
|
+
return self._get_app().config
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def root_path(self):
|
|
41
|
+
"""Get the root path from the current application instance."""
|
|
42
|
+
return self._get_app().root_path
|
|
43
|
+
|
|
44
|
+
def reset_app(self):
|
|
45
|
+
"""Reset the current application instance."""
|
|
46
|
+
self.__app_ctx.set(None)
|
|
47
|
+
# Also reset FastApp singleton instance for testing
|
|
48
|
+
FastApp.reset_instance()
|
|
49
|
+
|
|
50
|
+
current_app = CurrentApp()
|
|
51
|
+
|
|
52
|
+
class FastApp(FastAPI):
|
|
53
|
+
"""
|
|
54
|
+
A custom FastAPI instance that disables default documentation paths.
|
|
55
|
+
|
|
56
|
+
This class creates a custom FastAPI instance that disables the default
|
|
57
|
+
documentation paths (/docs, /redoc, /openapi) to enhance security and
|
|
58
|
+
reduce unnecessary exposure in production environments.
|
|
59
|
+
|
|
60
|
+
This class also adds configuration support and can initialize the
|
|
61
|
+
current_app context variable for global access to the application instance.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
_instance: Optional["FastApp"] = None
|
|
65
|
+
|
|
66
|
+
def __new__(cls, *args, **kwargs):
|
|
67
|
+
if cls._instance is None:
|
|
68
|
+
cls._instance = super().__new__(cls)
|
|
69
|
+
cls._instance._is_initialized = False
|
|
70
|
+
return cls._instance
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def reset_instance(cls):
|
|
74
|
+
"""Reset the singleton instance. Useful for testing."""
|
|
75
|
+
cls._instance = None
|
|
76
|
+
|
|
77
|
+
def __init__(self, *args, **kwargs):
|
|
78
|
+
if not self._is_initialized:
|
|
79
|
+
# Only disable docs if not explicitly provided by user
|
|
80
|
+
if 'docs_url' not in kwargs:
|
|
81
|
+
kwargs['docs_url'] = None
|
|
82
|
+
if 'redoc_url' not in kwargs:
|
|
83
|
+
kwargs['redoc_url'] = None
|
|
84
|
+
if 'openapi_url' not in kwargs:
|
|
85
|
+
kwargs['openapi_url'] = None
|
|
86
|
+
super().__init__(*args, **kwargs)
|
|
87
|
+
self.config = Config()
|
|
88
|
+
current_app.set_app(self)
|
|
89
|
+
self._is_initialized = True
|
|
90
|
+
else:
|
|
91
|
+
# Even if already initialized, ensure current_app is set
|
|
92
|
+
# This handles cases where reset_app() was called but instance still exists
|
|
93
|
+
current_app.set_app(self)
|
|
94
|
+
|
|
95
|
+
def mount(self, path: str, app: ASGIApp, name: Optional[str] = None):
|
|
96
|
+
"""Mount an ASGI application at the specified path."""
|
|
97
|
+
if hasattr(app, "config"):
|
|
98
|
+
app.config = self.config
|
|
99
|
+
super().mount(path, app, name)
|
|
100
|
+
|
|
101
|
+
def set_environ(self):
|
|
102
|
+
"""Set environment variables from configuration."""
|
|
103
|
+
for key, value in self.config.items():
|
|
104
|
+
if isinstance(value, str):
|
|
105
|
+
os.environ[key] = value
|
|
106
|
+
elif isinstance(value, (int, float, bool)):
|
|
107
|
+
os.environ[key] = str(value)
|
|
108
|
+
elif value is None:
|
|
109
|
+
os.environ[key] = ""
|
|
110
|
+
|
|
111
|
+
def set_app_root_path(self, app_file: str):
|
|
112
|
+
"""Set the application root path based on the app file location."""
|
|
113
|
+
self.app_root_path = os.path.dirname(os.path.abspath(app_file))
|
|
114
|
+
|
|
115
|
+
def add_exception_handler(self, exc_class_or_status_code, handler):
|
|
116
|
+
"""Add an exception handler to the application."""
|
|
117
|
+
super().add_exception_handler(exc_class_or_status_code, handler)
|
|
118
|
+
|
|
119
|
+
class FastModule(FastAPI):
|
|
120
|
+
"""
|
|
121
|
+
FastAPI sub-application module class.
|
|
122
|
+
|
|
123
|
+
Used to create independent sub-application modules, where each module
|
|
124
|
+
can have its own routes, middleware, and configuration. Supports
|
|
125
|
+
automatic Resource registration to simplify module management.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, name: str, *args, **kwargs):
|
|
129
|
+
"""
|
|
130
|
+
Initialize FastModule.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
name: Module name used to identify the module. Must match the
|
|
134
|
+
module name in @register_resource(module=...).
|
|
135
|
+
*args, **kwargs: Additional arguments passed to FastAPI.
|
|
136
|
+
"""
|
|
137
|
+
# Only disable docs if not explicitly provided by user
|
|
138
|
+
if 'docs_url' not in kwargs:
|
|
139
|
+
kwargs['docs_url'] = None
|
|
140
|
+
if 'redoc_url' not in kwargs:
|
|
141
|
+
kwargs['redoc_url'] = None
|
|
142
|
+
if 'openapi_url' not in kwargs:
|
|
143
|
+
kwargs['openapi_url'] = None
|
|
144
|
+
|
|
145
|
+
super().__init__(*args, **kwargs)
|
|
146
|
+
|
|
147
|
+
self.module_name = name
|
|
148
|
+
self._router: Optional[Any] = None # Store the unique ResourceRouter instance
|
|
149
|
+
|
|
150
|
+
# Get configuration from current_app
|
|
151
|
+
try:
|
|
152
|
+
self.config = current_app.config
|
|
153
|
+
except RuntimeError:
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
"FastApp instance must be created and set as current_app before creating FastModule"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def create_router(self, prefix: str = "", **kwargs):
|
|
159
|
+
"""
|
|
160
|
+
Create a ResourceRouter and associate it with this module.
|
|
161
|
+
|
|
162
|
+
A FastModule can only have one ResourceRouter. If a router has
|
|
163
|
+
already been created, calling this method again will raise a ValueError.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
prefix: Route prefix.
|
|
167
|
+
**kwargs: Additional arguments passed to ResourceRouter.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
ResourceRouter: The created ResourceRouter instance.
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
router = module.create_router(prefix="/api/v1")
|
|
174
|
+
"""
|
|
175
|
+
from tomskit.server.resource import ResourceRouter
|
|
176
|
+
|
|
177
|
+
if self._router is not None:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"FastModule '{self.module_name}' already has a router. "
|
|
180
|
+
"A FastModule can only have one ResourceRouter. "
|
|
181
|
+
"If you need multiple routers, create multiple FastModule instances."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
router = ResourceRouter(app=self, prefix=prefix, **kwargs)
|
|
185
|
+
self._router = router
|
|
186
|
+
return router
|
|
187
|
+
|
|
188
|
+
def auto_register_resources(self):
|
|
189
|
+
"""
|
|
190
|
+
Automatically register all Resources marked for this module.
|
|
191
|
+
|
|
192
|
+
This method searches ResourceRegistry for all Resources marked with
|
|
193
|
+
the current module name and automatically registers them to this
|
|
194
|
+
module's ResourceRouter.
|
|
195
|
+
|
|
196
|
+
If the router does not exist, it will be automatically created
|
|
197
|
+
(using default prefix="").
|
|
198
|
+
|
|
199
|
+
Example:
|
|
200
|
+
module = FastModule(name="files")
|
|
201
|
+
module.auto_register_resources() # Auto-register all Resources marked for "files" module
|
|
202
|
+
|
|
203
|
+
# Or create router first, then auto-register
|
|
204
|
+
module = FastModule(name="files")
|
|
205
|
+
router = module.create_router(prefix="/api/v1")
|
|
206
|
+
module.auto_register_resources() # Register to the created router
|
|
207
|
+
"""
|
|
208
|
+
from tomskit.server.resource import ResourceRegistry
|
|
209
|
+
|
|
210
|
+
# Get or create router
|
|
211
|
+
if self._router is None:
|
|
212
|
+
self._router = self.create_router()
|
|
213
|
+
|
|
214
|
+
# Get all Resources for this module from registry
|
|
215
|
+
resources = ResourceRegistry.get_module_resources(self.module_name)
|
|
216
|
+
|
|
217
|
+
if not resources:
|
|
218
|
+
# If no Resources are registered, issue a warning but don't error (allows manual registration)
|
|
219
|
+
warnings.warn(
|
|
220
|
+
f"Module '{self.module_name}' has no Resources registered via @register_resource. "
|
|
221
|
+
f"Ensure Resource classes use @register_resource(module='{self.module_name}', ...) decorator.",
|
|
222
|
+
UserWarning,
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
# Register all Resources
|
|
227
|
+
for resource_info in resources:
|
|
228
|
+
self._router.add_resource(
|
|
229
|
+
resource_cls=resource_info.resource_cls,
|
|
230
|
+
path=resource_info.path,
|
|
231
|
+
tags=resource_info.tags if resource_info.tags else None,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Include router after all resources are registered
|
|
235
|
+
# This ensures router has routes before being included
|
|
236
|
+
self.include_router(self._router)
|
|
237
|
+
|
|
238
|
+
def setup_cors(
|
|
239
|
+
self,
|
|
240
|
+
allow_origins: Optional[list[str]] = None,
|
|
241
|
+
allow_credentials: bool = True,
|
|
242
|
+
allow_methods: Optional[list[str]] = None,
|
|
243
|
+
allow_headers: Optional[list[str]] = None,
|
|
244
|
+
expose_headers: Optional[list[str]] = None,
|
|
245
|
+
):
|
|
246
|
+
"""
|
|
247
|
+
Configure CORS middleware.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
allow_origins: List of allowed origins for cross-origin requests.
|
|
251
|
+
allow_credentials: Whether to allow credentials in cross-origin requests.
|
|
252
|
+
allow_methods: List of allowed HTTP methods.
|
|
253
|
+
allow_headers: List of allowed request headers.
|
|
254
|
+
expose_headers: List of response headers exposed to clients.
|
|
255
|
+
|
|
256
|
+
Example:
|
|
257
|
+
module.setup_cors(
|
|
258
|
+
allow_origins=["http://localhost:3000"],
|
|
259
|
+
allow_credentials=True,
|
|
260
|
+
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
|
261
|
+
)
|
|
262
|
+
"""
|
|
263
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
264
|
+
|
|
265
|
+
self.add_middleware(
|
|
266
|
+
CORSMiddleware,
|
|
267
|
+
allow_origins=allow_origins or [],
|
|
268
|
+
allow_credentials=allow_credentials,
|
|
269
|
+
allow_methods=allow_methods or ["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
|
270
|
+
allow_headers=allow_headers or ["Content-Type", "Authorization"],
|
|
271
|
+
expose_headers=expose_headers or [],
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def add_exception_handler(self, exc_class_or_status_code, handler):
|
|
275
|
+
"""Add an exception handler to the module."""
|
|
276
|
+
super().add_exception_handler(exc_class_or_status_code, handler)
|
tomskit/server/type.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Any, Dict, Type
|
|
3
|
+
from pydantic_core import core_schema
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
def IntRange(minimum: int, maximum: int) -> Type[int]:
|
|
7
|
+
"""
|
|
8
|
+
Factory for a constrained int subtype: value must be in [minimum, maximum].
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
class User(BaseModel):
|
|
12
|
+
age: IntRange(18, 35)
|
|
13
|
+
"""
|
|
14
|
+
if minimum > maximum:
|
|
15
|
+
raise ValueError(f"IntRange: minimum ({minimum}) cannot exceed maximum ({maximum})")
|
|
16
|
+
|
|
17
|
+
class IntRangeType(int):
|
|
18
|
+
@classmethod
|
|
19
|
+
def __get_pydantic_core_schema__(cls, source, handler) -> core_schema.CoreSchema:
|
|
20
|
+
# Use the built-in int_schema with ge/le
|
|
21
|
+
return core_schema.int_schema(strict=True, ge=minimum, le=maximum)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def __modify_json_schema__(cls, schema: Dict[str, Any]) -> None:
|
|
25
|
+
schema.update(
|
|
26
|
+
type="integer",
|
|
27
|
+
minimum=minimum,
|
|
28
|
+
maximum=maximum,
|
|
29
|
+
description=f"Integer in range [{minimum}, {maximum}]"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
IntRangeType.__name__ = f"IntRange_{minimum}_{maximum}"
|
|
33
|
+
return IntRangeType
|
|
34
|
+
|
|
35
|
+
class Boolean:
|
|
36
|
+
"""
|
|
37
|
+
Type that parses "true"/"false"/"1"/"0" into bool.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _parse_bool(v: Any) -> bool:
|
|
42
|
+
if isinstance(v, bool):
|
|
43
|
+
return v
|
|
44
|
+
if v is None or (isinstance(v, str) and not v):
|
|
45
|
+
raise ValueError("Boolean type must be non-null")
|
|
46
|
+
s = str(v).lower()
|
|
47
|
+
if s in ("true", "1"):
|
|
48
|
+
return True
|
|
49
|
+
if s in ("false", "0"):
|
|
50
|
+
return False
|
|
51
|
+
raise ValueError(f"Invalid literal for Boolean(): {v!r}")
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def __get_pydantic_core_schema__(cls, source, handler) -> core_schema.CoreSchema:
|
|
55
|
+
return core_schema.union_schema([
|
|
56
|
+
core_schema.bool_schema(strict=True),
|
|
57
|
+
core_schema.chain_schema([
|
|
58
|
+
core_schema.str_schema(strict=False, coerce_numbers_to_str=True),
|
|
59
|
+
core_schema.no_info_plain_validator_function(cls._parse_bool),
|
|
60
|
+
]),
|
|
61
|
+
], mode='smart')
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def __modify_json_schema__(cls, schema: Dict[str, Any]) -> None:
|
|
65
|
+
schema.update(
|
|
66
|
+
type="boolean",
|
|
67
|
+
description="Accepts true/false/1/0 (case-insensitive)"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PhoneNumber(str):
|
|
72
|
+
"""
|
|
73
|
+
Phone number type for Chinese mobile numbers (11 digits, starts with 1[3-9]).
|
|
74
|
+
"""
|
|
75
|
+
@classmethod
|
|
76
|
+
def __get_pydantic_core_schema__(cls, source, handler) -> core_schema.CoreSchema:
|
|
77
|
+
return core_schema.str_schema(
|
|
78
|
+
strict=True,
|
|
79
|
+
pattern=r"^1[3-9]\d{9}$",
|
|
80
|
+
regex_engine="python-re"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def __modify_json_schema__(cls, schema: Dict[str, Any]) -> None:
|
|
85
|
+
schema.update(
|
|
86
|
+
type="string",
|
|
87
|
+
pattern=r"^1[3-9]\d{9}$",
|
|
88
|
+
description="Chinese mobile phone number"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
class EmailStr(str):
|
|
92
|
+
"""
|
|
93
|
+
Email address type.
|
|
94
|
+
"""
|
|
95
|
+
@classmethod
|
|
96
|
+
def __get_pydantic_core_schema__(cls, source, handler) -> core_schema.CoreSchema:
|
|
97
|
+
return core_schema.str_schema(
|
|
98
|
+
strict=True,
|
|
99
|
+
pattern=r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$",
|
|
100
|
+
regex_engine="python-re"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def __modify_json_schema__(cls, schema: Dict[str, Any]) -> None:
|
|
105
|
+
schema.update(
|
|
106
|
+
type="string",
|
|
107
|
+
format="email",
|
|
108
|
+
description="Email address"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def StrLen(max_length: int, min_length: int = 0) -> Type[str]:
|
|
112
|
+
"""
|
|
113
|
+
Factory for a constrained string subtype: string length must be in [min_length, max_length].
|
|
114
|
+
|
|
115
|
+
Usage:
|
|
116
|
+
class User(BaseModel):
|
|
117
|
+
username: StrLen(3, 20) # between 3-20 characters
|
|
118
|
+
name: StrLen(1, 50) # between 1-50 characters
|
|
119
|
+
"""
|
|
120
|
+
if min_length < 0:
|
|
121
|
+
raise ValueError(f"StrLen: min_length ({min_length}) cannot be negative")
|
|
122
|
+
if min_length > max_length:
|
|
123
|
+
raise ValueError(f"StrLen: min_length ({min_length}) cannot exceed max_length ({max_length})")
|
|
124
|
+
|
|
125
|
+
min_length = max_length if min_length == 0 else min_length
|
|
126
|
+
|
|
127
|
+
class StrLenType(str):
|
|
128
|
+
@classmethod
|
|
129
|
+
def __get_pydantic_core_schema__(cls, source, handler) -> core_schema.CoreSchema:
|
|
130
|
+
return core_schema.str_schema(
|
|
131
|
+
strict=True,
|
|
132
|
+
min_length=min_length,
|
|
133
|
+
max_length=max_length
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def __modify_json_schema__(cls, schema: Dict[str, Any]) -> None:
|
|
138
|
+
schema.update(
|
|
139
|
+
type="string",
|
|
140
|
+
minLength=min_length,
|
|
141
|
+
maxLength=max_length,
|
|
142
|
+
description=f"String with length between {min_length} and {max_length} characters"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
StrLenType.__name__ = f"StrLen_{min_length}_{max_length}"
|
|
146
|
+
return StrLenType
|
|
147
|
+
|
|
148
|
+
def DatetimeString(format: str, argument: str = "datetime") -> Type[str]:
|
|
149
|
+
"""
|
|
150
|
+
Factory for datetime string type with custom format validation.
|
|
151
|
+
|
|
152
|
+
Usage:
|
|
153
|
+
class Event(BaseModel):
|
|
154
|
+
created_at: DatetimeString("%Y-%m-%d %H:%M:%S")
|
|
155
|
+
date_only: DatetimeString("%Y-%m-%d", "date")
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
class DatetimeStrType(str):
|
|
159
|
+
@classmethod
|
|
160
|
+
def __get_pydantic_core_schema__(cls, source, handler) -> core_schema.CoreSchema:
|
|
161
|
+
def validate_datetime(value: str) -> str:
|
|
162
|
+
try:
|
|
163
|
+
datetime.strptime(value, format)
|
|
164
|
+
return value
|
|
165
|
+
except ValueError:
|
|
166
|
+
raise ValueError(f"Invalid {argument}: '{value}', expected format: {format}")
|
|
167
|
+
|
|
168
|
+
return core_schema.chain_schema([
|
|
169
|
+
core_schema.str_schema(),
|
|
170
|
+
core_schema.no_info_plain_validator_function(validate_datetime)
|
|
171
|
+
])
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def __modify_json_schema__(cls, schema: Dict[str, Any]) -> None:
|
|
175
|
+
# 根据格式判断是日期还是日期时间
|
|
176
|
+
is_datetime = any(x in format for x in ["%H", "%M", "%S"])
|
|
177
|
+
|
|
178
|
+
schema.update(
|
|
179
|
+
type="string",
|
|
180
|
+
format="date-time" if is_datetime else "date",
|
|
181
|
+
description=f"{argument.title()} string in format: {format}",
|
|
182
|
+
example=datetime.now().strftime(format)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
DatetimeStrType.__name__ = f"DatetimeString_{argument}"
|
|
186
|
+
return DatetimeStrType
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class UUIDType(str):
|
|
190
|
+
"""
|
|
191
|
+
UUID 类型验证器
|
|
192
|
+
用于验证字符串是否为有效的 UUID 格式
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def __get_pydantic_core_schema__(cls, source, handler) -> core_schema.CoreSchema:
|
|
197
|
+
def validate_uuid(value: Any) -> str:
|
|
198
|
+
"""验证 UUID 格式"""
|
|
199
|
+
if value == "":
|
|
200
|
+
return value
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
# 验证是否为有效的 UUID
|
|
204
|
+
uuid_obj = uuid.UUID(str(value))
|
|
205
|
+
# 返回标准化的 UUID 字符串
|
|
206
|
+
return str(uuid_obj)
|
|
207
|
+
except (ValueError, TypeError):
|
|
208
|
+
raise ValueError(f"{value} is not a valid UUID")
|
|
209
|
+
|
|
210
|
+
return core_schema.no_info_plain_validator_function(
|
|
211
|
+
validate_uuid
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def __modify_json_schema__(cls, schema: Dict[str, Any]) -> None:
|
|
216
|
+
schema.update(
|
|
217
|
+
type="string",
|
|
218
|
+
format="uuid",
|
|
219
|
+
pattern="^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
220
|
+
description="A valid UUID string"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def is_valid_uuid(cls, value: str) -> bool:
|
|
225
|
+
"""
|
|
226
|
+
检查字符串是否为有效的 UUID
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
value: 待验证的字符串
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
bool: 如果是有效的 UUID 返回 True,否则返回 False
|
|
233
|
+
"""
|
|
234
|
+
if not value or value == "":
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
uuid.UUID(value)
|
|
239
|
+
return True
|
|
240
|
+
except (ValueError, TypeError):
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
# 预定义的常用日期时间格式
|
|
244
|
+
class CommonDateFormats:
|
|
245
|
+
"""常用的日期时间格式"""
|
|
246
|
+
ISO_DATE = "%Y-%m-%d" # 2023-12-25
|
|
247
|
+
ISO_DATETIME = "%Y-%m-%d %H:%M:%S" # 2023-12-25 14:30:00
|
|
248
|
+
ISO_DATETIME_MS = "%Y-%m-%d %H:%M:%S.%f" # 2023-12-25 14:30:00.123456
|
|
249
|
+
US_DATE = "%m/%d/%Y" # 12/25/2023
|
|
250
|
+
EU_DATE = "%d/%m/%Y" # 25/12/2023
|
|
251
|
+
TIME_24H = "%H:%M:%S" # 14:30:00
|
|
252
|
+
TIME_12H = "%I:%M:%S %p" # 02:30:00 PM
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# 便捷的预定义类型
|
|
256
|
+
ISODate = DatetimeString(CommonDateFormats.ISO_DATE, "date")
|
|
257
|
+
ISODateTime = DatetimeString(CommonDateFormats.ISO_DATETIME, "datetime")
|
|
258
|
+
USDate = DatetimeString(CommonDateFormats.US_DATE, "date")
|
|
259
|
+
EUDate = DatetimeString(CommonDateFormats.EU_DATE, "date")
|
|
260
|
+
Time24H = DatetimeString(CommonDateFormats.TIME_24H, "time")
|
|
261
|
+
Time12H = DatetimeString(CommonDateFormats.TIME_12H, "time")
|
|
262
|
+
|
|
263
|
+
|