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 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"
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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """ Automatic Pydantic schemas for SQLAlchemy models """
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -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,10 @@
1
+ pydantic>=2.13.4
2
+ sqlalchemy>=2.0.49
3
+
4
+ [dev]
5
+ pytest>=7.0.0
6
+ pytest-cov>=4.0.0
7
+
8
+ [example]
9
+ fastapi>=0.100.0
10
+ uvicorn>=0.23.0
@@ -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