From 9c1aaf8b63989e597c0f78fc0f048c0f9cc333f6 Mon Sep 17 00:00:00 2001 From: Pierce Freeman Date: Tue, 31 Mar 2026 22:21:51 -0700 Subject: [PATCH 1/5] Reproduce client error and add pydantic testing to CI --- .github/workflows/test.yml | 58 +++++++- iceaxe/__tests__/test_base.py | 21 ++- iceaxe/__tests__/test_field.py | 40 ++++++ iceaxe/field.py | 28 ++-- scripts/pydantic_version_matrix.py | 209 +++++++++++++++++++++++++++++ 5 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 scripts/pydantic_version_matrix.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7b2bc7b..1da071d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,6 +41,62 @@ jobs: env: ICEAXE_LOG_LEVEL: DEBUG + pydantic-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.generate.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - name: Install uv with Python 3.12 + uses: astral-sh/setup-uv@v6.3.0 + with: + enable-cache: false + python-version: 3.12 + - name: Generate Pydantic compatibility matrix + id: generate + run: | + { + echo 'matrix<> "$GITHUB_OUTPUT" + + pydantic-compat: + needs: [pydantic-matrix] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.pydantic-matrix.outputs.matrix) }} + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: iceaxe + POSTGRES_PASSWORD: mysecretpassword + POSTGRES_DB: iceaxe_test_db + ports: + - 5438:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + - name: Install uv with Python 3.12 + uses: astral-sh/setup-uv@v6.3.0 + with: + enable-cache: false + python-version: 3.12 + - name: Run compatibility tests on Pydantic ${{ matrix.label }} + run: > + uv run + --with ${{ matrix.package }}${{ matrix.specifier }} + pytest -v --continue-on-collection-errors + env: + ICEAXE_LOG_LEVEL: DEBUG + lint: runs-on: ubuntu-latest steps: @@ -54,7 +110,7 @@ jobs: run: make lint full-build: - needs: [test, lint] + needs: [test, pydantic-compat, lint] if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'Full Build') || startsWith(github.ref, 'refs/tags/v') strategy: matrix: diff --git a/iceaxe/__tests__/test_base.py b/iceaxe/__tests__/test_base.py index 74bec28..7c7cc2e 100644 --- a/iceaxe/__tests__/test_base.py +++ b/iceaxe/__tests__/test_base.py @@ -1,10 +1,10 @@ -from typing import Generic, TypeVar +from typing import Annotated, Any, Generic, TypeVar from iceaxe.base import ( DBModelMetaclass, TableBase, ) -from iceaxe.field import DBFieldInfo +from iceaxe.field import DBFieldInfo, Field def test_autodetect(): @@ -50,3 +50,20 @@ class User(TableBase): # Check that the special fields exist with the right types assert isinstance(User.model_fields["modified_attrs"], DBFieldInfo) assert isinstance(User.model_fields["modified_attrs_callbacks"], DBFieldInfo) + + +def test_model_fields_with_annotated_metadata(): + class Dummy: + def __get_pydantic_core_schema__(self, source_type, handler): + return handler(source_type) + + Payload = Annotated[dict[str, Any] | None, Dummy()] + + class Event(TableBase, autodetect=False): + metadata: Payload = Field(default=None, is_json=True) + + field = Event.model_fields["metadata"] + assert isinstance(field, DBFieldInfo) + assert field.annotation == dict[str, Any] | None + assert field.default is None + assert field.is_json is True diff --git a/iceaxe/__tests__/test_field.py b/iceaxe/__tests__/test_field.py index a469a8d..cf6c9d6 100644 --- a/iceaxe/__tests__/test_field.py +++ b/iceaxe/__tests__/test_field.py @@ -1,3 +1,7 @@ +from typing import Any + +from pydantic.fields import FieldInfo + from iceaxe.base import TableBase from iceaxe.field import DBFieldClassDefinition, DBFieldInfo @@ -9,3 +13,39 @@ def test_db_field_class_definition_instantiation(): assert field_def.root_model == TableBase assert field_def.key == "test_key" assert isinstance(field_def.field_definition, DBFieldInfo) + + +def test_extend_field_accepts_db_kwargs_already_in_attributes_set(): + # Simulate Pydantic normalizing an Iceaxe field back to FieldInfo while + # preserving Iceaxe-specific entries in _attributes_set. + field = FieldInfo(default=None) + field.annotation = dict[str, Any] | None + field._attributes_set = { + "primary_key": False, + "postgres_config": None, + "foreign_key": None, + "unique": False, + "index": False, + "check_expression": None, + "is_json": True, + "explicit_type": None, + "default": None, + "annotation": dict[str, Any] | None, + } + + extended = DBFieldInfo.extend_field( + field, + primary_key=False, + postgres_config=None, + foreign_key=None, + unique=False, + index=False, + check_expression=None, + is_json=False, + explicit_type=None, + ) + + assert isinstance(extended, DBFieldInfo) + assert extended.annotation == dict[str, Any] | None + assert extended.default is None + assert extended.is_json is True diff --git a/iceaxe/field.py b/iceaxe/field.py index 363a508..96fe21e 100644 --- a/iceaxe/field.py +++ b/iceaxe/field.py @@ -148,16 +148,26 @@ def extend_field( Helper function to extend a Pydantic FieldInfo with database-specific attributes. """ + field_attributes = dict(field._attributes_set) # type: ignore + autoincrement = field_attributes.pop("autoincrement", _Unset) + db_kwargs: dict[str, Any] = { + "primary_key": field_attributes.pop("primary_key", primary_key), + "postgres_config": field_attributes.pop("postgres_config", postgres_config), + "foreign_key": field_attributes.pop("foreign_key", foreign_key), + "unique": field_attributes.pop("unique", unique), + "index": field_attributes.pop("index", index), + "check_expression": field_attributes.pop( + "check_expression", check_expression + ), + "is_json": field_attributes.pop("is_json", is_json), + "explicit_type": field_attributes.pop("explicit_type", explicit_type), + } + if autoincrement is not _Unset: + db_kwargs["autoincrement"] = autoincrement + return cls( - primary_key=primary_key, - postgres_config=postgres_config, - foreign_key=foreign_key, - unique=unique, - index=index, - check_expression=check_expression, - is_json=is_json, - explicit_type=explicit_type, - **field._attributes_set, # type: ignore + **db_kwargs, + **field_attributes, ) def to_db_value(self, value: Any): diff --git a/scripts/pydantic_version_matrix.py b/scripts/pydantic_version_matrix.py new file mode 100644 index 0000000..0b9e831 --- /dev/null +++ b/scripts/pydantic_version_matrix.py @@ -0,0 +1,209 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# /// + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any +from urllib.parse import quote +from urllib.request import urlopen + +STABLE_RELEASE = re.compile(r"^(?P\d+)\.(?P\d+)\.(?P\d+)$") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Emit a GitHub Actions matrix for Pydantic compatibility tests. " + "The matrix includes exact fixed versions and the latest patch " + "release for the most recent minor lines." + ) + ) + parser.add_argument("--package", default="pydantic") + parser.add_argument( + "--major", + type=int, + default=2, + help="Only include releases from this major version. Default: 2", + ) + parser.add_argument( + "--limit", + type=int, + default=25, + help="Maximum number of minor lines to include. Default: 25", + ) + parser.add_argument( + "--fixed", + nargs="*", + default=[], + metavar="VERSION", + help=( + "Exact stable versions to include in addition to the rolling minor " + "matrix, for example: --fixed 2.10.6 2.11.0 2.12.5" + ), + ) + parser.add_argument( + "--releases-file", + type=Path, + help=( + "Load release metadata from a local JSON file instead of PyPI. " + "The file may contain the full PyPI JSON payload or only a " + "'releases' mapping." + ), + ) + parser.add_argument( + "--pretty", + action="store_true", + help="Pretty-print the JSON output.", + ) + return parser.parse_args() + + +def parse_stable_version(version: str) -> tuple[int, int, int] | None: + match = STABLE_RELEASE.fullmatch(version) + if match is None: + return None + + return ( + int(match.group("major")), + int(match.group("minor")), + int(match.group("patch")), + ) + + +def is_yanked_release(files: Any) -> bool: + if not isinstance(files, list) or not files: + return False + + yanked_flags = [ + file_info.get("yanked", False) + for file_info in files + if isinstance(file_info, dict) + ] + return bool(yanked_flags) and all(yanked_flags) + + +def load_release_map(package: str, releases_file: Path | None) -> dict[str, Any]: + if releases_file is not None: + payload = json.loads(releases_file.read_text()) + else: + url = f"https://pypi.org/pypi/{quote(package)}/json" + with urlopen(url, timeout=30) as response: + payload = json.load(response) + + if isinstance(payload, dict) and "releases" in payload: + releases = payload["releases"] + else: + releases = payload + + if not isinstance(releases, dict): + raise ValueError("Expected a JSON object containing a 'releases' mapping.") + + return releases + + +def latest_patch_by_minor( + release_map: dict[str, Any], major: int, limit: int +) -> list[tuple[int, int, int]]: + latest_by_minor: dict[tuple[int, int], tuple[int, int, int]] = {} + + for version, files in release_map.items(): + parsed = parse_stable_version(version) + if parsed is None or parsed[0] != major or is_yanked_release(files): + continue + + key = parsed[:2] + current = latest_by_minor.get(key) + if current is None or parsed > current: + latest_by_minor[key] = parsed + + return sorted(latest_by_minor.values(), reverse=True)[:limit] + + +def build_matrix( + package: str, + major: int, + fixed_versions: list[str], + rolling_versions: list[tuple[int, int, int]], + release_map: dict[str, Any], +) -> dict[str, list[dict[str, str]]]: + include: list[dict[str, str]] = [] + seen_fixed: set[str] = set() + + for version in fixed_versions: + parsed = parse_stable_version(version) + if parsed is None: + raise ValueError( + f"Fixed version '{version}' must be a stable X.Y.Z release." + ) + if parsed[0] != major: + raise ValueError( + f"Fixed version '{version}' is outside the configured major line " + f"{major}.x." + ) + if version not in release_map: + raise ValueError(f"Fixed version '{version}' was not found on PyPI.") + if version in seen_fixed: + continue + + seen_fixed.add(version) + include.append( + { + "label": f"fixed-{version}", + "kind": "fixed", + "specifier": f"=={version}", + "resolved_version": version, + "package": package, + } + ) + + for major, minor, patch in rolling_versions: + minor_label = f"{major}.{minor}" + include.append( + { + "label": f"minor-{minor_label}", + "kind": "minor", + "specifier": f"=={minor_label}.*", + "resolved_version": f"{major}.{minor}.{patch}", + "package": package, + } + ) + + return {"include": include} + + +def main() -> int: + args = parse_args() + release_map = load_release_map(args.package, args.releases_file) + rolling_versions = latest_patch_by_minor(release_map, args.major, args.limit) + + if not rolling_versions: + raise ValueError( + f"No stable {args.package} {args.major}.x releases were found on PyPI." + ) + + matrix = build_matrix( + package=args.package, + major=args.major, + fixed_versions=args.fixed, + rolling_versions=rolling_versions, + release_map=release_map, + ) + + json.dump(matrix, sys.stdout, indent=2 if args.pretty else None) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ValueError as exc: + sys.stderr.write(f"{exc}\n") + raise SystemExit(1) From 1f5adea4f6d33ab58945577c064a9f5fdd614949 Mon Sep 17 00:00:00 2001 From: Pierce Freeman Date: Tue, 31 Mar 2026 22:23:50 -0700 Subject: [PATCH 2/5] Fix lints --- iceaxe/__tests__/test_base.py | 16 +++++++++------- iceaxe/__tests__/test_field.py | 7 ++++--- iceaxe/field.py | 10 ++++------ 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/iceaxe/__tests__/test_base.py b/iceaxe/__tests__/test_base.py index 7c7cc2e..81c0266 100644 --- a/iceaxe/__tests__/test_base.py +++ b/iceaxe/__tests__/test_base.py @@ -7,6 +7,14 @@ from iceaxe.field import DBFieldInfo, Field +class _AnnotatedDummy: + def __get_pydantic_core_schema__(self, source_type, handler): + return handler(source_type) + + +AnnotatedPayload = Annotated[dict[str, Any] | None, _AnnotatedDummy()] + + def test_autodetect(): class WillAutodetect(TableBase): pass @@ -53,14 +61,8 @@ class User(TableBase): def test_model_fields_with_annotated_metadata(): - class Dummy: - def __get_pydantic_core_schema__(self, source_type, handler): - return handler(source_type) - - Payload = Annotated[dict[str, Any] | None, Dummy()] - class Event(TableBase, autodetect=False): - metadata: Payload = Field(default=None, is_json=True) + metadata: AnnotatedPayload = Field(default=None, is_json=True) field = Event.model_fields["metadata"] assert isinstance(field, DBFieldInfo) diff --git a/iceaxe/__tests__/test_field.py b/iceaxe/__tests__/test_field.py index cf6c9d6..0659557 100644 --- a/iceaxe/__tests__/test_field.py +++ b/iceaxe/__tests__/test_field.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast from pydantic.fields import FieldInfo @@ -19,8 +19,9 @@ def test_extend_field_accepts_db_kwargs_already_in_attributes_set(): # Simulate Pydantic normalizing an Iceaxe field back to FieldInfo while # preserving Iceaxe-specific entries in _attributes_set. field = FieldInfo(default=None) - field.annotation = dict[str, Any] | None - field._attributes_set = { + raw_field = cast(Any, field) + raw_field.annotation = dict[str, Any] | None + raw_field._attributes_set = { "primary_key": False, "postgres_config": None, "foreign_key": None, diff --git a/iceaxe/field.py b/iceaxe/field.py index 96fe21e..31e90bb 100644 --- a/iceaxe/field.py +++ b/iceaxe/field.py @@ -148,9 +148,9 @@ def extend_field( Helper function to extend a Pydantic FieldInfo with database-specific attributes. """ - field_attributes = dict(field._attributes_set) # type: ignore + field_attributes = cast(dict[str, Any], dict(field._attributes_set)) autoincrement = field_attributes.pop("autoincrement", _Unset) - db_kwargs: dict[str, Any] = { + db_kwargs: DBFieldInputs = { "primary_key": field_attributes.pop("primary_key", primary_key), "postgres_config": field_attributes.pop("postgres_config", postgres_config), "foreign_key": field_attributes.pop("foreign_key", foreign_key), @@ -165,10 +165,8 @@ def extend_field( if autoincrement is not _Unset: db_kwargs["autoincrement"] = autoincrement - return cls( - **db_kwargs, - **field_attributes, - ) + kwargs = cast(DBFieldInputs, {**field_attributes, **db_kwargs}) + return cls(**kwargs) def to_db_value(self, value: Any): if self.is_json: From c13028d6316c6a4764e13d11e2bd6561c25143ba Mon Sep 17 00:00:00 2001 From: Pierce Freeman Date: Tue, 31 Mar 2026 22:29:29 -0700 Subject: [PATCH 3/5] Fix older field setting --- iceaxe/__tests__/test_base.py | 10 ++++++++++ iceaxe/base.py | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/iceaxe/__tests__/test_base.py b/iceaxe/__tests__/test_base.py index 81c0266..93b15f7 100644 --- a/iceaxe/__tests__/test_base.py +++ b/iceaxe/__tests__/test_base.py @@ -60,6 +60,16 @@ class User(TableBase): assert isinstance(User.model_fields["modified_attrs_callbacks"], DBFieldInfo) +def test_model_fields_assignment_is_supported(): + class User(TableBase, autodetect=False): + id: int + + fields = User.model_fields + User.model_fields = fields + + assert User.model_fields is fields + + def test_model_fields_with_annotated_metadata(): class Event(TableBase, autodetect=False): metadata: AnnotatedPayload = Field(default=None, is_json=True) diff --git a/iceaxe/base.py b/iceaxe/base.py index 07cc292..2ce9657 100644 --- a/iceaxe/base.py +++ b/iceaxe/base.py @@ -155,6 +155,10 @@ def model_fields(self) -> dict[str, DBFieldInfo]: # type: ignore """ return getattr(self, "__pydantic_fields__", {}) # type: ignore + @model_fields.setter + def model_fields(self, value: dict[str, Any]): + self.__pydantic_fields__ = value # type: ignore + class UniqueConstraint(BaseModel): """ From a7bd89f97753067323303427261dfe2f7ee737b7 Mon Sep 17 00:00:00 2001 From: Pierce Freeman Date: Tue, 31 Mar 2026 22:33:12 -0700 Subject: [PATCH 4/5] Fix v2 crash --- iceaxe/__tests__/test_base.py | 21 +++++++++++++++++---- iceaxe/base.py | 4 ++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/iceaxe/__tests__/test_base.py b/iceaxe/__tests__/test_base.py index 93b15f7..72bc5ec 100644 --- a/iceaxe/__tests__/test_base.py +++ b/iceaxe/__tests__/test_base.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any, Generic, TypeVar +from typing import Annotated, Any, Generic, TypeVar, cast from iceaxe.base import ( DBModelMetaclass, @@ -64,10 +64,23 @@ def test_model_fields_assignment_is_supported(): class User(TableBase, autodetect=False): id: int - fields = User.model_fields - User.model_fields = fields + user_cls = cast(Any, User) + fields = user_cls.model_fields + user_cls.model_fields = fields - assert User.model_fields is fields + assert user_cls.model_fields is fields + + +def test_instance_model_fields_access_is_supported(): + class User(TableBase, autodetect=False): + id: int + + user = User(id=1) + user.id = 2 + + assert user.id == 2 + assert user.model_fields["id"] is User.model_fields["id"] + assert user.modified_attrs["id"] == 2 def test_model_fields_with_annotated_metadata(): diff --git a/iceaxe/base.py b/iceaxe/base.py index 2ce9657..b6c088c 100644 --- a/iceaxe/base.py +++ b/iceaxe/base.py @@ -270,6 +270,10 @@ class User(TableBase): List of callbacks to be called when the model is modified. """ + @property + def model_fields(self) -> dict[str, DBFieldInfo]: # type: ignore + return self.__class__.model_fields + def __setattr__(self, name: str, value: Any) -> None: """ Track modified attributes when fields are updated. From d6a776465100c0d4da4e3d4851867c783aa07d27 Mon Sep 17 00:00:00 2001 From: Pierce Freeman Date: Tue, 31 Mar 2026 22:36:44 -0700 Subject: [PATCH 5/5] Mountaineer requires higher stack --- .../__tests__/mountaineer/dependencies/test_core.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/iceaxe/__tests__/mountaineer/dependencies/test_core.py b/iceaxe/__tests__/mountaineer/dependencies/test_core.py index 249e44c..de45ebb 100644 --- a/iceaxe/__tests__/mountaineer/dependencies/test_core.py +++ b/iceaxe/__tests__/mountaineer/dependencies/test_core.py @@ -3,8 +3,15 @@ import asyncpg import pytest -from iceaxe.mountaineer.config import DatabaseConfig -from iceaxe.mountaineer.dependencies.core import get_db_connection +try: + from iceaxe.mountaineer.config import DatabaseConfig + from iceaxe.mountaineer.dependencies.core import get_db_connection +except ImportError as exc: + pytest.skip( + f"Mountaineer integration is unavailable for this dependency set: {exc}", + allow_module_level=True, + ) + from iceaxe.session import DBConnection