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__/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 diff --git a/iceaxe/__tests__/test_base.py b/iceaxe/__tests__/test_base.py index 74bec28..72bc5ec 100644 --- a/iceaxe/__tests__/test_base.py +++ b/iceaxe/__tests__/test_base.py @@ -1,10 +1,18 @@ -from typing import Generic, TypeVar +from typing import Annotated, Any, Generic, TypeVar, cast from iceaxe.base import ( DBModelMetaclass, TableBase, ) -from iceaxe.field import DBFieldInfo +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(): @@ -50,3 +58,37 @@ 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_assignment_is_supported(): + class User(TableBase, autodetect=False): + id: int + + user_cls = cast(Any, User) + fields = user_cls.model_fields + user_cls.model_fields = 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(): + class Event(TableBase, autodetect=False): + metadata: AnnotatedPayload = 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..0659557 100644 --- a/iceaxe/__tests__/test_field.py +++ b/iceaxe/__tests__/test_field.py @@ -1,3 +1,7 @@ +from typing import Any, cast + +from pydantic.fields import FieldInfo + from iceaxe.base import TableBase from iceaxe.field import DBFieldClassDefinition, DBFieldInfo @@ -9,3 +13,40 @@ 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) + 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, + "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/base.py b/iceaxe/base.py index 07cc292..b6c088c 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): """ @@ -266,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. diff --git a/iceaxe/field.py b/iceaxe/field.py index 363a508..31e90bb 100644 --- a/iceaxe/field.py +++ b/iceaxe/field.py @@ -148,17 +148,25 @@ def extend_field( Helper function to extend a Pydantic FieldInfo with database-specific attributes. """ - 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 - ) + field_attributes = cast(dict[str, Any], dict(field._attributes_set)) + autoincrement = field_attributes.pop("autoincrement", _Unset) + 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), + "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 + + kwargs = cast(DBFieldInputs, {**field_attributes, **db_kwargs}) + return cls(**kwargs) def to_db_value(self, value: Any): if self.is_json: 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)