Skip to content

Drop Python 3.9 support#150

Merged
seapagan merged 13 commits into
mainfrom
breaking/drop-python-39
May 11, 2026
Merged

Drop Python 3.9 support#150
seapagan merged 13 commits into
mainfrom
breaking/drop-python-39

Conversation

@seapagan
Copy link
Copy Markdown
Owner

@seapagan seapagan commented May 10, 2026

This PR drops Python 3.9 support and moves the project floor to Python 3.10.

It updates package metadata, Ruff and mypy targets, the GitHub Actions test matrix, docs, and generated dependency exports. It also updates the dev tooling stack, including pytest 9 and mypy 2, and applies the Python 3.10 typing cleanups required by the newer Ruff rules.

While validating the dependency updates, pytest 9 surfaced SQLite ResourceWarning noise from tests that left connections to be finalized by GC. The test fixtures and related edge cases now close database handles deterministically, with a small finalizer backstop for abandoned sync database instances.

Summary by CodeRabbit

  • New Features

    • Introduced async functionality (use cautiously in early releases).
  • Documentation

    • Minimum Python requirement raised to 3.10+; note that 3.9 support ends after 0.20.0.
    • Clarified nullable field detection and foreign-key behaviour; added async-related caution.
  • Chores

    • CI updated (removed Python 3.9, added Python 3.14).
    • Development dependencies and test infrastructure refreshed; tests now ensure DB connections are closed and finalisation cleans up unclosed connections.

Review Change Stack

seapagan added 8 commits May 10, 2026 17:01
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
We should be using the `X | Y` notation instead of `Union` or `Optional`

Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 10, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 61527362-f0a8-4b2e-ae26-9f0bfb73f29a

📥 Commits

Reviewing files that changed from the base of the PR and between 307ea46 and 0550248.

📒 Files selected for processing (3)
  • tests/test_complex_types.py
  • tests/test_context_manager.py
  • tests/test_m2m.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_context_manager.py
  • tests/test_m2m.py

📝 Walkthrough

Walkthrough

This PR comprehensively modernises Python type annotations across the codebase from typing.Optional and typing.Union syntax to PEP 604 union types (X | None, X | Y). It also raises the minimum Python version from 3.9 to 3.10, improves test resource cleanup with generator-based fixtures, adds a finaliser to SqliterDB, and extends union type detection in partial model validation.

Changes

Python 3.10+ Type Annotation Migration

