Skip to content

Commit b802498

Browse files
committed
ci: add GitHub Actions workflow with pytest suite and consumer integration
Validates the library end-to-end without publishing: Test job (matrix 3.12 / 3.13 / 3.14) - Installs the project with [dev] extras into an isolated venv - Runs pytest covering smoke imports, basic CRUD, and the STI default-isolation regression Consumer-integration job (matrix 3.12 / 3.13 / 3.14) - Builds wheel + sdist via python -m build - Installs the wheel (only + aiosqlite) into a clean venv with no dev dependencies, mimicking a PyPI consumer - Runs examples/consumer_integration/run.py which exercises STI behaviour against async SQLite and fails fast on any assertion Regression coverage for the 0.3.2 fix (clearing SA defaults on STI shared columns) is asserted at both metadata and runtime layers so any reintroduction of the leak breaks CI on every PR.
1 parent 75a1184 commit b802498

8 files changed

Lines changed: 634 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
branches: [master]
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
concurrency:
14+
group: ci-${{ github.ref }}
15+
cancel-in-progress: true
16+
17+
jobs:
18+
# -------------------------------------------------------------------
19+
# Job 1: source-tree tests across supported Python versions.
20+
# Uses uv sync so the lockfile is honoured and runs are reproducible.
21+
# -------------------------------------------------------------------
22+
test:
23+
name: test (py${{ matrix.python-version }})
24+
runs-on: ubuntu-latest
25+
strategy:
26+
fail-fast: false
27+
matrix:
28+
python-version: ["3.12", "3.13", "3.14"]
29+
steps:
30+
- name: Checkout
31+
uses: actions/checkout@v4
32+
33+
- name: Install uv
34+
uses: astral-sh/setup-uv@v5
35+
with:
36+
enable-cache: true
37+
cache-dependency-glob: "uv.lock"
38+
39+
- name: Set up Python ${{ matrix.python-version }}
40+
run: uv python install ${{ matrix.python-version }}
41+
42+
- name: Create venv and install project with dev extras
43+
# Dev extras: pytest, pytest-asyncio, aiosqlite, ruff. Editable install
44+
# keeps the wheel build separate for the consumer-integration job.
45+
run: |
46+
uv venv --python ${{ matrix.python-version }} .venv
47+
uv pip install --python .venv/bin/python -e ".[dev]"
48+
49+
- name: Run pytest
50+
run: .venv/bin/python -m pytest tests/ -v
51+
52+
# -------------------------------------------------------------------
53+
# Job 2: build wheel + sdist, then install the wheel into a clean
54+
# virtualenv with zero dev deps and run the consumer integration
55+
# script. This simulates an external PyPI consumer.
56+
# -------------------------------------------------------------------
57+
consumer-integration:
58+
name: consumer integration (py${{ matrix.python-version }})
59+
runs-on: ubuntu-latest
60+
needs: test
61+
strategy:
62+
fail-fast: false
63+
matrix:
64+
python-version: ["3.12", "3.13", "3.14"]
65+
steps:
66+
- name: Checkout
67+
uses: actions/checkout@v4
68+
69+
- name: Install uv
70+
uses: astral-sh/setup-uv@v5
71+
with:
72+
enable-cache: true
73+
74+
- name: Set up Python ${{ matrix.python-version }}
75+
run: uv python install ${{ matrix.python-version }}
76+
77+
- name: Build wheel and sdist
78+
run: uv tool run --from build python -m build
79+
80+
- name: Create isolated consumer venv
81+
run: uv venv --python ${{ matrix.python-version }} /tmp/consumer-env
82+
83+
- name: Install built wheel (no dev deps, only the wheel + aiosqlite driver)
84+
run: uv pip install --python /tmp/consumer-env/bin/python dist/*.whl aiosqlite
85+
86+
- name: Confirm installed version matches wheel
87+
run: |
88+
/tmp/consumer-env/bin/python -c "import sqlmodel_ext; print('installed:', sqlmodel_ext.__version__ if hasattr(sqlmodel_ext, '__version__') else '(no __version__)')"
89+
90+
- name: Run consumer integration scenario
91+
run: /tmp/consumer-env/bin/python examples/consumer_integration/run.py
92+
93+
- name: Upload distribution artifacts
94+
if: matrix.python-version == '3.12'
95+
uses: actions/upload-artifact@v4
96+
with:
97+
name: dist
98+
path: dist/
99+
if-no-files-found: error
100+
retention-days: 14
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""
2+
Consumer-side integration test.
3+
4+
Runs from a clean virtualenv that has ONLY the built sqlmodel-ext wheel
5+
installed (plus aiosqlite for the async SQLite driver). Simulates what an
6+
external PyPI consumer experiences: if this script passes, the wheel is
7+
well-formed and the STI default-isolation fix behaves end-to-end.
8+
9+
Exits with status 0 on success, non-zero on any failed assertion. Intended
10+
to be invoked directly by CI:
11+
12+
python -m pip install dist/*.whl aiosqlite
13+
python examples/consumer_integration/run.py
14+
"""
15+
from __future__ import annotations
16+
17+
import asyncio
18+
import sys
19+
20+
from sqlalchemy import select
21+
from sqlalchemy.ext.asyncio import create_async_engine
22+
from sqlalchemy.orm import configure_mappers
23+
from sqlmodel import SQLModel
24+
from sqlmodel.ext.asyncio.session import AsyncSession
25+
26+
from sqlmodel_ext import (
27+
AutoPolymorphicIdentityMixin,
28+
PolymorphicBaseMixin,
29+
SQLModelBase,
30+
UUIDTableBaseMixin,
31+
register_sti_column_properties_for_all_subclasses,
32+
register_sti_columns_for_all_subclasses,
33+
)
34+
35+
36+
# ---------- STI model hierarchy ----------
37+
38+
class Tool(SQLModelBase, UUIDTableBaseMixin, PolymorphicBaseMixin, table=True):
39+
"""Shared STI parent table."""
40+
name: str
41+
42+
43+
class ExportFunction(Tool, AutoPolymorphicIdentityMixin, table=True):
44+
"""Declares ``max_size`` with a large realistic default."""
45+
# 5 GiB chosen because the real-world production bug used exactly this value.
46+
max_size: int = 5 * 1024 * 1024 * 1024
47+
48+
49+
class UploadFunction(Tool, AutoPolymorphicIdentityMixin, table=True):
50+
"""Declares a different default for a different field."""
51+
allow_overwrite: bool = True
52+
53+
54+
class NoOpFunction(Tool, AutoPolymorphicIdentityMixin, table=True):
55+
"""The sibling with zero own fields - the purest victim of default leaks."""
56+
pass
57+
58+
59+
# ---------- Integration scenario ----------
60+
61+
FIVE_GIB = 5 * 1024 * 1024 * 1024
62+
63+
64+
def fail(msg: str) -> None:
65+
print(f"[FAIL] {msg}", file=sys.stderr)
66+
sys.exit(1)
67+
68+
69+
async def main() -> None:
70+
# STI two-phase registration (public API contract).
71+
register_sti_columns_for_all_subclasses()
72+
configure_mappers()
73+
register_sti_column_properties_for_all_subclasses()
74+
75+
# --- Layer 1: metadata assertions ---
76+
shared = Tool.__table__.columns
77+
if shared["max_size"].default is not None:
78+
fail(
79+
f"tool.max_size.default should be None (fix cleared the shared SA "
80+
f"default). Got: {shared['max_size'].default!r}"
81+
)
82+
if shared["max_size"].server_default is not None:
83+
fail(f"tool.max_size.server_default should be None")
84+
if shared["allow_overwrite"].default is not None:
85+
fail(
86+
f"tool.allow_overwrite.default should be None. "
87+
f"Got: {shared['allow_overwrite'].default!r}"
88+
)
89+
90+
# Pydantic field-level defaults must still be intact.
91+
if ExportFunction.model_fields["max_size"].default != FIVE_GIB:
92+
fail(
93+
f"Pydantic default for ExportFunction.max_size was wiped by the fix: "
94+
f"{ExportFunction.model_fields['max_size'].default!r}"
95+
)
96+
97+
# --- Layer 2: behavioural assertions via real async SQLite ---
98+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
99+
try:
100+
async with engine.begin() as conn:
101+
await conn.run_sync(SQLModel.metadata.create_all)
102+
103+
async with AsyncSession(engine) as session:
104+
# NoOpFunction does not declare max_size; the shared column must be NULL.
105+
noop = NoOpFunction(name="noop")
106+
noop = await noop.save(session)
107+
result = await session.execute(
108+
select(Tool.__table__.c.max_size).where(Tool.__table__.c.id == noop.id)
109+
)
110+
stored = result.scalar_one()
111+
if stored is not None:
112+
fail(
113+
f"NoOpFunction.max_size should be NULL (bug: sibling default "
114+
f"leaked from ExportFunction). Got: {stored!r}"
115+
)
116+
117+
# UploadFunction also does not declare max_size.
118+
upload = UploadFunction(name="upload")
119+
upload = await upload.save(session)
120+
result = await session.execute(
121+
select(Tool.__table__.c.max_size).where(Tool.__table__.c.id == upload.id)
122+
)
123+
stored = result.scalar_one()
124+
if stored is not None:
125+
fail(
126+
f"UploadFunction.max_size should be NULL. Got: {stored!r}"
127+
)
128+
129+
# Happy path: declaring subclass still persists its own Pydantic default.
130+
export = ExportFunction(name="export")
131+
export = await export.save(session)
132+
result = await session.execute(
133+
select(Tool.__table__.c.max_size).where(Tool.__table__.c.id == export.id)
134+
)
135+
stored = result.scalar_one()
136+
if stored != FIVE_GIB:
137+
fail(
138+
f"ExportFunction.max_size should be {FIVE_GIB}. Got: {stored!r} "
139+
f"- fix may have broken the declaring subclass's default path."
140+
)
141+
finally:
142+
await engine.dispose()
143+
144+
print("[OK] sqlmodel-ext consumer integration test passed")
145+
146+
147+
if __name__ == "__main__":
148+
asyncio.run(main())

tests/__init__.py

Whitespace-only changes.

tests/_models.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
Synthetic STI hierarchy used by regression tests.
3+
4+
Intentionally placed outside any test function so the polymorphic metaclass
5+
queues the classes onto ``_sti_subclasses_to_register`` at import time. The
6+
conftest fixture drives the two-phase registration and schema creation.
7+
8+
Hierarchy:
9+
Tool (STI root, has its own table)
10+
+-- FunctionA - declares ``max_files`` with default 100
11+
+-- FunctionB - declares ``timeout`` with default 30
12+
+-- FunctionC - declares nothing extra (sibling with no own fields)
13+
14+
The STI bug under regression: FunctionA's Pydantic default 100 would leak
15+
into the shared ``tool.max_files`` SA Column.default. Inserting a FunctionB
16+
or FunctionC row would then silently materialise ``max_files = 100`` in
17+
the sibling row because the ORM falls back to ``Column.default`` when the
18+
attribute is absent from the sibling's ``__dict__``.
19+
"""
20+
from sqlmodel_ext import (
21+
AutoPolymorphicIdentityMixin,
22+
PolymorphicBaseMixin,
23+
SQLModelBase,
24+
UUIDTableBaseMixin,
25+
)
26+
27+
28+
class Tool(SQLModelBase, UUIDTableBaseMixin, PolymorphicBaseMixin, table=True):
29+
"""STI root: all subclasses share this single ``tool`` table."""
30+
name: str
31+
32+
33+
class FunctionA(Tool, AutoPolymorphicIdentityMixin, table=True):
34+
"""Sibling with a field carrying a non-None Pydantic default."""
35+
max_files: int = 100
36+
37+
38+
class FunctionB(Tool, AutoPolymorphicIdentityMixin, table=True):
39+
"""Sibling with its own non-overlapping field + default."""
40+
timeout: int = 30
41+
42+
43+
class FunctionC(Tool, AutoPolymorphicIdentityMixin, table=True):
44+
"""Sibling with no additional fields - the purest victim of default leaks."""
45+
pass

tests/conftest.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""
2+
Pytest fixtures for sqlmodel-ext tests.
3+
4+
Uses aiosqlite (in-memory) so tests never touch a real database.
5+
"""
6+
from __future__ import annotations
7+
8+
from collections.abc import AsyncIterator
9+
10+
import pytest
11+
import pytest_asyncio
12+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
13+
from sqlalchemy.orm import configure_mappers
14+
from sqlmodel import SQLModel
15+
from sqlmodel.ext.asyncio.session import AsyncSession
16+
17+
from sqlmodel_ext import (
18+
register_sti_column_properties_for_all_subclasses,
19+
register_sti_columns_for_all_subclasses,
20+
)
21+
22+
# Import test models so the STI metaclass queues them before phase 1 runs.
23+
from tests import _models # noqa: F401 -- imported for side effects
24+
25+
26+
@pytest.fixture(scope="session", autouse=True)
27+
def _register_sti() -> None:
28+
"""Run the STI two-phase registration exactly once per session.
29+
30+
Phase 1 must run before ``configure_mappers()``, phase 2 after.
31+
Calling this more than once is idempotent because the queue is drained
32+
but SA mapper configuration short-circuits already-configured mappers.
33+
"""
34+
register_sti_columns_for_all_subclasses()
35+
configure_mappers()
36+
register_sti_column_properties_for_all_subclasses()
37+
38+
39+
@pytest_asyncio.fixture
40+
async def engine() -> AsyncIterator[AsyncEngine]:
41+
"""Fresh in-memory async SQLite engine per test.
42+
43+
Each test gets a brand-new database so state never leaks between tests.
44+
"""
45+
eng = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
46+
async with eng.begin() as conn:
47+
await conn.run_sync(SQLModel.metadata.create_all)
48+
try:
49+
yield eng
50+
finally:
51+
await eng.dispose()
52+
53+
54+
@pytest_asyncio.fixture
55+
async def session(engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
56+
"""Async session bound to the fresh engine."""
57+
async with AsyncSession(engine) as s:
58+
yield s

0 commit comments

Comments
 (0)