Skip to content

Commit bbf14ba

Browse files
authored
fix(typing): deprecate DictProtocol public export (#757)
## Summary Closes #583. - Deprecates `advanced_alchemy.typing.DictProtocol` starting in 1.11.0 and schedules removal for 2.0.0. - Keeps the compatibility object available through 1.x with lazy warning behavior instead of binding the public export at import time. - Removes the runtime serialization dependency on the public protocol while preserving the `hasattr(obj, "__dict__")` behavior from #579. - Adds regression coverage for `has_dict_attribute()`, `schema_dump()` fallback behavior, direct typing import warnings, and the deprecated service typing shim. - Adds a 1.11.0 changelog entry for the deprecation.
1 parent 81c937e commit bbf14ba

4 files changed

Lines changed: 115 additions & 6 deletions

File tree

advanced_alchemy/typing.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
# pyright: reportUnsupportedDunderAll=false
12
"""Public type shims for optional dependencies.
23
34
Re-exports foundational stub types so that internal and external code
45
can ``from advanced_alchemy.typing import SQLModelBase`` (or any other
56
shim) without reaching into private modules.
67
"""
78

9+
from typing import Any
10+
811
from advanced_alchemy._typing import (
912
ATTRS_INSTALLED,
1013
CATTRS_INSTALLED,
@@ -20,7 +23,6 @@
2023
BaseModel,
2124
BaseModelLike,
2225
DataclassProtocol,
23-
DictProtocol,
2426
DTOData,
2527
DTODataLike,
2628
FailFast,
@@ -41,7 +43,7 @@
4143
convert,
4244
)
4345

44-
__all__ = (
46+
__all__ = ( # noqa: F822
4547
"ATTRS_INSTALLED",
4648
"CATTRS_INSTALLED",
4749
"LITESTAR_INSTALLED",
@@ -76,3 +78,21 @@
7678
"cattrs_unstructure",
7779
"convert",
7880
)
81+
82+
83+
def __getattr__(name: str) -> Any:
84+
if name != "DictProtocol":
85+
msg = f"module {__name__!r} has no attribute {name!r}"
86+
raise AttributeError(msg)
87+
88+
from advanced_alchemy._typing import DictProtocol
89+
from advanced_alchemy.utils.deprecation import warn_deprecation
90+
91+
warn_deprecation(
92+
version="1.11.0",
93+
removal_in="2.0.0",
94+
deprecated_name=f"{__name__}.DictProtocol",
95+
kind="import",
96+
alternative="advanced_alchemy.utils.serialization.has_dict_attribute",
97+
)
98+
return DictProtocol

advanced_alchemy/utils/serialization.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@
7474
AttrsLike,
7575
BaseModel,
7676
BaseModelLike,
77-
DictProtocol,
7877
DTOData,
7978
DTODataLike,
8079
FailFast,
@@ -200,6 +199,10 @@
200199
"""Type alias for bulk model dictionaries."""
201200

202201

202+
class _HasDictAttribute(Protocol):
203+
__dict__: dict[str, Any]
204+
205+
203206
@dataclass(frozen=True)
204207
class SchemaDumpConfig:
205208
"""Configuration for dumping schema-like service input data."""
@@ -370,7 +373,7 @@ def is_dict(v: Any) -> TypeGuard[dict[str, Any]]:
370373
return isinstance(v, dict)
371374

372375

373-
def has_dict_attribute(obj: Any) -> "TypeGuard[DictProtocol]":
376+
def has_dict_attribute(obj: Any) -> "TypeGuard[_HasDictAttribute]":
374377
"""Check if an object has a __dict__ attribute."""
375378
return obj is not None and hasattr(obj, "__dict__")
376379

docs/changelog.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@
1717
excluding unset values, ``None`` values, defaults, and sentinel values
1818
while preserving the existing partial-update defaults.
1919

20+
.. change:: deprecate DictProtocol
21+
:type: misc
22+
:issue: 583
23+
24+
Deprecates ``advanced_alchemy.typing.DictProtocol`` and schedules it
25+
for removal in 2.0. Runtime code should rely on ``has_dict_attribute()``
26+
or direct ``__dict__`` duck typing instead of Protocol-based instance
27+
checks.
28+
2029
.. change:: add choices and boolean filters
2130
:type: feature
2231
:pr: 723

tests/unit/test_utils/test_serialization.py

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
import threading
99
import time
10+
import warnings
1011
from decimal import Decimal
1112
from enum import Enum
1213
from ipaddress import IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6Interface, IPv6Network
@@ -417,6 +418,62 @@ def __init__(self) -> None:
417418
assert schema_dump(AttrsLike()) == {"name": "Ada"}
418419

419420

421+
@pytest.mark.parametrize(
422+
"value",
423+
[
424+
None,
425+
1,
426+
[1, 2],
427+
{},
428+
],
429+
)
430+
def test_has_dict_attribute_rejects_values_without_instance_dict(value: Any) -> None:
431+
assert not serialization.has_dict_attribute(value)
432+
433+
434+
def test_has_dict_attribute_accepts_plain_object() -> None:
435+
class PlainObject:
436+
def __init__(self) -> None:
437+
self.name = "Ada"
438+
439+
assert serialization.has_dict_attribute(PlainObject())
440+
441+
442+
def test_has_dict_attribute_rejects_slots_only_object() -> None:
443+
class SlotsOnlyObject:
444+
__slots__ = ("name",)
445+
446+
def __init__(self) -> None:
447+
self.name = "Ada"
448+
449+
assert not serialization.has_dict_attribute(SlotsOnlyObject())
450+
451+
452+
def test_schema_dump_uses_instance_dict_fallback_for_plain_objects() -> None:
453+
class PlainObject:
454+
def __init__(self) -> None:
455+
self.name = "Ada"
456+
457+
assert schema_dump(PlainObject()) == {"name": "Ada"}
458+
459+
460+
def test_schema_dump_passes_through_slots_only_objects() -> None:
461+
class SlotsOnlyObject:
462+
__slots__ = ("name",)
463+
464+
def __init__(self) -> None:
465+
self.name = "Ada"
466+
467+
value = SlotsOnlyObject()
468+
result: Any = schema_dump(value)
469+
470+
assert result is value
471+
472+
473+
def test_serialization_module_does_not_import_public_dict_protocol() -> None:
474+
assert "DictProtocol" not in vars(serialization)
475+
476+
420477
@pytest.mark.parametrize(
421478
("kind", "value", "expected"),
422479
[
@@ -978,14 +1035,34 @@ def test_legacy_service_typing_shim_emits_deprecation_warning(name: str, new_mod
9781035
assert value is canonical
9791036

9801037

1038+
def test_typing_dict_protocol_emits_deprecation_warning() -> None:
1039+
with pytest.warns(DeprecationWarning, match=r"DictProtocol.*1\.11\.0.*removed in 2\.0\.0"):
1040+
value = _import_attr("advanced_alchemy.typing", "DictProtocol")
1041+
1042+
assert value is _import_attr("advanced_alchemy._typing", "DictProtocol")
1043+
1044+
1045+
def test_typing_canonical_exports_do_not_emit_deprecation_warning() -> None:
1046+
typing_module = importlib.import_module("advanced_alchemy.typing")
1047+
1048+
with warnings.catch_warnings():
1049+
warnings.simplefilter("error")
1050+
assert getattr(typing_module, "PYDANTIC_INSTALLED") is PYDANTIC_INSTALLED
1051+
1052+
1053+
def test_legacy_service_typing_dict_protocol_remains_compatible() -> None:
1054+
with pytest.warns(DeprecationWarning, match="DictProtocol"):
1055+
value = _import_attr("advanced_alchemy.service.typing", "DictProtocol")
1056+
1057+
assert value is _import_attr("advanced_alchemy._typing", "DictProtocol")
1058+
1059+
9811060
def test_legacy_service_typing_shim_unknown_attribute_raises() -> None:
9821061
with pytest.raises(AttributeError, match="not_a_real_name"):
9831062
_import_attr("advanced_alchemy.service.typing", "not_a_real_name")
9841063

9851064

9861065
def test_legacy_service_typing_shim_keeps_pydantic_use_failfast() -> None:
987-
import warnings
988-
9891066
import advanced_alchemy.service.typing as shim
9901067

9911068
with warnings.catch_warnings():

0 commit comments

Comments
 (0)