Layer / File(s) Summary
Version Requirements & Configuration
.github/workflows/testing.yml, .pre-commit-config.yaml, pyproject.toml, CONTRIBUTING.md
Minimum Python version raised from 3.9 to 3.10 in workflow, CI configuration, and project metadata. Tool versions (ruff v0.15.12, mypy v2.0.0, uv 0.11.12) updated accordingly.
Dependency Updates
requirements-dev.txt, pyproject.toml
Test tooling refreshed: pytest upgraded to 9.0.3, pytest-asyncio/pytest-cov/pytest-randomly updated. Linting/formatting tools bumped. Python 3.10+ marker variants consolidated.
Documentation Updates
README.md, docs/index.md, docs/guide/foreign-keys/orm.md, CONTRIBUTING.md
Added caution notes for new async feature stability. Documented Python version support timeline (0.20.0 last to support 3.9, 0.21.0+ requires 3.10). Updated FK nullability docs to mention both annotation forms.
Core Model & Schema Types
sqliter/model/model.py, sqliter/model/foreign_key.py, sqliter/helpers.py
Changed BaseDBModel.Meta index and unique index unions to `list[str
ORM Field, Model & Relationship Types
sqliter/orm/fields.py, sqliter/orm/model.py, sqliter/orm/registry.py
Modernised HasPK protocol, LazyLoader, ForeignKey descriptor and all related parameter and return types to PEP 604 syntax. Updated BaseDBModel.db_context and nullable FK _id field annotations. Updated ModelRegistry method signatures.
Many-to-Many Relationship Types
sqliter/orm/m2m.py, sqliter/orm/query.py
Updated HasPKAndContext protocol, ManyToManyInfo, ManyToManyManager, PrefetchedM2MResult, ManyToMany and ReverseManyToMany descriptors to use PEP 604 unions throughout.
Async ORM & Query Types
sqliter/asyncio/db.py, sqliter/asyncio/orm/fields.py, sqliter/asyncio/orm/m2m.py, sqliter/asyncio/orm/query.py, sqliter/asyncio/query.py
Modernised AsyncSqliterDB, AsyncLazyLoader, async many-to-many manager, async reverse relationships, and async query builder type annotations to use PEP 604 syntax.
Sync Query & Aggregation Types
sqliter/query/query.py, sqliter/query/aggregates.py, sqliter/orm/query.py
Updated QueryBuilder, QueryExecutionPlan, ReverseQuery, ReverseRelationship, and AggregateSpec annotations to use PEP 604 union syntax across all parameters and return types.
Primary SqliterDB API Types
sqliter/sqliter.py
Updated SqliterDB.__init__, filename property, cache helpers, instance creation, get(), delete(), select(), and index creation method signatures from Optional[...] to `T
Demo & TUI Type Updates
sqliter/tui/demos/base.py, sqliter/tui/demos/constraints.py, sqliter/tui/demos/filters.py, sqliter/tui/demos/models.py, sqliter/tui/demos/orm.py, demo.py
Modernised Demo dataclass, demo model field annotations, and optional/nullable foreign key examples to use PEP 604 union syntax.
Test Type Annotations, Fixture Cleanup & Assertions
tests/conftest.py, tests/test_*.py
Updated all test model field annotations to use `T
Resource Management & Finaliser
sqliter/sqliter.py, tests/test_sqliter.py
Added SqliterDB.__del__ finaliser to close unclosed connections during garbage collection with error suppression. Added test to verify finaliser releases abandoned connections without ResourceWarning.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

enhancement, testing, documentation

Poem

🐰
I hopped through unions, Optional I shed,
Pipes now bind types where my paws tread.
Fixtures yield and tidy each test,
Finaliser closes what was left.
Python 3.10 — onward we tread!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: dropping Python 3.9 support and raising the minimum version to 3.10. This is the main focus of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 92.75% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch breaking/drop-python-39

@seapagan seapagan self-assigned this May 10, 2026
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 10, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 16 complexity · 2 duplication

Metric Results
Complexity 16
Duplication 2

View in Codacy

🟢 Coverage 100.00% diff coverage · +0.00% coverage variation

Metric Results
Coverage variation +0.00% coverage variation (-1.00%)
Diff coverage 100.00% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (4899405) 6517 6517 100.00%
Head commit (0550248) 6491 (-26) 6491 (-26) 100.00% (+0.00%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#150) 119 119 100.00%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

seapagan added 2 commits May 10, 2026 18:58
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
@seapagan seapagan marked this pull request as ready for review May 10, 2026 18:39
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/test_context_manager.py (1)

117-141: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Ensure db_mock is closed on failure paths too.

db_mock.close() currently executes only after successful assertions. If an assertion fails earlier, this test can still leave an open connection and reintroduce warning noise.

Suggested patch
-        db_mock = SqliterDB(db_filename=str(db_file), auto_commit=True)
-        db_mock.create_table(ExampleModel)
-
-        # Use the context manager to simulate a transaction
-        with db_mock:
-            db_mock.insert(
-                ExampleModel(slug="test", name="Test", content="Test content")
-            )
-
-        # After the transaction, open a new connection to query the database
-        new_conn = sqlite3.connect(str(db_file))
-        try:
-            result = new_conn.execute(
-                "SELECT * FROM test_table WHERE slug = 'test'"
-            ).fetchone()
-        finally:
-            new_conn.close()
-
-        # Assert that the data was committed
-        assert result is not None, "Data was not committed."
-        assert result[3] == "test", (
-            f"Expected slug to be 'test', but got {result[3]}"
-        )
-
-        db_mock.close()
+        db_mock = SqliterDB(db_filename=str(db_file), auto_commit=True)
+        try:
+            db_mock.create_table(ExampleModel)
+
+            # Use the context manager to simulate a transaction
+            with db_mock:
+                db_mock.insert(
+                    ExampleModel(
+                        slug="test",
+                        name="Test",
+                        content="Test content",
+                    )
+                )
+
+            # After the transaction, open a new connection to query the database
+            new_conn = sqlite3.connect(str(db_file))
+            try:
+                result = new_conn.execute(
+                    "SELECT * FROM test_table WHERE slug = 'test'"
+                ).fetchone()
+            finally:
+                new_conn.close()
+
+            # Assert that the data was committed
+            assert result is not None, "Data was not committed."
+            assert result[3] == "test", (
+                f"Expected slug to be 'test', but got {result[3]}"
+            )
+        finally:
+            db_mock.close()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_context_manager.py` around lines 117 - 141, The test opens a
SqliterDB instance (db_mock = SqliterDB(...)) and only calls db_mock.close()
after assertions, so failures can leave the DB open; wrap the interaction and
assertions in a try/finally (or use a pytest fixture/teardown) that always calls
db_mock.close(), ensuring db_mock.close() is executed on all paths (referencing
SqliterDB, db_mock, create_table, insert, and close).
🧹 Nitpick comments (2)
sqliter/sqliter.py (1)

636-643: ⚡ Quick win

Verify finalizer behaviour during interpreter shutdown.

The __del__ finalizer attempts to close the SQLite connection during garbage collection. While the use of suppress() and getattr() with defaults provides some safety, finalizers can still encounter issues during interpreter shutdown when imported modules (like sqlite3 and contextlib) may be partially torn down. Consider whether a context manager or explicit close() pattern would be more reliable than relying on __del__.

tests/test_m2m.py (1)

469-482: ⚡ Quick win

Consider passing raw_conn to DummyConn.__init__ for clarity.

