core-framework 0.12.9__py3-none-any.whl → 0.12.11__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.
- core/__init__.py +10 -1
- core/app.py +51 -0
- core/auth/views.py +141 -25
- core/config.py +20 -0
- core/validation.py +768 -0
- core/views.py +108 -0
- {core_framework-0.12.9.dist-info → core_framework-0.12.11.dist-info}/METADATA +1 -1
- {core_framework-0.12.9.dist-info → core_framework-0.12.11.dist-info}/RECORD +10 -9
- {core_framework-0.12.9.dist-info → core_framework-0.12.11.dist-info}/WHEEL +0 -0
- {core_framework-0.12.9.dist-info → core_framework-0.12.11.dist-info}/entry_points.txt +0 -0
core/__init__.py
CHANGED
|
@@ -41,6 +41,15 @@ from core.dependencies import Depends, get_db, get_current_user
|
|
|
41
41
|
from core.config import Settings, get_settings
|
|
42
42
|
from core.app import CoreApp
|
|
43
43
|
|
|
44
|
+
# Validation
|
|
45
|
+
from core.validation import (
|
|
46
|
+
SchemaModelValidator,
|
|
47
|
+
SchemaModelMismatchError,
|
|
48
|
+
ValidationWarning,
|
|
49
|
+
validate_schema,
|
|
50
|
+
validate_all_viewsets,
|
|
51
|
+
)
|
|
52
|
+
|
|
44
53
|
# Advanced Fields (UUID7, JSON, etc.)
|
|
45
54
|
from core.fields import (
|
|
46
55
|
uuid7,
|
|
@@ -278,7 +287,7 @@ from core.exceptions import (
|
|
|
278
287
|
MissingDependency,
|
|
279
288
|
)
|
|
280
289
|
|
|
281
|
-
__version__ = "0.12.
|
|
290
|
+
__version__ = "0.12.11"
|
|
282
291
|
__all__ = [
|
|
283
292
|
# Models
|
|
284
293
|
"Model",
|
core/app.py
CHANGED
|
@@ -158,6 +158,13 @@ class CoreApp:
|
|
|
158
158
|
|
|
159
159
|
async def _startup(self) -> None:
|
|
160
160
|
"""Executa tarefas de startup."""
|
|
161
|
+
import logging
|
|
162
|
+
logger = logging.getLogger("core.app")
|
|
163
|
+
|
|
164
|
+
# Schema/Model validation (before database init for fail-fast)
|
|
165
|
+
if getattr(self.settings, "strict_validation", self.settings.debug):
|
|
166
|
+
await self._validate_schemas()
|
|
167
|
+
|
|
161
168
|
# Verifica se deve usar replicas
|
|
162
169
|
if self.settings.has_read_replica:
|
|
163
170
|
# Inicializa com read/write replicas
|
|
@@ -195,6 +202,50 @@ class CoreApp:
|
|
|
195
202
|
if hasattr(result, "__await__"):
|
|
196
203
|
await result
|
|
197
204
|
|
|
205
|
+
async def _validate_schemas(self) -> None:
|
|
206
|
+
"""
|
|
207
|
+
Validate all ViewSet schemas against their models.
|
|
208
|
+
|
|
209
|
+
Called during startup if strict_validation is enabled.
|
|
210
|
+
In DEBUG mode, raises SchemaModelMismatchError on critical issues.
|
|
211
|
+
In production, logs errors but continues.
|
|
212
|
+
"""
|
|
213
|
+
import logging
|
|
214
|
+
logger = logging.getLogger("core.app")
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
from core.views import validate_pending_viewsets
|
|
218
|
+
from core.validation import SchemaModelMismatchError
|
|
219
|
+
|
|
220
|
+
logger.info("Running schema/model validations...")
|
|
221
|
+
|
|
222
|
+
# In debug mode, fail fast on critical issues
|
|
223
|
+
strict = self.settings.debug
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
issues = validate_pending_viewsets(strict=strict)
|
|
227
|
+
|
|
228
|
+
if issues:
|
|
229
|
+
logger.warning(
|
|
230
|
+
f"Schema validation completed with {len(issues)} issues"
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
logger.info("Schema validation passed")
|
|
234
|
+
|
|
235
|
+
except SchemaModelMismatchError as e:
|
|
236
|
+
if self.settings.debug:
|
|
237
|
+
logger.error(f"Schema validation failed: {e}")
|
|
238
|
+
raise RuntimeError(
|
|
239
|
+
f"Schema validation errors (set DEBUG=False to skip):\n{e}"
|
|
240
|
+
) from e
|
|
241
|
+
else:
|
|
242
|
+
logger.error(f"Schema validation errors (ignored): {e}")
|
|
243
|
+
|
|
244
|
+
except ImportError:
|
|
245
|
+
logger.debug("Validation module not available, skipping")
|
|
246
|
+
except Exception as e:
|
|
247
|
+
logger.warning(f"Could not validate schemas: {e}")
|
|
248
|
+
|
|
198
249
|
async def _shutdown(self) -> None:
|
|
199
250
|
"""Executa tarefas de shutdown."""
|
|
200
251
|
# Executa callbacks customizados
|
core/auth/views.py
CHANGED
|
@@ -108,14 +108,22 @@ class AuthViewSet(ViewSet):
|
|
|
108
108
|
|
|
109
109
|
def _get_register_schema(self) -> type:
|
|
110
110
|
"""
|
|
111
|
-
|
|
111
|
+
Get registration schema with STRICT extra fields support.
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
This method validates against the model to determine if fields are
|
|
114
|
+
required (NOT NULL) or optional (nullable).
|
|
115
|
+
|
|
116
|
+
Rules:
|
|
117
|
+
- If model column is NOT NULL and has no default -> REQUIRED in schema
|
|
118
|
+
- If model column is nullable or has default -> OPTIONAL in schema
|
|
115
119
|
|
|
116
120
|
Returns:
|
|
117
121
|
Pydantic schema class for registration
|
|
118
122
|
"""
|
|
123
|
+
import logging
|
|
124
|
+
import warnings
|
|
125
|
+
logger = logging.getLogger("core.auth")
|
|
126
|
+
|
|
119
127
|
# If register_schema was explicitly overridden, use it
|
|
120
128
|
if self.register_schema != BaseRegisterInput:
|
|
121
129
|
return self.register_schema
|
|
@@ -124,32 +132,53 @@ class AuthViewSet(ViewSet):
|
|
|
124
132
|
if not self.extra_register_fields:
|
|
125
133
|
return BaseRegisterInput
|
|
126
134
|
|
|
127
|
-
#
|
|
135
|
+
# Return cached schema if available
|
|
128
136
|
if self._dynamic_register_schema is not None:
|
|
129
137
|
return self._dynamic_register_schema
|
|
130
138
|
|
|
131
|
-
# Build extra fields - all as optional strings by default
|
|
132
|
-
# Users can provide type hints via annotations in User model
|
|
133
|
-
extra_fields = {}
|
|
134
139
|
User = self._get_user_model()
|
|
135
|
-
|
|
140
|
+
|
|
141
|
+
# Get model column info for nullable/required check
|
|
142
|
+
model_columns = {}
|
|
143
|
+
try:
|
|
144
|
+
from sqlalchemy import inspect
|
|
145
|
+
mapper = inspect(User)
|
|
146
|
+
model_columns = {col.name: col for col in mapper.columns}
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.debug(f"Could not inspect User model: {e}")
|
|
149
|
+
|
|
150
|
+
extra_fields = {}
|
|
136
151
|
|
|
137
152
|
for field_name in self.extra_register_fields:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
153
|
+
col = model_columns.get(field_name)
|
|
154
|
+
|
|
155
|
+
if col is not None:
|
|
156
|
+
# Determine type from model
|
|
157
|
+
python_type = self._get_python_type_from_column(col.type)
|
|
158
|
+
|
|
159
|
+
# Check if field is required
|
|
160
|
+
is_nullable = col.nullable
|
|
161
|
+
has_default = col.default is not None or col.server_default is not None
|
|
162
|
+
|
|
163
|
+
if not is_nullable and not has_default:
|
|
164
|
+
# REQUIRED field - use ... (Ellipsis) as default
|
|
165
|
+
extra_fields[field_name] = (python_type, ...)
|
|
166
|
+
logger.info(
|
|
167
|
+
f"Field '{field_name}' is NOT NULL in model, "
|
|
168
|
+
f"adding as REQUIRED to register schema"
|
|
169
|
+
)
|
|
150
170
|
else:
|
|
151
|
-
|
|
171
|
+
# Optional field
|
|
172
|
+
extra_fields[field_name] = (python_type | None, None)
|
|
152
173
|
else:
|
|
174
|
+
# Field not in model columns, warn and make optional
|
|
175
|
+
warnings.warn(
|
|
176
|
+
f"Field '{field_name}' in extra_register_fields "
|
|
177
|
+
f"not found in {User.__name__} model columns. "
|
|
178
|
+
f"Adding as optional str.",
|
|
179
|
+
UserWarning,
|
|
180
|
+
stacklevel=2,
|
|
181
|
+
)
|
|
153
182
|
extra_fields[field_name] = (str | None, None)
|
|
154
183
|
|
|
155
184
|
# Create dynamic model
|
|
@@ -160,14 +189,98 @@ class AuthViewSet(ViewSet):
|
|
|
160
189
|
**extra_fields,
|
|
161
190
|
)
|
|
162
191
|
|
|
163
|
-
# Allow extra fields
|
|
192
|
+
# Allow extra fields (ignore unknown)
|
|
164
193
|
self._dynamic_register_schema.model_config = {
|
|
165
194
|
**BaseRegisterInput.model_config,
|
|
166
|
-
"extra": "ignore",
|
|
195
|
+
"extra": "ignore",
|
|
167
196
|
}
|
|
168
197
|
|
|
169
198
|
return self._dynamic_register_schema
|
|
170
199
|
|
|
200
|
+
def _get_python_type_from_column(self, sa_type) -> type:
|
|
201
|
+
"""
|
|
202
|
+
Convert SQLAlchemy column type to Python type.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
sa_type: SQLAlchemy type instance
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Corresponding Python type
|
|
209
|
+
"""
|
|
210
|
+
from sqlalchemy import String, Integer, Boolean, Float, Text, DateTime, Date
|
|
211
|
+
|
|
212
|
+
type_map = {
|
|
213
|
+
String: str,
|
|
214
|
+
Text: str,
|
|
215
|
+
Integer: int,
|
|
216
|
+
Boolean: bool,
|
|
217
|
+
Float: float,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for sa_cls, py_type in type_map.items():
|
|
221
|
+
if isinstance(sa_type, sa_cls):
|
|
222
|
+
return py_type
|
|
223
|
+
|
|
224
|
+
# Check type name for dialect-specific types
|
|
225
|
+
type_name = type(sa_type).__name__
|
|
226
|
+
if "String" in type_name or "Text" in type_name or "VARCHAR" in type_name:
|
|
227
|
+
return str
|
|
228
|
+
if "Integer" in type_name or "INT" in type_name:
|
|
229
|
+
return int
|
|
230
|
+
if "Boolean" in type_name or "BOOL" in type_name:
|
|
231
|
+
return bool
|
|
232
|
+
if "Float" in type_name or "Numeric" in type_name or "Decimal" in type_name:
|
|
233
|
+
return float
|
|
234
|
+
if "DateTime" in type_name or "Timestamp" in type_name:
|
|
235
|
+
from datetime import datetime
|
|
236
|
+
return datetime
|
|
237
|
+
if "Date" in type_name:
|
|
238
|
+
from datetime import date
|
|
239
|
+
return date
|
|
240
|
+
if "UUID" in type_name:
|
|
241
|
+
from uuid import UUID
|
|
242
|
+
return UUID
|
|
243
|
+
|
|
244
|
+
# Default to str
|
|
245
|
+
return str
|
|
246
|
+
|
|
247
|
+
def _get_extra_field_names(self) -> list[str]:
|
|
248
|
+
"""
|
|
249
|
+
Auto-detect extra fields from register_schema.
|
|
250
|
+
|
|
251
|
+
Compares register_schema fields with BaseRegisterInput to find
|
|
252
|
+
additional fields that need to be passed to create_user().
|
|
253
|
+
|
|
254
|
+
This allows users to define a custom register_schema without
|
|
255
|
+
also having to define extra_register_fields (DRY principle).
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
List of field names that are in register_schema but not in BaseRegisterInput
|
|
259
|
+
|
|
260
|
+
Example:
|
|
261
|
+
class CustomRegisterInput(BaseRegisterInput):
|
|
262
|
+
name: str
|
|
263
|
+
phone: str | None = None
|
|
264
|
+
|
|
265
|
+
class MyAuthViewSet(AuthViewSet):
|
|
266
|
+
register_schema = CustomRegisterInput
|
|
267
|
+
# extra_register_fields not needed!
|
|
268
|
+
|
|
269
|
+
# _get_extra_field_names() returns ["name", "phone"]
|
|
270
|
+
"""
|
|
271
|
+
schema = self._get_register_schema()
|
|
272
|
+
|
|
273
|
+
# Get base fields (email, password)
|
|
274
|
+
base_fields = set(BaseRegisterInput.model_fields.keys())
|
|
275
|
+
|
|
276
|
+
# Get schema fields
|
|
277
|
+
schema_fields = set(schema.model_fields.keys())
|
|
278
|
+
|
|
279
|
+
# Return extra fields (fields in schema but not in base)
|
|
280
|
+
extra = schema_fields - base_fields
|
|
281
|
+
|
|
282
|
+
return list(extra)
|
|
283
|
+
|
|
171
284
|
def _create_tokens(self, user) -> dict:
|
|
172
285
|
"""
|
|
173
286
|
Bug #6 Fix: Create access and refresh tokens using current API.
|
|
@@ -252,9 +365,12 @@ class AuthViewSet(ViewSet):
|
|
|
252
365
|
detail="User with this email already exists"
|
|
253
366
|
)
|
|
254
367
|
|
|
255
|
-
#
|
|
368
|
+
# Extract extra fields for user creation
|
|
369
|
+
# Auto-detect from schema if extra_register_fields not explicitly defined
|
|
370
|
+
extra_field_names = self.extra_register_fields or self._get_extra_field_names()
|
|
371
|
+
|
|
256
372
|
extra_fields = {}
|
|
257
|
-
for field_name in
|
|
373
|
+
for field_name in extra_field_names:
|
|
258
374
|
value = getattr(validated, field_name, None)
|
|
259
375
|
if value is not None:
|
|
260
376
|
extra_fields[field_name] = value
|
core/config.py
CHANGED
|
@@ -76,6 +76,26 @@ class Settings(BaseSettings):
|
|
|
76
76
|
description="Chave secreta para criptografia e tokens",
|
|
77
77
|
)
|
|
78
78
|
|
|
79
|
+
# =========================================================================
|
|
80
|
+
# Validation
|
|
81
|
+
# =========================================================================
|
|
82
|
+
|
|
83
|
+
strict_validation: bool = PydanticField(
|
|
84
|
+
default=True,
|
|
85
|
+
description=(
|
|
86
|
+
"Habilita validação rigorosa de schemas contra models. "
|
|
87
|
+
"Em modo strict, erros críticos (campo NOT NULL opcional no schema) "
|
|
88
|
+
"causam falha no startup em DEBUG mode."
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
validation_fail_fast: bool | None = PydanticField(
|
|
92
|
+
default=None,
|
|
93
|
+
description=(
|
|
94
|
+
"Se True, falha no primeiro erro de validação. "
|
|
95
|
+
"Se None, usa valor de DEBUG."
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
79
99
|
# =========================================================================
|
|
80
100
|
# Database
|
|
81
101
|
# =========================================================================
|
core/validation.py
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sistema de Validação Rigorosa para Core Framework.
|
|
3
|
+
|
|
4
|
+
Este módulo implementa validação automática entre Pydantic schemas e
|
|
5
|
+
SQLAlchemy models, garantindo consistência e prevenindo erros em runtime.
|
|
6
|
+
|
|
7
|
+
Regras de Validação:
|
|
8
|
+
R1: Campo NOT NULL no model = REQUIRED no schema (Erro em strict)
|
|
9
|
+
R2: max_length do model >= max_length do schema (Warning)
|
|
10
|
+
R3: Tipo do schema deve ser compatível com model (Warning)
|
|
11
|
+
R4: Campo extra não existe no model (Warning)
|
|
12
|
+
R5: Validação executada no startup (Fail-fast em DEBUG)
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
from core.validation import SchemaModelValidator
|
|
16
|
+
|
|
17
|
+
# Validar manualmente
|
|
18
|
+
issues = SchemaModelValidator.validate(
|
|
19
|
+
UserInput,
|
|
20
|
+
User,
|
|
21
|
+
strict=True,
|
|
22
|
+
context="UserViewSet"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Ou deixar o framework validar automaticamente no startup
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import warnings
|
|
32
|
+
from typing import TYPE_CHECKING, Any, get_args, get_origin
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from pydantic import BaseModel
|
|
36
|
+
from pydantic.fields import FieldInfo
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("core.validation")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# =============================================================================
|
|
42
|
+
# Exceptions
|
|
43
|
+
# =============================================================================
|
|
44
|
+
|
|
45
|
+
class SchemaModelMismatchError(Exception):
|
|
46
|
+
"""
|
|
47
|
+
Raised when schema and model have incompatible field definitions.
|
|
48
|
+
|
|
49
|
+
This error indicates a critical mismatch that will likely cause
|
|
50
|
+
runtime errors (e.g., database integrity violations).
|
|
51
|
+
"""
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ValidationWarning(UserWarning):
|
|
56
|
+
"""Warning for non-critical schema/model mismatches."""
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# =============================================================================
|
|
61
|
+
# Type Mapping
|
|
62
|
+
# =============================================================================
|
|
63
|
+
|
|
64
|
+
# SQLAlchemy type to Python type mapping
|
|
65
|
+
_SQLALCHEMY_TYPE_MAP: dict[str, type] = {
|
|
66
|
+
"String": str,
|
|
67
|
+
"Text": str,
|
|
68
|
+
"VARCHAR": str,
|
|
69
|
+
"CHAR": str,
|
|
70
|
+
"Integer": int,
|
|
71
|
+
"BigInteger": int,
|
|
72
|
+
"SmallInteger": int,
|
|
73
|
+
"BIGINT": int,
|
|
74
|
+
"SMALLINT": int,
|
|
75
|
+
"INT": int,
|
|
76
|
+
"INTEGER": int,
|
|
77
|
+
"Boolean": bool,
|
|
78
|
+
"BOOLEAN": bool,
|
|
79
|
+
"Float": float,
|
|
80
|
+
"FLOAT": float,
|
|
81
|
+
"Numeric": float,
|
|
82
|
+
"DECIMAL": float,
|
|
83
|
+
"DateTime": "datetime",
|
|
84
|
+
"DATETIME": "datetime",
|
|
85
|
+
"Date": "date",
|
|
86
|
+
"DATE": "date",
|
|
87
|
+
"Time": "time",
|
|
88
|
+
"TIME": "time",
|
|
89
|
+
"UUID": "uuid",
|
|
90
|
+
"JSON": dict,
|
|
91
|
+
"JSONB": dict,
|
|
92
|
+
"ARRAY": list,
|
|
93
|
+
"LargeBinary": bytes,
|
|
94
|
+
"BLOB": bytes,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# =============================================================================
|
|
99
|
+
# Schema Model Validator
|
|
100
|
+
# =============================================================================
|
|
101
|
+
|
|
102
|
+
class SchemaModelValidator:
|
|
103
|
+
"""
|
|
104
|
+
Validates that Pydantic schemas match SQLAlchemy models.
|
|
105
|
+
|
|
106
|
+
This validator ensures consistency between your API contracts (schemas)
|
|
107
|
+
and your database structure (models), preventing common errors like:
|
|
108
|
+
|
|
109
|
+
- Allowing None for NOT NULL columns
|
|
110
|
+
- Accepting strings longer than the column allows
|
|
111
|
+
- Type mismatches between schema and model
|
|
112
|
+
|
|
113
|
+
Rules:
|
|
114
|
+
1. If model field is NOT NULL, schema field MUST be required
|
|
115
|
+
2. If model field has max_length, schema should validate it
|
|
116
|
+
3. If model field has constraints, schema should mirror them
|
|
117
|
+
4. Fields in schema but not in model generate warnings
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
>>> from core.validation import SchemaModelValidator
|
|
121
|
+
>>>
|
|
122
|
+
>>> class User(Model):
|
|
123
|
+
... name = Column(String(100), nullable=False)
|
|
124
|
+
... bio = Column(Text, nullable=True)
|
|
125
|
+
>>>
|
|
126
|
+
>>> class UserInput(BaseModel):
|
|
127
|
+
... name: str | None = None # BUG: Should be required!
|
|
128
|
+
... bio: str | None = None # OK: nullable in model
|
|
129
|
+
>>>
|
|
130
|
+
>>> SchemaModelValidator.validate(UserInput, User, strict=True)
|
|
131
|
+
# Raises SchemaModelMismatchError!
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
# Cache for validated pairs to avoid re-validation
|
|
135
|
+
_validated_pairs: set[tuple[type, type]] = set()
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def validate(
|
|
139
|
+
cls,
|
|
140
|
+
schema: type["BaseModel"],
|
|
141
|
+
model: type,
|
|
142
|
+
*,
|
|
143
|
+
strict: bool = True,
|
|
144
|
+
context: str = "",
|
|
145
|
+
check_output: bool = False,
|
|
146
|
+
) -> list[str]:
|
|
147
|
+
"""
|
|
148
|
+
Validate schema against model.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
schema: Pydantic schema class (BaseModel subclass)
|
|
152
|
+
model: SQLAlchemy model class
|
|
153
|
+
strict: If True, raise error on critical mismatch.
|
|
154
|
+
If False, just warn and continue.
|
|
155
|
+
context: Context for error messages (e.g., "UserViewSet.register")
|
|
156
|
+
check_output: If True, this is an output schema (less strict)
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
List of issues found (warnings and errors)
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
SchemaModelMismatchError: If strict=True and critical issues found
|
|
163
|
+
|
|
164
|
+
Example:
|
|
165
|
+
>>> issues = SchemaModelValidator.validate(
|
|
166
|
+
... UserCreateInput,
|
|
167
|
+
... User,
|
|
168
|
+
... strict=True,
|
|
169
|
+
... context="UserViewSet.create"
|
|
170
|
+
... )
|
|
171
|
+
"""
|
|
172
|
+
# Check cache
|
|
173
|
+
cache_key = (schema, model)
|
|
174
|
+
if cache_key in cls._validated_pairs:
|
|
175
|
+
return []
|
|
176
|
+
|
|
177
|
+
issues: list[str] = []
|
|
178
|
+
critical_issues: list[str] = []
|
|
179
|
+
|
|
180
|
+
# Get schema fields
|
|
181
|
+
schema_fields = cls._get_schema_fields(schema)
|
|
182
|
+
if not schema_fields:
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
# Get model columns
|
|
186
|
+
model_columns = cls._get_model_columns(model)
|
|
187
|
+
if not model_columns:
|
|
188
|
+
logger.debug(f"Could not inspect model {model.__name__}, skipping validation")
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
ctx = f"[{context}] " if context else ""
|
|
192
|
+
|
|
193
|
+
for field_name, field_info in schema_fields.items():
|
|
194
|
+
# Skip fields not in model (could be computed fields)
|
|
195
|
+
if field_name not in model_columns:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
col_info = model_columns[field_name]
|
|
199
|
+
|
|
200
|
+
# Rule 1: NOT NULL check (critical for input schemas)
|
|
201
|
+
if not check_output:
|
|
202
|
+
r1_issue = cls._check_nullable_mismatch(
|
|
203
|
+
field_name, field_info, col_info, ctx
|
|
204
|
+
)
|
|
205
|
+
if r1_issue:
|
|
206
|
+
critical_issues.append(r1_issue)
|
|
207
|
+
|
|
208
|
+
# Rule 2: max_length check
|
|
209
|
+
r2_issue = cls._check_max_length(
|
|
210
|
+
field_name, field_info, col_info, ctx
|
|
211
|
+
)
|
|
212
|
+
if r2_issue:
|
|
213
|
+
issues.append(r2_issue)
|
|
214
|
+
|
|
215
|
+
# Rule 3: Type compatibility check
|
|
216
|
+
r3_issue = cls._check_type_compatibility(
|
|
217
|
+
field_name, field_info, col_info, ctx
|
|
218
|
+
)
|
|
219
|
+
if r3_issue:
|
|
220
|
+
issues.append(r3_issue)
|
|
221
|
+
|
|
222
|
+
# Rule 4: Check for unknown fields (fields in schema not in model)
|
|
223
|
+
for field_name in schema_fields:
|
|
224
|
+
if field_name not in model_columns and field_name not in ("id", "created_at", "updated_at"):
|
|
225
|
+
issues.append(
|
|
226
|
+
f"{ctx}Field '{field_name}' in schema but not in model "
|
|
227
|
+
f"{model.__name__}. This may be intentional for computed fields."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Process issues
|
|
231
|
+
all_issues = critical_issues + issues
|
|
232
|
+
|
|
233
|
+
for issue in issues:
|
|
234
|
+
logger.warning(issue)
|
|
235
|
+
if not strict:
|
|
236
|
+
warnings.warn(issue, ValidationWarning, stacklevel=3)
|
|
237
|
+
|
|
238
|
+
for issue in critical_issues:
|
|
239
|
+
logger.error(issue)
|
|
240
|
+
|
|
241
|
+
# Raise if strict mode and critical issues found
|
|
242
|
+
if strict and critical_issues:
|
|
243
|
+
cls._validated_pairs.discard(cache_key)
|
|
244
|
+
raise SchemaModelMismatchError(
|
|
245
|
+
f"Schema/Model validation failed:\n" + "\n".join(critical_issues)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Cache successful validation
|
|
249
|
+
cls._validated_pairs.add(cache_key)
|
|
250
|
+
|
|
251
|
+
return all_issues
|
|
252
|
+
|
|
253
|
+
@classmethod
|
|
254
|
+
def validate_viewset(
|
|
255
|
+
cls,
|
|
256
|
+
viewset_class: type,
|
|
257
|
+
*,
|
|
258
|
+
strict: bool = True,
|
|
259
|
+
) -> list[str]:
|
|
260
|
+
"""
|
|
261
|
+
Validate all schemas in a ViewSet against its model.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
viewset_class: ViewSet class to validate
|
|
265
|
+
strict: If True, raise on critical issues
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
List of all issues found
|
|
269
|
+
"""
|
|
270
|
+
issues: list[str] = []
|
|
271
|
+
context = viewset_class.__name__
|
|
272
|
+
|
|
273
|
+
model = getattr(viewset_class, "model", None)
|
|
274
|
+
if not model:
|
|
275
|
+
return []
|
|
276
|
+
|
|
277
|
+
# Validate input schema (strict for required fields)
|
|
278
|
+
input_schema = getattr(viewset_class, "input_schema", None)
|
|
279
|
+
if input_schema:
|
|
280
|
+
issues.extend(cls.validate(
|
|
281
|
+
input_schema,
|
|
282
|
+
model,
|
|
283
|
+
strict=strict,
|
|
284
|
+
context=f"{context}.input_schema",
|
|
285
|
+
check_output=False,
|
|
286
|
+
))
|
|
287
|
+
|
|
288
|
+
# Validate output schema (less strict)
|
|
289
|
+
output_schema = getattr(viewset_class, "output_schema", None)
|
|
290
|
+
if output_schema:
|
|
291
|
+
issues.extend(cls.validate(
|
|
292
|
+
output_schema,
|
|
293
|
+
model,
|
|
294
|
+
strict=False, # Output can have fewer/different fields
|
|
295
|
+
context=f"{context}.output_schema",
|
|
296
|
+
check_output=True,
|
|
297
|
+
))
|
|
298
|
+
|
|
299
|
+
# Validate create schema
|
|
300
|
+
create_schema = getattr(viewset_class, "create_schema", None)
|
|
301
|
+
if create_schema and create_schema != input_schema:
|
|
302
|
+
issues.extend(cls.validate(
|
|
303
|
+
create_schema,
|
|
304
|
+
model,
|
|
305
|
+
strict=strict,
|
|
306
|
+
context=f"{context}.create_schema",
|
|
307
|
+
check_output=False,
|
|
308
|
+
))
|
|
309
|
+
|
|
310
|
+
# Validate update schema (optional fields OK)
|
|
311
|
+
update_schema = getattr(viewset_class, "update_schema", None)
|
|
312
|
+
if update_schema and update_schema != input_schema:
|
|
313
|
+
issues.extend(cls.validate(
|
|
314
|
+
update_schema,
|
|
315
|
+
model,
|
|
316
|
+
strict=False, # Update schemas can have optional fields
|
|
317
|
+
context=f"{context}.update_schema",
|
|
318
|
+
check_output=False,
|
|
319
|
+
))
|
|
320
|
+
|
|
321
|
+
return issues
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def clear_cache(cls) -> None:
|
|
325
|
+
"""Clear the validation cache."""
|
|
326
|
+
cls._validated_pairs.clear()
|
|
327
|
+
|
|
328
|
+
# =========================================================================
|
|
329
|
+
# Private Methods
|
|
330
|
+
# =========================================================================
|
|
331
|
+
|
|
332
|
+
@classmethod
|
|
333
|
+
def _get_schema_fields(cls, schema: type["BaseModel"]) -> dict[str, "FieldInfo"]:
|
|
334
|
+
"""Extract field info from Pydantic schema."""
|
|
335
|
+
try:
|
|
336
|
+
return schema.model_fields
|
|
337
|
+
except AttributeError:
|
|
338
|
+
# Pydantic v1 fallback
|
|
339
|
+
try:
|
|
340
|
+
return schema.__fields__
|
|
341
|
+
except AttributeError:
|
|
342
|
+
return {}
|
|
343
|
+
|
|
344
|
+
@classmethod
|
|
345
|
+
def _get_model_columns(cls, model: type) -> dict[str, "ColumnInfo"]:
|
|
346
|
+
"""
|
|
347
|
+
Extract column info from SQLAlchemy model.
|
|
348
|
+
|
|
349
|
+
Returns dict with column name -> ColumnInfo containing:
|
|
350
|
+
- nullable: bool
|
|
351
|
+
- max_length: int | None
|
|
352
|
+
- python_type: type
|
|
353
|
+
- sa_type: SQLAlchemy type instance
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
from sqlalchemy import inspect
|
|
357
|
+
from sqlalchemy.orm import Mapper
|
|
358
|
+
|
|
359
|
+
mapper: Mapper = inspect(model)
|
|
360
|
+
columns = {}
|
|
361
|
+
|
|
362
|
+
for col in mapper.columns:
|
|
363
|
+
columns[col.name] = ColumnInfo(
|
|
364
|
+
name=col.name,
|
|
365
|
+
nullable=col.nullable,
|
|
366
|
+
max_length=getattr(col.type, "length", None),
|
|
367
|
+
python_type=cls._get_python_type(col.type),
|
|
368
|
+
sa_type=col.type,
|
|
369
|
+
has_default=col.default is not None or col.server_default is not None,
|
|
370
|
+
is_primary_key=col.primary_key,
|
|
371
|
+
is_autoincrement=getattr(col, "autoincrement", False),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
return columns
|
|
375
|
+
except Exception as e:
|
|
376
|
+
logger.debug(f"Could not inspect model: {e}")
|
|
377
|
+
return {}
|
|
378
|
+
|
|
379
|
+
@classmethod
|
|
380
|
+
def _get_python_type(cls, sa_type: Any) -> type:
|
|
381
|
+
"""Convert SQLAlchemy type to Python type."""
|
|
382
|
+
type_name = type(sa_type).__name__
|
|
383
|
+
|
|
384
|
+
# Check direct mapping
|
|
385
|
+
if type_name in _SQLALCHEMY_TYPE_MAP:
|
|
386
|
+
mapped = _SQLALCHEMY_TYPE_MAP[type_name]
|
|
387
|
+
if isinstance(mapped, str):
|
|
388
|
+
# Handle special types
|
|
389
|
+
if mapped == "datetime":
|
|
390
|
+
from datetime import datetime
|
|
391
|
+
return datetime
|
|
392
|
+
elif mapped == "date":
|
|
393
|
+
from datetime import date
|
|
394
|
+
return date
|
|
395
|
+
elif mapped == "time":
|
|
396
|
+
from datetime import time
|
|
397
|
+
return time
|
|
398
|
+
elif mapped == "uuid":
|
|
399
|
+
from uuid import UUID
|
|
400
|
+
return UUID
|
|
401
|
+
return mapped
|
|
402
|
+
|
|
403
|
+
# Try to get impl type
|
|
404
|
+
try:
|
|
405
|
+
impl = getattr(sa_type, "impl", None)
|
|
406
|
+
if impl:
|
|
407
|
+
return cls._get_python_type(impl)
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
|
|
411
|
+
# Default to Any
|
|
412
|
+
return Any
|
|
413
|
+
|
|
414
|
+
@classmethod
|
|
415
|
+
def _check_nullable_mismatch(
|
|
416
|
+
cls,
|
|
417
|
+
field_name: str,
|
|
418
|
+
field_info: "FieldInfo",
|
|
419
|
+
col_info: "ColumnInfo",
|
|
420
|
+
ctx: str,
|
|
421
|
+
) -> str | None:
|
|
422
|
+
"""
|
|
423
|
+
Rule 1: Check if NOT NULL column has optional schema field.
|
|
424
|
+
|
|
425
|
+
This is a CRITICAL issue - will cause IntegrityError at runtime.
|
|
426
|
+
"""
|
|
427
|
+
# Skip primary keys and auto-generated fields
|
|
428
|
+
if col_info.is_primary_key or col_info.is_autoincrement:
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
# Skip fields with defaults
|
|
432
|
+
if col_info.has_default:
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
# Check if model requires the field (NOT NULL, no default)
|
|
436
|
+
model_requires = not col_info.nullable and not col_info.has_default
|
|
437
|
+
|
|
438
|
+
# Check if schema makes field optional
|
|
439
|
+
schema_optional = not cls._is_field_required(field_info)
|
|
440
|
+
|
|
441
|
+
if model_requires and schema_optional:
|
|
442
|
+
return (
|
|
443
|
+
f"{ctx}CRITICAL: Field '{field_name}' is NOT NULL in model "
|
|
444
|
+
f"but OPTIONAL in schema. This WILL cause IntegrityError! "
|
|
445
|
+
f"Make the field required in the schema."
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
@classmethod
|
|
451
|
+
def _check_max_length(
|
|
452
|
+
cls,
|
|
453
|
+
field_name: str,
|
|
454
|
+
field_info: "FieldInfo",
|
|
455
|
+
col_info: "ColumnInfo",
|
|
456
|
+
ctx: str,
|
|
457
|
+
) -> str | None:
|
|
458
|
+
"""
|
|
459
|
+
Rule 2: Check if schema allows values longer than model accepts.
|
|
460
|
+
"""
|
|
461
|
+
if not col_info.max_length:
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
schema_max = cls._get_schema_max_length(field_info)
|
|
465
|
+
|
|
466
|
+
if schema_max is None:
|
|
467
|
+
return (
|
|
468
|
+
f"{ctx}Field '{field_name}' has max_length={col_info.max_length} "
|
|
469
|
+
f"in model but no length validation in schema. "
|
|
470
|
+
f"Add max_length constraint to prevent truncation."
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
if schema_max > col_info.max_length:
|
|
474
|
+
return (
|
|
475
|
+
f"{ctx}Field '{field_name}' allows {schema_max} chars in schema "
|
|
476
|
+
f"but model only accepts {col_info.max_length}. "
|
|
477
|
+
f"Values may be truncated or cause errors."
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
@classmethod
|
|
483
|
+
def _check_type_compatibility(
|
|
484
|
+
cls,
|
|
485
|
+
field_name: str,
|
|
486
|
+
field_info: "FieldInfo",
|
|
487
|
+
col_info: "ColumnInfo",
|
|
488
|
+
ctx: str,
|
|
489
|
+
) -> str | None:
|
|
490
|
+
"""
|
|
491
|
+
Rule 3: Check if schema type is compatible with model type.
|
|
492
|
+
"""
|
|
493
|
+
schema_type = cls._get_schema_type(field_info)
|
|
494
|
+
model_type = col_info.python_type
|
|
495
|
+
|
|
496
|
+
if schema_type is None or model_type is Any:
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
# Handle Optional types
|
|
500
|
+
if get_origin(schema_type) is type(None):
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
# Unwrap Optional/Union
|
|
504
|
+
schema_type = cls._unwrap_optional(schema_type)
|
|
505
|
+
|
|
506
|
+
if schema_type is Any:
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
# Check compatibility
|
|
510
|
+
if not cls._types_compatible(schema_type, model_type):
|
|
511
|
+
return (
|
|
512
|
+
f"{ctx}Field '{field_name}' has type {model_type.__name__} in model "
|
|
513
|
+
f"but {schema_type} in schema. Types may be incompatible."
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
@classmethod
|
|
519
|
+
def _is_field_required(cls, field_info: "FieldInfo") -> bool:
|
|
520
|
+
"""Check if Pydantic field is required."""
|
|
521
|
+
try:
|
|
522
|
+
# Pydantic v2
|
|
523
|
+
return field_info.is_required()
|
|
524
|
+
except (AttributeError, TypeError):
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
# Check default
|
|
529
|
+
if field_info.default is not None:
|
|
530
|
+
return False
|
|
531
|
+
if field_info.default_factory is not None:
|
|
532
|
+
return False
|
|
533
|
+
# Check if explicitly required
|
|
534
|
+
from pydantic_core import PydanticUndefined
|
|
535
|
+
return field_info.default is PydanticUndefined
|
|
536
|
+
except Exception:
|
|
537
|
+
pass
|
|
538
|
+
|
|
539
|
+
# Fallback: assume required if no default
|
|
540
|
+
return getattr(field_info, "default", ...) is ...
|
|
541
|
+
|
|
542
|
+
@classmethod
|
|
543
|
+
def _get_schema_max_length(cls, field_info: "FieldInfo") -> int | None:
|
|
544
|
+
"""Extract max_length from Pydantic field metadata."""
|
|
545
|
+
# Check metadata
|
|
546
|
+
metadata = getattr(field_info, "metadata", []) or []
|
|
547
|
+
for meta in metadata:
|
|
548
|
+
if hasattr(meta, "max_length"):
|
|
549
|
+
return meta.max_length
|
|
550
|
+
|
|
551
|
+
# Check json_schema_extra
|
|
552
|
+
json_extra = getattr(field_info, "json_schema_extra", None)
|
|
553
|
+
if json_extra and isinstance(json_extra, dict):
|
|
554
|
+
return json_extra.get("maxLength")
|
|
555
|
+
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
@classmethod
|
|
559
|
+
def _get_schema_type(cls, field_info: "FieldInfo") -> type | None:
|
|
560
|
+
"""Extract type from Pydantic field."""
|
|
561
|
+
try:
|
|
562
|
+
return field_info.annotation
|
|
563
|
+
except AttributeError:
|
|
564
|
+
return getattr(field_info, "outer_type_", None)
|
|
565
|
+
|
|
566
|
+
@classmethod
|
|
567
|
+
def _unwrap_optional(cls, t: type) -> type:
|
|
568
|
+
"""Unwrap Optional[X] or X | None to X."""
|
|
569
|
+
from types import UnionType
|
|
570
|
+
|
|
571
|
+
origin = get_origin(t)
|
|
572
|
+
|
|
573
|
+
# Handle Union types (including X | None)
|
|
574
|
+
if origin is UnionType or (hasattr(t, "__class__") and t.__class__ is UnionType):
|
|
575
|
+
args = get_args(t)
|
|
576
|
+
non_none = [a for a in args if a is not type(None)]
|
|
577
|
+
if len(non_none) == 1:
|
|
578
|
+
return non_none[0]
|
|
579
|
+
return t
|
|
580
|
+
|
|
581
|
+
# Handle typing.Union
|
|
582
|
+
try:
|
|
583
|
+
from typing import Union
|
|
584
|
+
if origin is Union:
|
|
585
|
+
args = get_args(t)
|
|
586
|
+
non_none = [a for a in args if a is not type(None)]
|
|
587
|
+
if len(non_none) == 1:
|
|
588
|
+
return non_none[0]
|
|
589
|
+
except Exception:
|
|
590
|
+
pass
|
|
591
|
+
|
|
592
|
+
return t
|
|
593
|
+
|
|
594
|
+
@classmethod
|
|
595
|
+
def _types_compatible(cls, schema_type: type, model_type: type) -> bool:
|
|
596
|
+
"""Check if schema type is compatible with model type."""
|
|
597
|
+
# Same type
|
|
598
|
+
if schema_type is model_type:
|
|
599
|
+
return True
|
|
600
|
+
|
|
601
|
+
# Both are string-like
|
|
602
|
+
if schema_type is str and model_type is str:
|
|
603
|
+
return True
|
|
604
|
+
|
|
605
|
+
# Both are numeric
|
|
606
|
+
numeric = (int, float)
|
|
607
|
+
if schema_type in numeric and model_type in numeric:
|
|
608
|
+
return True
|
|
609
|
+
|
|
610
|
+
# Check subclass
|
|
611
|
+
try:
|
|
612
|
+
if isinstance(schema_type, type) and isinstance(model_type, type):
|
|
613
|
+
return issubclass(schema_type, model_type) or issubclass(model_type, schema_type)
|
|
614
|
+
except TypeError:
|
|
615
|
+
pass
|
|
616
|
+
|
|
617
|
+
return True # Be permissive for unknown types
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# =============================================================================
|
|
621
|
+
# Column Info Data Class
|
|
622
|
+
# =============================================================================
|
|
623
|
+
|
|
624
|
+
class ColumnInfo:
|
|
625
|
+
"""Information about a SQLAlchemy column."""
|
|
626
|
+
|
|
627
|
+
__slots__ = (
|
|
628
|
+
"name",
|
|
629
|
+
"nullable",
|
|
630
|
+
"max_length",
|
|
631
|
+
"python_type",
|
|
632
|
+
"sa_type",
|
|
633
|
+
"has_default",
|
|
634
|
+
"is_primary_key",
|
|
635
|
+
"is_autoincrement",
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
def __init__(
|
|
639
|
+
self,
|
|
640
|
+
name: str,
|
|
641
|
+
nullable: bool,
|
|
642
|
+
max_length: int | None,
|
|
643
|
+
python_type: type,
|
|
644
|
+
sa_type: Any,
|
|
645
|
+
has_default: bool = False,
|
|
646
|
+
is_primary_key: bool = False,
|
|
647
|
+
is_autoincrement: bool = False,
|
|
648
|
+
):
|
|
649
|
+
self.name = name
|
|
650
|
+
self.nullable = nullable
|
|
651
|
+
self.max_length = max_length
|
|
652
|
+
self.python_type = python_type
|
|
653
|
+
self.sa_type = sa_type
|
|
654
|
+
self.has_default = has_default
|
|
655
|
+
self.is_primary_key = is_primary_key
|
|
656
|
+
self.is_autoincrement = is_autoincrement
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# =============================================================================
|
|
660
|
+
# Validation Decorator
|
|
661
|
+
# =============================================================================
|
|
662
|
+
|
|
663
|
+
def validate_schema(
|
|
664
|
+
schema: type["BaseModel"],
|
|
665
|
+
model: type,
|
|
666
|
+
*,
|
|
667
|
+
strict: bool = True,
|
|
668
|
+
context: str = "",
|
|
669
|
+
):
|
|
670
|
+
"""
|
|
671
|
+
Decorator to validate schema against model.
|
|
672
|
+
|
|
673
|
+
Usage:
|
|
674
|
+
@validate_schema(UserInput, User, context="create_user")
|
|
675
|
+
async def create_user(data: UserInput) -> User:
|
|
676
|
+
...
|
|
677
|
+
"""
|
|
678
|
+
def decorator(func):
|
|
679
|
+
# Validate on decoration (import time)
|
|
680
|
+
SchemaModelValidator.validate(
|
|
681
|
+
schema,
|
|
682
|
+
model,
|
|
683
|
+
strict=strict,
|
|
684
|
+
context=context or func.__name__,
|
|
685
|
+
)
|
|
686
|
+
return func
|
|
687
|
+
return decorator
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
# =============================================================================
|
|
691
|
+
# Startup Validation
|
|
692
|
+
# =============================================================================
|
|
693
|
+
|
|
694
|
+
def validate_all_viewsets(
|
|
695
|
+
viewsets: list[type],
|
|
696
|
+
*,
|
|
697
|
+
strict: bool | None = None,
|
|
698
|
+
fail_fast: bool | None = None,
|
|
699
|
+
) -> list[str]:
|
|
700
|
+
"""
|
|
701
|
+
Validate all registered viewsets.
|
|
702
|
+
|
|
703
|
+
Called automatically during application startup.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
viewsets: List of ViewSet classes to validate
|
|
707
|
+
strict: Override strict mode (default: based on DEBUG setting)
|
|
708
|
+
fail_fast: If True, raise on first error (default: DEBUG mode)
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
List of all issues found
|
|
712
|
+
|
|
713
|
+
Raises:
|
|
714
|
+
SchemaModelMismatchError: If fail_fast and critical issues found
|
|
715
|
+
"""
|
|
716
|
+
from core.config import get_settings
|
|
717
|
+
|
|
718
|
+
settings = get_settings()
|
|
719
|
+
|
|
720
|
+
if strict is None:
|
|
721
|
+
strict = getattr(settings, "strict_validation", getattr(settings, "debug", True))
|
|
722
|
+
|
|
723
|
+
if fail_fast is None:
|
|
724
|
+
fail_fast = getattr(settings, "debug", True)
|
|
725
|
+
|
|
726
|
+
logger.info(f"Validating {len(viewsets)} viewsets (strict={strict}, fail_fast={fail_fast})")
|
|
727
|
+
|
|
728
|
+
all_issues: list[str] = []
|
|
729
|
+
critical_errors: list[str] = []
|
|
730
|
+
|
|
731
|
+
for viewset in viewsets:
|
|
732
|
+
try:
|
|
733
|
+
issues = SchemaModelValidator.validate_viewset(
|
|
734
|
+
viewset,
|
|
735
|
+
strict=strict,
|
|
736
|
+
)
|
|
737
|
+
all_issues.extend(issues)
|
|
738
|
+
except SchemaModelMismatchError as e:
|
|
739
|
+
critical_errors.append(str(e))
|
|
740
|
+
if fail_fast:
|
|
741
|
+
raise
|
|
742
|
+
|
|
743
|
+
if critical_errors and not fail_fast:
|
|
744
|
+
logger.error(
|
|
745
|
+
f"Schema validation found {len(critical_errors)} critical errors:\n" +
|
|
746
|
+
"\n".join(critical_errors)
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
if all_issues:
|
|
750
|
+
logger.warning(f"Schema validation found {len(all_issues)} total issues")
|
|
751
|
+
else:
|
|
752
|
+
logger.info("Schema validation passed")
|
|
753
|
+
|
|
754
|
+
return all_issues
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# =============================================================================
|
|
758
|
+
# Exports
|
|
759
|
+
# =============================================================================
|
|
760
|
+
|
|
761
|
+
__all__ = [
|
|
762
|
+
"SchemaModelValidator",
|
|
763
|
+
"SchemaModelMismatchError",
|
|
764
|
+
"ValidationWarning",
|
|
765
|
+
"ColumnInfo",
|
|
766
|
+
"validate_schema",
|
|
767
|
+
"validate_all_viewsets",
|
|
768
|
+
]
|
core/views.py
CHANGED
|
@@ -44,6 +44,58 @@ ModelT = TypeVar("ModelT")
|
|
|
44
44
|
InputT = TypeVar("InputT", bound=InputSchema)
|
|
45
45
|
OutputT = TypeVar("OutputT", bound=OutputSchema)
|
|
46
46
|
|
|
47
|
+
# Registry for viewsets pending validation
|
|
48
|
+
_pending_viewsets: set[type] = set()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_pending_viewsets(*, strict: bool | None = None) -> list[str]:
|
|
52
|
+
"""
|
|
53
|
+
Validate all pending viewsets.
|
|
54
|
+
|
|
55
|
+
Called automatically during application startup.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
strict: Override strict mode. If None, uses each viewset's setting.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
List of all validation issues found
|
|
62
|
+
"""
|
|
63
|
+
import logging
|
|
64
|
+
logger = logging.getLogger("core.views")
|
|
65
|
+
|
|
66
|
+
if not _pending_viewsets:
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
logger.debug(f"Validating {len(_pending_viewsets)} viewsets...")
|
|
70
|
+
|
|
71
|
+
all_issues: list[str] = []
|
|
72
|
+
|
|
73
|
+
for viewset_cls in list(_pending_viewsets):
|
|
74
|
+
try:
|
|
75
|
+
vs_strict = strict if strict is not None else viewset_cls.strict_validation
|
|
76
|
+
|
|
77
|
+
# Temporarily set strict mode
|
|
78
|
+
original_strict = viewset_cls.strict_validation
|
|
79
|
+
viewset_cls.strict_validation = vs_strict
|
|
80
|
+
|
|
81
|
+
issues = viewset_cls._validate_schemas()
|
|
82
|
+
all_issues.extend(issues)
|
|
83
|
+
|
|
84
|
+
# Restore
|
|
85
|
+
viewset_cls.strict_validation = original_strict
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
all_issues.append(f"{viewset_cls.__name__}: {e}")
|
|
89
|
+
|
|
90
|
+
_pending_viewsets.clear()
|
|
91
|
+
|
|
92
|
+
if all_issues:
|
|
93
|
+
logger.warning(f"ViewSet validation found {len(all_issues)} issues")
|
|
94
|
+
else:
|
|
95
|
+
logger.debug("ViewSet validation passed")
|
|
96
|
+
|
|
97
|
+
return all_issues
|
|
98
|
+
|
|
47
99
|
|
|
48
100
|
# =============================================================================
|
|
49
101
|
# Decorator para actions customizadas
|
|
@@ -242,6 +294,62 @@ class ViewSet(Generic[ModelT, InputT, OutputT]):
|
|
|
242
294
|
# Formato: {"field_name": [validator1, validator2]}
|
|
243
295
|
field_validators: ClassVar[dict[str, list[AsyncValidator]]] = {}
|
|
244
296
|
|
|
297
|
+
# Schema/Model Validation
|
|
298
|
+
# Se True, valida schemas contra model no startup (falha em DEBUG)
|
|
299
|
+
strict_validation: ClassVar[bool] = True
|
|
300
|
+
# Cache de validação
|
|
301
|
+
_schema_validated: ClassVar[bool] = False
|
|
302
|
+
|
|
303
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
304
|
+
"""
|
|
305
|
+
Called when a ViewSet subclass is created.
|
|
306
|
+
|
|
307
|
+
Registers the viewset for startup validation.
|
|
308
|
+
"""
|
|
309
|
+
super().__init_subclass__(**kwargs)
|
|
310
|
+
cls._schema_validated = False
|
|
311
|
+
# Register for lazy validation
|
|
312
|
+
_pending_viewsets.add(cls)
|
|
313
|
+
|
|
314
|
+
@classmethod
|
|
315
|
+
def _validate_schemas(cls) -> list[str]:
|
|
316
|
+
"""
|
|
317
|
+
Validate schemas against model.
|
|
318
|
+
|
|
319
|
+
Called automatically on application startup or first request.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
List of validation issues found
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
SchemaModelMismatchError: If strict_validation=True and critical issues
|
|
326
|
+
"""
|
|
327
|
+
if cls._schema_validated:
|
|
328
|
+
return []
|
|
329
|
+
|
|
330
|
+
model = getattr(cls, "model", None)
|
|
331
|
+
if not model:
|
|
332
|
+
cls._schema_validated = True
|
|
333
|
+
return []
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
from core.validation import SchemaModelValidator
|
|
337
|
+
|
|
338
|
+
issues = SchemaModelValidator.validate_viewset(
|
|
339
|
+
cls,
|
|
340
|
+
strict=cls.strict_validation,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
cls._schema_validated = True
|
|
344
|
+
return issues
|
|
345
|
+
except Exception as e:
|
|
346
|
+
import logging
|
|
347
|
+
logging.getLogger("core.views").warning(
|
|
348
|
+
f"Could not validate {cls.__name__}: {e}"
|
|
349
|
+
)
|
|
350
|
+
cls._schema_validated = True
|
|
351
|
+
return []
|
|
352
|
+
|
|
245
353
|
def __init__(self) -> None:
|
|
246
354
|
self.action: str | None = None
|
|
247
355
|
self.request: Request | None = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: core-framework
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.11
|
|
4
4
|
Summary: Core Framework - Django-inspired, FastAPI-powered. Alta performance, baixo acoplamento, produtividade extrema.
|
|
5
5
|
Project-URL: Homepage, https://github.com/SorPuti/core-framework
|
|
6
6
|
Project-URL: Documentation, https://github.com/SorPuti/core-framework#readme
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
core/__init__.py,sha256
|
|
2
|
-
core/app.py,sha256=
|
|
1
|
+
core/__init__.py,sha256=tvZLlVpXoyS3BPdF0hivmXeVRomd2awBRg-ZeAxvyMg,12232
|
|
2
|
+
core/app.py,sha256=SsMC5Vlj6PNgACXlfeccOm6CQEKfgh3q3X2p9ubRDlQ,23092
|
|
3
3
|
core/choices.py,sha256=rhcL3p2dB7RK99zIilpmoTFVcibQEIaRpz0CY0kImCE,10502
|
|
4
|
-
core/config.py,sha256=
|
|
4
|
+
core/config.py,sha256=dq4O7QBdrdwj-fZRe2yhX1fKyi_Uetb6sx9-RovJ-9c,14771
|
|
5
5
|
core/database.py,sha256=XqB5tZnb9UYDbVGIh96YbmbGJZMqln6-diPBHCr3VWk,11564
|
|
6
6
|
core/datetime.py,sha256=bzqlAj3foA-lzbhXjlEiDNR2D-nwXu9mpxpdcUb-Pmw,32730
|
|
7
7
|
core/dependencies.py,sha256=p207A8qwj-QVAb7nNSe3HxkefClwSQNQQylSFFa-meU,11627
|
|
@@ -15,8 +15,9 @@ core/relations.py,sha256=UbdRgj0XQGI4lv2FQV1ImSAwu4Pn8yxTkSsdzR3m8cM,21372
|
|
|
15
15
|
core/routing.py,sha256=vIiJN8bQ2836WW2zUKTJVBTC8RpjtDYgEGdz7mldnGc,15422
|
|
16
16
|
core/serializers.py,sha256=gR5Y7wTACm1pECkUEpAKBUbPmONGLMDDwej4fyIiOdo,9438
|
|
17
17
|
core/tenancy.py,sha256=R4tNrLcAgRRDSqOvJS2IRXcD2J-zoCE4ng01ip9xWKI,9169
|
|
18
|
+
core/validation.py,sha256=F6sq6g2hzhgQ10nVMc9cdXqK4Og00nCjgdkAi5TlpJw,24533
|
|
18
19
|
core/validators.py,sha256=LCDyvqwIKnMaUEdaVx5kWveZt3XsydknZ_bxBL4ic5U,27895
|
|
19
|
-
core/views.py,sha256=
|
|
20
|
+
core/views.py,sha256=jP2HypxplVP5nHJfmhQ2d4pegnVYhl8KXVydc25l7V4,46541
|
|
20
21
|
core/auth/__init__.py,sha256=_yr4rMMvDt_uKujzkKfqlQZ6x9UiQ6jmRppw14hTQNc,4645
|
|
21
22
|
core/auth/backends.py,sha256=PkLk2RQN2rQdtYSiN0mn7cqSp95hnLjO9xTFZqSsPF8,10486
|
|
22
23
|
core/auth/base.py,sha256=Q7vXgwTmgdmyW7G8eJmDket2bKB_8YFnraZ_kK9_gTs,21425
|
|
@@ -28,7 +29,7 @@ core/auth/models.py,sha256=aEE7deQKPS1aH0Btzzh3Z1Bwuqy8zvLZwu4JFEmiUNk,34058
|
|
|
28
29
|
core/auth/permissions.py,sha256=v3ykAgNpq5wJ0NkuC_FuveMctOkDfM9Xp11XEnUAuBg,12461
|
|
29
30
|
core/auth/schemas.py,sha256=L0W96dOD348rJDGeu1K5Rz3aJj-GdwMr2vbwwsYfo2g,3469
|
|
30
31
|
core/auth/tokens.py,sha256=jOF40D5O8WRG8klRwMBuSG-jOhdsp1irXn2aZ2puNSg,9149
|
|
31
|
-
core/auth/views.py,sha256=
|
|
32
|
+
core/auth/views.py,sha256=psL4g2Fe1saXn1eCnm6b18E2JCbALixVJahr2OIaLvI,18211
|
|
32
33
|
core/cli/__init__.py,sha256=EOYSATzRugHD2oJ1SPfTIMUygUoNJnO_dRt2yJrkQcU,542
|
|
33
34
|
core/cli/main.py,sha256=0WAO1deegd2Yja19ggDbX4j6p5S_p2KrmyAmale9HZs,124490
|
|
34
35
|
core/deployment/__init__.py,sha256=RNcBRO9oB3WRnhtTTwM6wzVEcUKpKF4XfRkGSbbykIc,794
|
|
@@ -86,7 +87,7 @@ example/auth.py,sha256=zBpLutb8lVKnGfQqQ2wnyygsSutHYZzeJBuhnFhxBaQ,4971
|
|
|
86
87
|
example/models.py,sha256=xKdx0kJ9n0tZ7sCce3KhV3BTvKvsh6m7G69eFm3ukf0,4549
|
|
87
88
|
example/schemas.py,sha256=wJ9QofnuHp4PjtM_IuMMBLVFVDJ4YlwcF6uQm1ooKiY,6139
|
|
88
89
|
example/views.py,sha256=GQwgQcW6yoeUIDbF7-lsaZV7cs8G1S1vGVtiwVpZIQE,14338
|
|
89
|
-
core_framework-0.12.
|
|
90
|
-
core_framework-0.12.
|
|
91
|
-
core_framework-0.12.
|
|
92
|
-
core_framework-0.12.
|
|
90
|
+
core_framework-0.12.11.dist-info/METADATA,sha256=VpC4knI1g1olWe8cq5H-A1M8W84N6ONhq9pLIp4wrrg,13021
|
|
91
|
+
core_framework-0.12.11.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
92
|
+
core_framework-0.12.11.dist-info/entry_points.txt,sha256=MJytamxHbn0CyH3HbxiP-PqOWekjYUo2CA6EWiKWuSI,78
|
|
93
|
+
core_framework-0.12.11.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|