diff --git a/ormar/models/helpers/relations.py b/ormar/models/helpers/relations.py index e28b0e10e..8bce9f40a 100644 --- a/ormar/models/helpers/relations.py +++ b/ormar/models/helpers/relations.py @@ -192,20 +192,28 @@ def serialize( by excluding the children. """ try: - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", message="Pydantic serializer warnings" - ) - return handler(children) - except ValueError as exc: # pragma: no cover - if not str(exc).startswith("Circular reference"): - raise exc - - result = [] - for child in children: - # If there is one circular ref dump all children as pk only - result.append({child.ormar_config.pkname: child.pk}) - return result + try: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", message="Pydantic serializer warnings" + ) + return handler(children) + except ValueError as exc: # pragma: no cover + if not str(exc).startswith("Circular reference"): + raise exc + + result = [] + for child in children: + if not hasattr(child, "ormar_config"): + continue + # If there is one circular ref dump all children as pk only + result.append({child.ormar_config.pkname: child.pk}) + return result + except ReferenceError: + # Pydantic >= 2.13 may invoke this serializer with weakref + # children whose referent is already gone (e.g. when ormar's + # late metaclass rebuild changed dispatch ordering). + return None decorator = field_serializer(related_name, mode="wrap", check_fields=False)( serialize diff --git a/ormar/models/metaclass.py b/ormar/models/metaclass.py index 2c8083ad1..d9508c089 100644 --- a/ormar/models/metaclass.py +++ b/ormar/models/metaclass.py @@ -632,17 +632,26 @@ def serialize( Serialize a value if it's not expired weak reference. """ try: - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", message="Pydantic serializer warnings" - ) - return handler(value) + try: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", message="Pydantic serializer warnings" + ) + return handler(value) + except ValueError as exc: + if not str(exc).startswith("Circular reference"): + raise exc + # Pydantic >= 2.13 dispatches the wrap serializer with + # broader value types (e.g. QuerysetProxy for M2M reverse + # accessors) where 2.12 unwrapped to Model first. Earlier + # pydantic versions never reach this branch with a non- + # Model value, so the None return is uncovered on the + # 2.11 floor pinned in CI. + if value is None or not hasattr(value, "ormar_config"): + return None # pragma: no cover + return {value.ormar_config.pkname: value.pk} except ReferenceError: return None - except ValueError as exc: - if not str(exc).startswith("Circular reference"): - raise exc - return {value.ormar_config.pkname: value.pk} if value else None return serialize @@ -753,10 +762,24 @@ def __new__( # type: ignore # noqa: CCR001 None, ) ) - new_model.model_rebuild(force=True) - new_model.pk = PkDescriptor(name=new_model.ormar_config.pkname) + if not ( + new_model.ormar_config.abstract + or new_model.ormar_config.proxy + or new_model.ormar_config.requires_ref_update + ): + # Pydantic >= 2.13 changed how TypeAdapter(Annotated[T, + # FieldInfo(...)]) resolves nested schemas — it now uses + # the cached child snapshot instead of walking the live + # model. Reverse-relation fields added by + # expand_reverse_relationships above become invisible + # from nested dumps unless we invalidate the cached + # schema. Mark incomplete so the next access (or the + # FastAPI-style TypeAdapter wrapper) recompiles afresh + # without the stale snapshot. + new_model.__pydantic_complete__ = False # type: ignore[attr-defined] + return new_model @property diff --git a/poetry.lock b/poetry.lock index b44c2c88e..10641cdab 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. [[package]] name = "aiomysql" @@ -7,7 +7,7 @@ description = "MySQL driver for asyncio." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"mysql\"" +markers = "extra == \"mysql\" or extra == \"all\"" files = [ {file = "aiomysql-0.3.2-py3-none-any.whl", hash = "sha256:c82c5ba04137d7afd5c693a258bea8ead2aad77101668044143a991e04632eb2"}, {file = "aiomysql-0.3.2.tar.gz", hash = "sha256:72d15ef5cfc34c03468eb41e1b90adb9fd9347b0b589114bd23ead569a02ac1a"}, @@ -47,7 +47,7 @@ description = "asyncio bridge to the standard sqlite3 module" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"sqlite\"" +markers = "extra == \"sqlite\" or extra == \"all\"" files = [ {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, @@ -167,7 +167,7 @@ description = "Timeout context manager for asyncio programs" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"aiopg\" or extra == \"all\" or (extra == \"all\" or extra == \"postgres\" or extra == \"postgresql\" or extra == \"aiopg\") and python_version < \"3.11.0\"" +markers = "extra == \"aiopg\" or extra == \"all\" or (extra == \"postgresql\" or extra == \"postgres\" or extra == \"all\" or extra == \"aiopg\") and python_version < \"3.11.0\"" files = [ {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, @@ -180,7 +180,7 @@ description = "An asyncio PostgreSQL driver" optional = true python-versions = ">=3.9.0" groups = ["main"] -markers = "extra == \"all\" or extra == \"postgres\" or extra == \"postgresql\"" +markers = "extra == \"postgresql\" or extra == \"postgres\" or extra == \"all\"" files = [ {file = "asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61"}, {file = "asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be"}, @@ -314,7 +314,7 @@ description = "Foreign Function Interface for Python calling C code." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "(extra == \"all\" or extra == \"crypto\") and platform_python_implementation != \"PyPy\"" +markers = "(extra == \"crypto\" or extra == \"all\") and platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -690,7 +690,7 @@ description = "cryptography is a package which provides cryptographic recipes an optional = true python-versions = "!=3.9.0,!=3.9.1,>=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"crypto\"" +markers = "extra == \"crypto\" or extra == \"all\"" files = [ {file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"}, {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"}, @@ -1957,7 +1957,7 @@ description = "psycopg2 - Python-PostgreSQL Database Adapter" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"aiopg\" or extra == \"all\" or extra == \"postgres\" or extra == \"postgresql\"" +markers = "extra == \"aiopg\" or extra == \"all\" or extra == \"postgresql\" or extra == \"postgres\"" files = [ {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4"}, {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56"}, @@ -2047,7 +2047,7 @@ description = "C parser in Python" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "(extra == \"all\" or extra == \"crypto\") and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +markers = "(extra == \"crypto\" or extra == \"all\") and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -2277,7 +2277,7 @@ description = "Pure Python MySQL Driver" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"all\" or extra == \"mysql\"" +markers = "extra == \"mysql\" or extra == \"all\"" files = [ {file = "pymysql-1.2.0-py3-none-any.whl", hash = "sha256:62169ce6d5510f08e140c5e7990ee884a9764024e4a9a27b2cc11f1099322ae0"}, {file = "pymysql-1.2.0.tar.gz", hash = "sha256:6c7b17ca686988104d7426c27895b455cdeea3e9d3ceb1270f0c3704fead8c33"}, @@ -3138,4 +3138,4 @@ sqlite = ["aiosqlite"] [metadata] lock-version = "2.1" python-versions = "^3.10.0" -content-hash = "65a72dc61f47ea20129a7af9a3d01634590fefaed20dd22cf47e08447193ca83" +content-hash = "9a4e978fa5918f84958ab225d273b097c1ebe0fc0b40741fa52cfca728e828e1" diff --git a/pyproject.toml b/pyproject.toml index 879db8375..e62e7bb3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,9 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.10.0" -pydantic = "^2.11.9" +# Pinned below 2.14 because ormar relies on pydantic internals; bump the +# upper bound manually after verifying compatibility with each new release. +pydantic = ">=2.11.9,<2.14" SQLAlchemy = {version = "^2.0.40", extras = ["asyncio"]} cryptography = { version = ">=44.0.1,<49.0.0", optional = true } # Async database drivers