The DummyConn methods at lines 471 and 474 reference raw_conn, which isn't defined until line 480. Whilst this works in Python (methods aren't executed until called), it's unconventional and may confuse readers. A clearer pattern would be to pass raw_conn as a constructor parameter.

♻️ Proposed refactor
     class DummyConn:
+        def __init__(self, conn: sqlite3.Connection) -> None:
+            self._conn = conn
+
         def cursor(self) -> DummyCursor:
-            return DummyCursor(raw_conn.cursor())
+            return DummyCursor(self._conn.cursor())

         def commit(self) -> None:
-            raw_conn.commit()
+            self._conn.commit()

     class DummyDB(SqliterDB):
         def connect(self) -> sqlite3.Connection:
             return cast("sqlite3.Connection", dummy_conn)

     raw_conn = sqlite3.connect(":memory:")
-    dummy_conn = DummyConn()
+    dummy_conn = DummyConn(raw_conn)
     dummy_db = DummyDB(memory=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_m2m.py` around lines 469 - 482, The DummyConn class currently
closes over raw_conn defined later which is confusing; add an __init__(self,
raw_conn: sqlite3.Connection) that stores it on self (e.g., self._raw_conn) and
update cursor() and commit() to use self._raw_conn, then instantiate
DummyConn(raw_conn) where dummy_conn is created; no other behavior changes are
needed—DummyDB.connect should still return the dummy_conn instance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_complex_types.py`:
- Line 196: The parameter annotation for invalid_value doesn't match the actual
parametrized inputs (one case passes a str "not a list"); update the type hint
for invalid_value in the test (the function parameter named invalid_value) to
include str (e.g., list[Any] | dict[Any, Any] | set[Any] | tuple[Any, ...] |
str) or broaden to Any/object so the annotation aligns with the test cases and
removes type-checking noise.

---

Outside diff comments:
In `@tests/test_context_manager.py`:
- Around line 117-141: The test opens a SqliterDB instance (db_mock =
SqliterDB(...)) and only calls db_mock.close() after assertions, so failures can
leave the DB open; wrap the interaction and assertions in a try/finally (or use
a pytest fixture/teardown) that always calls db_mock.close(), ensuring
db_mock.close() is executed on all paths (referencing SqliterDB, db_mock,
create_table, insert, and close).

---

Nitpick comments:
In `@tests/test_m2m.py`:
- Around line 469-482: The DummyConn class currently closes over raw_conn
defined later which is confusing; add an __init__(self, raw_conn:
sqlite3.Connection) that stores it on self (e.g., self._raw_conn) and update
cursor() and commit() to use self._raw_conn, then instantiate
DummyConn(raw_conn) where dummy_conn is created; no other behavior changes are
needed—DummyDB.connect should still return the dummy_conn instance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 45f6b15d-b407-4028-b41e-3f32c8b79912

📥 Commits

Reviewing files that changed from the base of the PR and between 4899405 and 307ea46.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (49)
  • .github/workflows/testing.yml
  • .pre-commit-config.yaml
  • CONTRIBUTING.md
  • README.md
  • demo.py
  • docs/guide/foreign-keys/orm.md
  • docs/index.md
  • pyproject.toml
  • requirements-dev.txt
  • sqliter/asyncio/db.py
  • sqliter/asyncio/orm/fields.py
  • sqliter/asyncio/orm/m2m.py
  • sqliter/asyncio/orm/query.py
  • sqliter/asyncio/query.py
  • sqliter/helpers.py
  • sqliter/model/foreign_key.py
  • sqliter/model/model.py
  • sqliter/orm/fields.py
  • sqliter/orm/m2m.py
  • sqliter/orm/model.py
  • sqliter/orm/query.py
  • sqliter/orm/registry.py
  • sqliter/query/aggregates.py
  • sqliter/query/query.py
  • sqliter/sqliter.py
  • sqliter/tui/demos/base.py
  • sqliter/tui/demos/constraints.py
  • sqliter/tui/demos/filters.py
  • sqliter/tui/demos/models.py
  • sqliter/tui/demos/orm.py
  • tests/conftest.py
  • tests/test_aggregates.py
  • tests/test_asyncio_core.py
  • tests/test_bulk_insert.py
  • tests/test_bulk_update.py
  • tests/test_complex_types.py
  • tests/test_context_manager.py
  • tests/test_dates.py
  • tests/test_foreign_keys.py
  • tests/test_foreign_keys_orm.py
  • tests/test_m2m.py
  • tests/test_model.py
  • tests/test_optional_fields.py
  • tests/test_orm_fields.py
  • tests/test_prefetch_related.py
  • tests/test_query.py
  • tests/test_select_related.py
  • tests/test_sqliter.py
  • tests/test_unique.py

Comment thread tests/test_complex_types.py Outdated
seapagan added 3 commits May 10, 2026 20:10
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
@seapagan
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@seapagan seapagan merged commit 691ddc5 into main May 11, 2026
14 checks passed
@seapagan seapagan deleted the breaking/drop-python-39 branch May 11, 2026 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant