Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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<<EOF'
uv run scripts/pydantic_version_matrix.py \
--fixed 2.10.6 2.11.0 2.12.5
echo 'EOF'
} >> "$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:
Expand All @@ -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:
Expand Down
11 changes: 9 additions & 2 deletions iceaxe/__tests__/mountaineer/dependencies/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
46 changes: 44 additions & 2 deletions iceaxe/__tests__/test_base.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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
41 changes: 41 additions & 0 deletions iceaxe/__tests__/test_field.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
8 changes: 8 additions & 0 deletions iceaxe/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 19 additions & 11 deletions iceaxe/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading