schemap 0.1.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.
- schemap-0.1.0/PKG-INFO +23 -0
- schemap-0.1.0/README.md +0 -0
- schemap-0.1.0/pyproject.toml +49 -0
- schemap-0.1.0/setup.cfg +4 -0
- schemap-0.1.0/src/schemap/__init__.py +4 -0
- schemap-0.1.0/src/schemap/base.py +1 -0
- schemap-0.1.0/src/schemap/builder.py +44 -0
- schemap-0.1.0/src/schemap/types.py +64 -0
- schemap-0.1.0/src/schemap/utils/likeness.py +49 -0
- schemap-0.1.0/src/schemap/utils/mapping.py +57 -0
- schemap-0.1.0/src/schemap/utils/schema.py +78 -0
- schemap-0.1.0/src/schemap.egg-info/PKG-INFO +23 -0
- schemap-0.1.0/src/schemap.egg-info/SOURCES.txt +16 -0
- schemap-0.1.0/src/schemap.egg-info/dependency_links.txt +1 -0
- schemap-0.1.0/src/schemap.egg-info/requires.txt +10 -0
- schemap-0.1.0/src/schemap.egg-info/top_level.txt +1 -0
- schemap-0.1.0/tests/test_builder.py +81 -0
- schemap-0.1.0/tests/test_types.py +47 -0
schemap-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: schemap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automatic Pydantic Schemas for SQLAlchemy models
|
|
5
|
+
Author-email: Emiliano Gandini Outeda <emiliano.gandini@protonmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/emiliano-gandini-outeda/schemap
|
|
8
|
+
Project-URL: Repository, https://github.com/emiliano-gandini-outeda/schemap
|
|
9
|
+
Project-URL: Issues, https://github.com/emiliano-gandini-outeda/schemap/issues
|
|
10
|
+
Keywords: python,schemas,models,orm
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.12.7
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pydantic>=2.13.4
|
|
17
|
+
Requires-Dist: sqlalchemy>=2.0.49
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
21
|
+
Provides-Extra: example
|
|
22
|
+
Requires-Dist: fastapi>=0.100.0; extra == "example"
|
|
23
|
+
Requires-Dist: uvicorn>=0.23.0; extra == "example"
|
schemap-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "schemap"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Automatic Pydantic Schemas for SQLAlchemy models"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12.7"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Emiliano Gandini Outeda", email = "emiliano.gandini@protonmail.com" }
|
|
9
|
+
]
|
|
10
|
+
license = "MIT"
|
|
11
|
+
keywords = [
|
|
12
|
+
"python",
|
|
13
|
+
"schemas",
|
|
14
|
+
"models",
|
|
15
|
+
"orm"
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
dependencies = [
|
|
24
|
+
"pydantic>=2.13.4",
|
|
25
|
+
"sqlalchemy>=2.0.49",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=7.0.0",
|
|
31
|
+
"pytest-cov>=4.0.0",
|
|
32
|
+
]
|
|
33
|
+
example = [
|
|
34
|
+
"fastapi>=0.100.0",
|
|
35
|
+
"uvicorn>=0.23.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/emiliano-gandini-outeda/schemap"
|
|
40
|
+
Repository = "https://github.com/emiliano-gandini-outeda/schemap"
|
|
41
|
+
Issues = "https://github.com/emiliano-gandini-outeda/schemap/issues"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
45
|
+
build-backend = "setuptools.build_meta"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["src"]
|
|
49
|
+
exclude = ["tests*", "examples*"]
|
schemap-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Base classes for AutoSchema models."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import Type, Any, Optional
|
|
2
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
3
|
+
from sqlalchemy import inspect
|
|
4
|
+
from pydantic import create_model, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
from .types import extract_column_metadata
|
|
7
|
+
from .utils.schema import should_include, transform_for_schema
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_schema(
|
|
11
|
+
model: Type[DeclarativeBase],
|
|
12
|
+
schema_type: str = "default", # "default", "create", "update", "public"
|
|
13
|
+
config: Optional[Any] = None, # SchemaConfig
|
|
14
|
+
) -> Any:
|
|
15
|
+
"""
|
|
16
|
+
Build a Pydantic schema class for a SQLAlchemy model.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
model: The SQLAlchemy model class (e.g., User)
|
|
20
|
+
schema_type: Which schema variant to build
|
|
21
|
+
config: Optional SchemaConfig for customization
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A Pydantic model class
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
inspector = inspect(model)
|
|
28
|
+
|
|
29
|
+
columns_meta = []
|
|
30
|
+
|
|
31
|
+
for col in inspector.columns:
|
|
32
|
+
meta = extract_column_metadata(col)
|
|
33
|
+
if should_include(schema_type, meta):
|
|
34
|
+
columns_meta.append(meta)
|
|
35
|
+
|
|
36
|
+
fields = {}
|
|
37
|
+
|
|
38
|
+
for col_meta in columns_meta:
|
|
39
|
+
field_type, field_kwargs = transform_for_schema(col_meta, schema_type)
|
|
40
|
+
fields[col_meta["name"]] = (field_type, field_kwargs)
|
|
41
|
+
|
|
42
|
+
schema_name = f"{model.__name__}{schema_type.capitalize()}Schema"
|
|
43
|
+
|
|
44
|
+
return create_model(schema_name, __config__=ConfigDict(from_attributes=True), **fields)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from .utils.mapping import TYPE_MAP
|
|
2
|
+
from .utils.likeness import ColumnLike
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import types as sa_types, inspect
|
|
5
|
+
from sqlalchemy.sql.schema import Column
|
|
6
|
+
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
7
|
+
from typing import Union, Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_python_type(column : ColumnLike) -> tuple[type, bool]:
|
|
11
|
+
"""
|
|
12
|
+
Extract Python type and optional flag from a SQLAlchemy column.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
column: A SQLAlchemy Column or InstrumentedAttribute
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
tuple[type, bool]: (python_type, is_optional)
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
>>> from sqlalchemy import Integer, String
|
|
22
|
+
>>> from sqlalchemy.orm import Mapped, mapped_column
|
|
23
|
+
>>>
|
|
24
|
+
>>> # Simulating Mapped[int]
|
|
25
|
+
>>> extract_python_type(Integer())
|
|
26
|
+
(int, False)
|
|
27
|
+
"""
|
|
28
|
+
# If passed a TypeEngine directly (like Integer()), use it as-is
|
|
29
|
+
if isinstance(column, sa_types.TypeEngine):
|
|
30
|
+
sql_type = type(column)
|
|
31
|
+
return TYPE_MAP.get(sql_type, (Any, False)), False
|
|
32
|
+
|
|
33
|
+
sql_type = type(column.type)
|
|
34
|
+
return TYPE_MAP.get(sql_type, (Any, False)), False
|
|
35
|
+
|
|
36
|
+
def extract_column_metadata(column : ColumnLike) -> dict[str, Any]:
|
|
37
|
+
"""
|
|
38
|
+
Extract all metadata from column for Pydantic Field construction.
|
|
39
|
+
|
|
40
|
+
Returns dict with keys:
|
|
41
|
+
- "python_type": the Python type from extract_python_type
|
|
42
|
+
- "is_optional": boolean
|
|
43
|
+
- "max_length": int | None (from String, Text)
|
|
44
|
+
- "ge": int | None (minimum value for numeric)
|
|
45
|
+
- "le": int | None (maximum value for numeric)
|
|
46
|
+
- "default": Any | None (from column.default)
|
|
47
|
+
- "primary_key": bool
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
column: SQLAlchemy Column object
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
dict with above keys
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
return{
|
|
57
|
+
"name": column.name,
|
|
58
|
+
"python_type": extract_python_type(column)[0],
|
|
59
|
+
"is_optional": column.nullable,
|
|
60
|
+
"primary_key": column.primary_key,
|
|
61
|
+
"max_length": getattr(column.type, 'length', None),
|
|
62
|
+
"default": column.default.arg if column.default else None,
|
|
63
|
+
"server_default" : column.server_default,
|
|
64
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from typing import Protocol, Optional, Set, Any
|
|
2
|
+
from sqlalchemy.sql.type_api import TypeEngine
|
|
3
|
+
from sqlalchemy.sql.schema import (
|
|
4
|
+
ColumnDefault,
|
|
5
|
+
DefaultClause,
|
|
6
|
+
Identity,
|
|
7
|
+
Computed,
|
|
8
|
+
ForeignKey,
|
|
9
|
+
Constraint,
|
|
10
|
+
DialectKWArgs,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ColumnLike(Protocol):
|
|
15
|
+
"""Protocol for objects that behave like SQLAlchemy Column."""
|
|
16
|
+
|
|
17
|
+
# --- Identity ---
|
|
18
|
+
name: str
|
|
19
|
+
key: str
|
|
20
|
+
|
|
21
|
+
# --- Type ---
|
|
22
|
+
type: TypeEngine[Any]
|
|
23
|
+
|
|
24
|
+
# --- Nullability / defaults ---
|
|
25
|
+
nullable: bool
|
|
26
|
+
default: Optional[ColumnDefault]
|
|
27
|
+
server_default: Optional[DefaultClause]
|
|
28
|
+
autoincrement: Any # SQLAlchemy uses bool | Literal["auto"] | etc
|
|
29
|
+
|
|
30
|
+
# --- Constraints / keys ---
|
|
31
|
+
primary_key: bool
|
|
32
|
+
unique: Optional[bool]
|
|
33
|
+
index: Optional[bool]
|
|
34
|
+
foreign_keys: Set[ForeignKey]
|
|
35
|
+
constraints: Set[Constraint]
|
|
36
|
+
|
|
37
|
+
# --- Generated / identity ---
|
|
38
|
+
identity: Optional[Identity]
|
|
39
|
+
computed: Optional[Computed]
|
|
40
|
+
|
|
41
|
+
# --- Docs / comments ---
|
|
42
|
+
comment: Optional[str]
|
|
43
|
+
doc: Optional[str]
|
|
44
|
+
|
|
45
|
+
# --- Misc ---
|
|
46
|
+
quote: Optional[bool]
|
|
47
|
+
system: bool
|
|
48
|
+
info: dict[str, Any]
|
|
49
|
+
dialect_options: DialectKWArgs
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from sqlalchemy import types as sa_types
|
|
2
|
+
from datetime import date, time, datetime
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
TYPE_MAP = {
|
|
7
|
+
# --- Strings ---
|
|
8
|
+
sa_types.String: str,
|
|
9
|
+
sa_types.Text: str,
|
|
10
|
+
sa_types.Unicode: str,
|
|
11
|
+
sa_types.UnicodeText: str,
|
|
12
|
+
sa_types.CHAR: str,
|
|
13
|
+
sa_types.VARCHAR: str,
|
|
14
|
+
sa_types.NCHAR: str,
|
|
15
|
+
sa_types.NVARCHAR: str,
|
|
16
|
+
sa_types.CLOB: str,
|
|
17
|
+
|
|
18
|
+
# --- Integers ---
|
|
19
|
+
sa_types.Integer: int,
|
|
20
|
+
sa_types.SmallInteger: int,
|
|
21
|
+
sa_types.BigInteger: int,
|
|
22
|
+
|
|
23
|
+
# --- Numeric ---
|
|
24
|
+
sa_types.Numeric: Decimal,
|
|
25
|
+
sa_types.DECIMAL: Decimal,
|
|
26
|
+
|
|
27
|
+
# --- Floating point ---
|
|
28
|
+
sa_types.Float: float,
|
|
29
|
+
sa_types.REAL: float,
|
|
30
|
+
|
|
31
|
+
# --- Boolean ---
|
|
32
|
+
sa_types.Boolean: bool,
|
|
33
|
+
|
|
34
|
+
# --- Date / Time ---
|
|
35
|
+
sa_types.Date: date,
|
|
36
|
+
sa_types.Time: time,
|
|
37
|
+
sa_types.DateTime: datetime,
|
|
38
|
+
sa_types.TIMESTAMP: datetime,
|
|
39
|
+
|
|
40
|
+
# --- Binary ---
|
|
41
|
+
sa_types.LargeBinary: bytes,
|
|
42
|
+
sa_types.BLOB: bytes,
|
|
43
|
+
sa_types.BINARY: bytes,
|
|
44
|
+
sa_types.VARBINARY: bytes,
|
|
45
|
+
|
|
46
|
+
# --- JSON ---
|
|
47
|
+
sa_types.JSON: dict,
|
|
48
|
+
|
|
49
|
+
# --- UUID ---
|
|
50
|
+
sa_types.UUID: uuid.UUID,
|
|
51
|
+
|
|
52
|
+
# --- Arrays ---
|
|
53
|
+
sa_types.ARRAY: list,
|
|
54
|
+
|
|
55
|
+
# --- Generic fallback ---
|
|
56
|
+
sa_types.NullType: object,
|
|
57
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
from pydantic import Field
|
|
3
|
+
|
|
4
|
+
def should_include(schema_type : str, metadata : dict[str, Any]):
|
|
5
|
+
"""Determine if a column should be included in the schema."""
|
|
6
|
+
|
|
7
|
+
if schema_type == "default":
|
|
8
|
+
return True
|
|
9
|
+
|
|
10
|
+
elif schema_type == "create":
|
|
11
|
+
# Exclude primary keys and server_default fields
|
|
12
|
+
if metadata["primary_key"]:
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
if metadata.get("server_default") is not None:
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
if metadata.get("default") is not None:
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
elif schema_type == "update":
|
|
24
|
+
# Exclude primary keys only
|
|
25
|
+
if metadata["primary_key"]:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
elif schema_type == "public":
|
|
31
|
+
# Exclude fields starting with __ (private)
|
|
32
|
+
if metadata["name"].startswith("__"):
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
else:
|
|
38
|
+
raise ValueError(f"Unknown schema_type: {schema_type}")
|
|
39
|
+
|
|
40
|
+
def transform_for_schema(metadata: dict, schema_type: str) -> tuple[type, Any]:
|
|
41
|
+
"""Transform column metadata into (field_type, Field(...)) for create_model."""
|
|
42
|
+
|
|
43
|
+
python_type = metadata["python_type"]
|
|
44
|
+
|
|
45
|
+
# Build Field kwargs from constraints
|
|
46
|
+
field_kwargs = {}
|
|
47
|
+
|
|
48
|
+
# String length constraint
|
|
49
|
+
if metadata.get("max_length") is not None:
|
|
50
|
+
field_kwargs["max_length"] = metadata["max_length"]
|
|
51
|
+
|
|
52
|
+
if schema_type == "update":
|
|
53
|
+
field_kwargs["default"] = None
|
|
54
|
+
return (Optional[python_type], Field(**field_kwargs))
|
|
55
|
+
|
|
56
|
+
elif schema_type == "create":
|
|
57
|
+
if metadata.get("default") is not None:
|
|
58
|
+
field_kwargs["default"] = metadata["default"]
|
|
59
|
+
return (python_type, Field(**field_kwargs))
|
|
60
|
+
|
|
61
|
+
elif metadata["is_optional"]:
|
|
62
|
+
field_kwargs["default"] = None
|
|
63
|
+
return (Optional[python_type], Field(**field_kwargs))
|
|
64
|
+
|
|
65
|
+
else:
|
|
66
|
+
return (python_type, Field(**field_kwargs) if field_kwargs else ...)
|
|
67
|
+
|
|
68
|
+
else: # default or public
|
|
69
|
+
if metadata.get("default") is not None:
|
|
70
|
+
field_kwargs["default"] = metadata["default"]
|
|
71
|
+
return (python_type, Field(**field_kwargs))
|
|
72
|
+
|
|
73
|
+
elif metadata["is_optional"]:
|
|
74
|
+
field_kwargs["default"] = None
|
|
75
|
+
return (Optional[python_type], Field(**field_kwargs))
|
|
76
|
+
|
|
77
|
+
else:
|
|
78
|
+
return (python_type, Field(**field_kwargs) if field_kwargs else ...)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: schemap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automatic Pydantic Schemas for SQLAlchemy models
|
|
5
|
+
Author-email: Emiliano Gandini Outeda <emiliano.gandini@protonmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/emiliano-gandini-outeda/schemap
|
|
8
|
+
Project-URL: Repository, https://github.com/emiliano-gandini-outeda/schemap
|
|
9
|
+
Project-URL: Issues, https://github.com/emiliano-gandini-outeda/schemap/issues
|
|
10
|
+
Keywords: python,schemas,models,orm
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.12.7
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pydantic>=2.13.4
|
|
17
|
+
Requires-Dist: sqlalchemy>=2.0.49
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
21
|
+
Provides-Extra: example
|
|
22
|
+
Requires-Dist: fastapi>=0.100.0; extra == "example"
|
|
23
|
+
Requires-Dist: uvicorn>=0.23.0; extra == "example"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/schemap/__init__.py
|
|
4
|
+
src/schemap/base.py
|
|
5
|
+
src/schemap/builder.py
|
|
6
|
+
src/schemap/types.py
|
|
7
|
+
src/schemap.egg-info/PKG-INFO
|
|
8
|
+
src/schemap.egg-info/SOURCES.txt
|
|
9
|
+
src/schemap.egg-info/dependency_links.txt
|
|
10
|
+
src/schemap.egg-info/requires.txt
|
|
11
|
+
src/schemap.egg-info/top_level.txt
|
|
12
|
+
src/schemap/utils/likeness.py
|
|
13
|
+
src/schemap/utils/mapping.py
|
|
14
|
+
src/schemap/utils/schema.py
|
|
15
|
+
tests/test_builder.py
|
|
16
|
+
tests/test_types.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
schemap
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Tests for dynamic schema builder."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from sqlalchemy import create_engine
|
|
6
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
from autoschema.builder import build_schema
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Base(DeclarativeBase):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class User(Base):
|
|
16
|
+
__tablename__ = "users"
|
|
17
|
+
|
|
18
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
19
|
+
username: Mapped[str] = mapped_column(nullable=False, unique=True)
|
|
20
|
+
email: Mapped[str] = mapped_column(nullable=False)
|
|
21
|
+
age: Mapped[int] = mapped_column(nullable=True)
|
|
22
|
+
created_at: Mapped[datetime] = mapped_column(default=datetime.now(timezone.utc))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_build_full_schema():
|
|
26
|
+
"""Test building complete schema with all fields."""
|
|
27
|
+
UserSchema = build_schema(User, "default")
|
|
28
|
+
|
|
29
|
+
# Check schema has all expected fields
|
|
30
|
+
assert "id" in UserSchema.model_fields
|
|
31
|
+
assert "username" in UserSchema.model_fields
|
|
32
|
+
assert "email" in UserSchema.model_fields
|
|
33
|
+
assert "age" in UserSchema.model_fields
|
|
34
|
+
assert "created_at" in UserSchema.model_fields
|
|
35
|
+
|
|
36
|
+
# Check types
|
|
37
|
+
assert UserSchema.model_fields["username"].annotation == str
|
|
38
|
+
assert UserSchema.model_fields["age"].annotation == Optional[int]
|
|
39
|
+
|
|
40
|
+
# Check from_attributes is enabled
|
|
41
|
+
assert UserSchema.model_config["from_attributes"] is True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_build_create_schema():
|
|
45
|
+
"""Test create schema excludes auto-generated fields."""
|
|
46
|
+
UserCreateSchema = build_schema(User, "create")
|
|
47
|
+
|
|
48
|
+
# id and created_at should be excluded (primary key and server_default)
|
|
49
|
+
assert "id" not in UserCreateSchema.model_fields
|
|
50
|
+
assert "created_at" not in UserCreateSchema.model_fields
|
|
51
|
+
|
|
52
|
+
# Required fields should be required
|
|
53
|
+
assert UserCreateSchema.model_fields["username"].is_required() is True
|
|
54
|
+
assert UserCreateSchema.model_fields["email"].is_required() is True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_build_update_schema():
|
|
58
|
+
"""Test update schema makes all fields optional."""
|
|
59
|
+
UserUpdateSchema = build_schema(User, "update")
|
|
60
|
+
|
|
61
|
+
# All fields should be Optional with default None
|
|
62
|
+
for field_name, field in UserUpdateSchema.model_fields.items():
|
|
63
|
+
# Check type is Optional (handles both Optional[X] and X | None)
|
|
64
|
+
is_optional = getattr(field.annotation, "__origin__", None) is Union and type(None) in getattr(field.annotation, "__args__", [])
|
|
65
|
+
assert is_optional or field.is_required() is False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_can_create_instance():
|
|
69
|
+
"""Test the generated schema can actually create instances."""
|
|
70
|
+
UserSchema = build_schema(User, "default")
|
|
71
|
+
|
|
72
|
+
user = UserSchema(
|
|
73
|
+
id=1,
|
|
74
|
+
username="testuser",
|
|
75
|
+
email="test@example.com",
|
|
76
|
+
age=30,
|
|
77
|
+
created_at=datetime.now(timezone.utc)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert user.username == "testuser"
|
|
81
|
+
assert user.model_dump()["username"] == "testuser"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Tests for type resolution."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from datetime import datetime, date
|
|
5
|
+
from sqlalchemy import Integer, String, Boolean, DateTime, Date, Float, Text, JSON
|
|
6
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
7
|
+
from sqlalchemy import Column
|
|
8
|
+
|
|
9
|
+
from autoschema.types import extract_python_type, extract_column_metadata
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_simple_type_mapping():
|
|
13
|
+
"""Test that basic SQLAlchemy types map to correct Python types."""
|
|
14
|
+
assert extract_python_type(Integer()) == (int, False)
|
|
15
|
+
assert extract_python_type(String()) == (str, False)
|
|
16
|
+
assert extract_python_type(Boolean()) == (bool, False)
|
|
17
|
+
assert extract_python_type(Float()) == (float, False)
|
|
18
|
+
assert extract_python_type(DateTime()) == (datetime, False)
|
|
19
|
+
assert extract_python_type(Date()) == (date, False)
|
|
20
|
+
assert extract_python_type(Text()) == (str, False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_column_metadata_basics():
|
|
24
|
+
"""Test extraction of column properties."""
|
|
25
|
+
|
|
26
|
+
col = Column("id", Integer, primary_key=True, nullable=False)
|
|
27
|
+
metadata = extract_column_metadata(col)
|
|
28
|
+
|
|
29
|
+
assert metadata["python_type"] == int
|
|
30
|
+
assert metadata["is_optional"] is False
|
|
31
|
+
assert metadata["primary_key"] is True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_string_length():
|
|
35
|
+
"""Test that String(100) captures max_length."""
|
|
36
|
+
col = Column("name", String(100), nullable=False)
|
|
37
|
+
metadata = extract_column_metadata(col)
|
|
38
|
+
|
|
39
|
+
assert metadata["max_length"] == 100
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_nullable_column():
|
|
43
|
+
"""Test that nullable=True sets is_optional=True."""
|
|
44
|
+
col = Column("bio", Text, nullable=True)
|
|
45
|
+
metadata = extract_column_metadata(col)
|
|
46
|
+
|
|
47
|
+
assert metadata["is_optional"] is True
|