Skip to content

Commit 1981878

Browse files
committed
[refactor] Make client layer natively support both Pydantic and msgspec models
- Replace isinstance checks in client/common/asset.py with _is_model_instance for dual-model compatibility (9 call sites) - Replace isinstance in client/asset.py and aio/batch.py for AtlasGlossaryTerm - Make BulkRequest.process_attributes skip msgspec models (they handle relationship categorization in their own serialization pipeline) - Use _is_model_instance in BulkRequest.process_relationship_attributes - Register msgspec JSON encoder in pyatlan/model/core.py using model's own to_json(nested=True) for proper nested API format serialization - Make Asset._convert_to_real_type_ accept v9 msgspec models via _is_model_instance - Remove all monkey-patches from tests_v9/unit/conftest.py (Patch 1-4 no longer needed — dual-model support is now in production code) All tests pass: 5798 legacy + 1540 v9
1 parent b3c6546 commit 1981878

6 files changed

Lines changed: 68 additions & 145 deletions

File tree

pyatlan/client/aio/batch.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from typing import TYPE_CHECKING, Dict, List, Optional, cast
66

7-
from pyatlan.validate import validate_arguments
7+
from pyatlan.validate import _is_model_instance, validate_arguments
88

99
from pyatlan.client.asset import (
1010
AssetCreationHandling,
@@ -410,7 +410,7 @@ def _track_response(self, response: AssetMutationResponse, sent: list[Asset]):
410410

411411
@staticmethod
412412
def __track(tracker: List[Asset], candidate: Asset):
413-
if isinstance(candidate, AtlasGlossaryTerm):
413+
if _is_model_instance(candidate, AtlasGlossaryTerm):
414414
# trim_to_required for AtlasGlossaryTerm requires anchor
415415
# which is not include in AssetMutationResponse
416416
asset = cast(Asset, AtlasGlossaryTerm.ref_by_guid(candidate.guid))

pyatlan/client/asset.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
parse_obj_as,
3333
)
3434

35-
from pyatlan.validate import validate_arguments
35+
from pyatlan.validate import _is_model_instance, validate_arguments
3636
from tenacity import (
3737
RetryError,
3838
retry,
@@ -2571,7 +2571,7 @@ def _track_response(self, response: AssetMutationResponse, sent: list[Asset]):
25712571

25722572
@staticmethod
25732573
def __track(tracker: List[Asset], candidate: Asset):
2574-
if isinstance(candidate, AtlasGlossaryTerm):
2574+
if _is_model_instance(candidate, AtlasGlossaryTerm):
25752575
# trim_to_required for AtlasGlossaryTerm requires anchor
25762576
# which is not include in AssetMutationResponse
25772577
asset = cast(Asset, AtlasGlossaryTerm.ref_by_guid(candidate.guid))

pyatlan/client/common/asset.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
with_active_term,
6060
)
6161
from pyatlan.utils import unflatten_custom_metadata_for_entity
62+
from pyatlan.validate import _is_model_instance
6263

6364
if TYPE_CHECKING:
6465
from pyatlan.client.aio import AsyncAtlanClient
@@ -331,7 +332,7 @@ def process_response(
331332
assets := [
332333
asset
333334
for asset in (search_results.current_page() or search_results)
334-
if isinstance(asset, asset_type)
335+
if _is_model_instance(asset, asset_type)
335336
]
336337
)
337338
):
@@ -535,7 +536,7 @@ def process_fluent_search_response(
535536
"""
536537
if search_results and search_results.current_page():
537538
first_result = search_results.current_page()[0]
538-
if isinstance(first_result, asset_type):
539+
if _is_model_instance(first_result, asset_type):
539540
return first_result
540541
else:
541542
raise ErrorCode.ASSET_NOT_FOUND_BY_NAME.exception_with_parameters(
@@ -564,7 +565,7 @@ def process_direct_api_response(
564565
asset_type.__name__, qualified_name
565566
)
566567
asset = GetByQualifiedName.handle_relationships(raw_json)
567-
if not isinstance(asset, asset_type):
568+
if not _is_model_instance(asset, asset_type):
568569
raise ErrorCode.ASSET_NOT_FOUND_BY_NAME.exception_with_parameters(
569570
asset_type.__name__, qualified_name
570571
)
@@ -644,7 +645,7 @@ def process_fluent_search_response(
644645
"""
645646
if search_results and search_results.current_page():
646647
first_result = search_results.current_page()[0]
647-
if isinstance(first_result, asset_type):
648+
if _is_model_instance(first_result, asset_type):
648649
return first_result
649650
else:
650651
raise ErrorCode.ASSET_NOT_TYPE_REQUESTED.exception_with_parameters(
@@ -667,7 +668,7 @@ def process_direct_api_response(
667668
:raises NotFoundError: if asset not found or wrong type
668669
"""
669670
asset = GetByQualifiedName.handle_relationships(raw_json)
670-
if not isinstance(asset, asset_type):
671+
if not _is_model_instance(asset, asset_type):
671672
raise ErrorCode.ASSET_NOT_TYPE_REQUESTED.exception_with_parameters(
672673
guid, asset_type.__name__
673674
)
@@ -1205,7 +1206,9 @@ def handle_glossary_anchor(
12051206
:param glossary_guid: GUID of the glossary
12061207
:raises AtlanError: if glossary_guid is required but missing
12071208
"""
1208-
if isinstance(asset, (AtlasGlossaryTerm, AtlasGlossaryCategory)):
1209+
if _is_model_instance(asset, AtlasGlossaryTerm) or _is_model_instance(
1210+
asset, AtlasGlossaryCategory
1211+
):
12091212
if not glossary_guid:
12101213
raise ErrorCode.MISSING_GLOSSARY_GUID.exception_with_parameters(
12111214
asset_type_name
@@ -1561,7 +1564,7 @@ def validate_search_results(
15611564
"""
15621565
if results and results.current_page():
15631566
first_result = results.current_page()[0]
1564-
if not isinstance(first_result, asset_type):
1567+
if not _is_model_instance(first_result, asset_type):
15651568
if guid is None:
15661569
raise ErrorCode.ASSET_NOT_FOUND_BY_NAME.exception_with_parameters(
15671570
asset_type.__name__, qualified_name
@@ -1665,7 +1668,7 @@ def process_search_results(
16651668
assets := [
16661669
asset
16671670
for asset in (results.current_page() or results)
1668-
if isinstance(asset, asset_type)
1671+
if _is_model_instance(asset, asset_type)
16691672
]
16701673
)
16711674
):
@@ -1706,13 +1709,13 @@ async def process_async_search_results(
17061709
if current_page:
17071710
# Use current page if available
17081711
assets = [
1709-
asset for asset in current_page if isinstance(asset, asset_type)
1712+
asset for asset in current_page if _is_model_instance(asset, asset_type)
17101713
]
17111714
else:
17121715
# Otherwise, collect from async iterator
17131716
assets = []
17141717
async for asset in results:
1715-
if isinstance(asset, asset_type):
1718+
if _is_model_instance(asset, asset_type):
17161719
assets.append(asset)
17171720

17181721
if assets:
@@ -1897,7 +1900,7 @@ def process_search_results(response, glossary):
18971900
category_dict = {}
18981901

18991902
for category in filter(
1900-
lambda a: isinstance(a, AtlasGlossaryCategory), response
1903+
lambda a: _is_model_instance(a, AtlasGlossaryCategory), response
19011904
):
19021905
guid = category.guid
19031906
category_dict[guid] = category
@@ -1934,13 +1937,13 @@ async def process_async_search_results(response, glossary):
19341937
categories = [
19351938
asset
19361939
for asset in response.current_page()
1937-
if isinstance(asset, AtlasGlossaryCategory)
1940+
if _is_model_instance(asset, AtlasGlossaryCategory)
19381941
]
19391942
else:
19401943
# Collect from async iterator
19411944
categories = []
19421945
async for asset in response:
1943-
if isinstance(asset, AtlasGlossaryCategory):
1946+
if _is_model_instance(asset, AtlasGlossaryCategory):
19441947
categories.append(asset)
19451948

19461949
for category in categories:

pyatlan/model/assets/core/asset.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
StarredDetails,
4141
)
4242
from pyatlan.utils import init_guid, validate_required_fields
43+
from pyatlan.validate import _is_model_instance
4344

4445
from .referenceable import Referenceable
4546

@@ -163,6 +164,11 @@ def _convert_to_real_type_(cls, data):
163164
if isinstance(data, Asset):
164165
return data
165166

167+
# Accept v9 msgspec models as-is — they share the same class name
168+
# hierarchy and are compatible via the dual-model support layer.
169+
if _is_model_instance(data, Asset):
170+
return data
171+
166172
if isinstance(data, list): # Recursively process lists
167173
return [cls._convert_to_real_type_(item) for item in data]
168174

pyatlan/model/core.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,36 @@
66
from abc import ABC
77
from typing import TYPE_CHECKING
88

9+
import msgspec
910
import yaml # type: ignore[import-untyped]
1011
from pydantic.v1 import BaseModel, Extra, Field, root_validator, validator
1112

13+
from pydantic.v1.json import ENCODERS_BY_TYPE
14+
1215
from pyatlan.model.utils import encoders, to_camel_case
16+
from pyatlan.validate import _is_model_instance
17+
18+
# ---------------------------------------------------------------------------
19+
# Register msgspec.Struct in Pydantic's JSON encoder so that Pydantic-based
20+
# models (e.g. BulkRequest, AtlanRequest) can serialise v9 msgspec entities
21+
# when calling .json(). This enables dual-model compatibility.
22+
#
23+
# If the v9 model has its own ``to_json`` method (all v9 assets do), we
24+
# use it — it produces the correct nested API format with ``attributes``,
25+
# ``relationshipAttributes``, ``appendRelationshipAttributes``, etc.
26+
# For simpler structs without custom serialization we fall back to the
27+
# generic ``msgspec.to_builtins``.
28+
# ---------------------------------------------------------------------------
29+
30+
31+
def _encode_msgspec_struct(obj):
32+
if hasattr(obj, "to_json"):
33+
return json.loads(obj.to_json(nested=True))
34+
return msgspec.to_builtins(obj)
35+
36+
37+
if msgspec.Struct not in ENCODERS_BY_TYPE:
38+
ENCODERS_BY_TYPE[msgspec.Struct] = _encode_msgspec_struct
1339

1440
if TYPE_CHECKING:
1541
from dataclasses import dataclass
@@ -447,6 +473,12 @@ class BulkRequest(AtlanObject, GenericModel, Generic[T]):
447473
def process_attributes(cls, asset):
448474
from pyatlan.model.assets import Asset
449475

476+
# v9 msgspec models handle relationship categorization
477+
# in their own serialization pipeline (categorize_relationships),
478+
# so return them as-is — the JSON encoder will handle the rest.
479+
if isinstance(asset, msgspec.Struct):
480+
return asset
481+
450482
if not isinstance(asset, Asset):
451483
return asset
452484

@@ -503,7 +535,7 @@ def process_relationship_attributes(cls, asset, attribute):
503535
# Process list of relationship attributes
504536
if attribute_value and isinstance(attribute_value, list):
505537
for value in attribute_value:
506-
if value and isinstance(value, Asset):
538+
if value and _is_model_instance(value, Asset):
507539
if value.semantic == SaveSemantic.REMOVE:
508540
remove_attributes.append(value)
509541
elif value.semantic == SaveSemantic.APPEND:
@@ -532,7 +564,7 @@ def process_relationship_attributes(cls, asset, attribute):
532564
exclude_attributes.add(attribute_name)
533565

534566
# Process single relationship attribute
535-
elif attribute_value and isinstance(attribute_value, Asset):
567+
elif attribute_value and _is_model_instance(attribute_value, Asset):
536568
if attribute_value.semantic == SaveSemantic.REMOVE:
537569
# Add the replace attribute to the set to exclude it
538570
# from the "attributes" property in the request payload.

tests_v9/unit/conftest.py

Lines changed: 8 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -4,134 +4,16 @@
44
"""
55
PyTest configuration and fixtures for pyatlan_v9 unit tests.
66
These fixtures provide test utilities for msgspec-based models.
7-
8-
This module also applies compatibility patches that allow v9 msgspec models
9-
to work seamlessly with the legacy Pydantic-based client layer. The patches
10-
MUST run at module load time (before any client classes are imported) so that
11-
Pydantic's cached validators pick up the patched behaviour.
127
"""
138

14-
# ---------------------------------------------------------------------------
15-
# v9 ↔ legacy compatibility patches (executed at import time)
16-
# ---------------------------------------------------------------------------
17-
18-
import msgspec
19-
from pydantic.v1.json import ENCODERS_BY_TYPE
20-
from pydantic.v1.main import ModelMetaclass
21-
22-
from pyatlan.model.assets.core.asset import Asset as _LegacyAsset
23-
from pyatlan_v9.model.assets import Asset as _V9Asset
24-
25-
# ---------- Patch 1: Pydantic isinstance ---------------------------------
26-
# Pydantic's ModelMetaclass (which extends ABCMeta) overrides
27-
# ``__instancecheck__`` and short-circuits with a
28-
# ``hasattr(instance, '__post_root_validators__')`` guard that always rejects
29-
# msgspec Structs. We relax that guard so that a v9 model whose *class name*
30-
# appears in the MRO of the checked type is accepted.
31-
_original_instancecheck = ModelMetaclass.__instancecheck__
32-
33-
34-
def _v9_instancecheck(self, instance):
35-
"""Accept v9 msgspec models where MRO names match."""
36-
if _original_instancecheck(self, instance):
37-
return True
38-
if isinstance(instance, msgspec.Struct):
39-
v9_mro_names = {cls.__name__ for cls in type(instance).__mro__}
40-
if self.__name__ in v9_mro_names:
41-
return True
42-
return False
43-
44-
45-
ModelMetaclass.__instancecheck__ = _v9_instancecheck
46-
47-
# ---------- Patch 2: Pydantic JSON encoder --------------------------------
48-
# Register ``msgspec.Struct`` so that Pydantic's json-serialisation path
49-
# (used by BulkRequest.json() and AtlanRequest.json()) can serialise v9
50-
# models without raising ``TypeError: not JSON serializable``.
51-
ENCODERS_BY_TYPE[msgspec.Struct] = lambda o: msgspec.to_builtins(o)
52-
53-
# ---------- Patch 3: Asset._convert_to_real_type_ -------------------------
54-
# The legacy @validate_arguments decorator calls
55-
# ``Asset._convert_to_real_type_(data)`` when validating Union[Asset, …]
56-
# parameters. This patch makes it accept v9 Struct instances as-is.
57-
_original_convert = _LegacyAsset._convert_to_real_type_.__func__
58-
59-
60-
@classmethod # type: ignore[misc]
61-
def _convert_to_real_type_v9_compat(cls, data):
62-
"""Accept v9 msgspec models in legacy Pydantic validation."""
63-
if isinstance(data, _V9Asset):
64-
return data
65-
return _original_convert(cls, data)
66-
67-
68-
_LegacyAsset._convert_to_real_type_ = _convert_to_real_type_v9_compat # type: ignore[assignment]
69-
70-
# ---------- Patch 4: BulkRequest.process_attributes -----------------------
71-
# The legacy BulkRequest validator tries to access Pydantic-specific
72-
# attributes (``remove_relationship_attributes``, ``attributes.__fields_set__``,
73-
# ``.dict()``) on every entity. v9 Struct models don't have those.
74-
# We monkey-patch the validator's *code object* so that any reference captured
75-
# in Pydantic's lambda closures automatically gets the updated behaviour.
76-
from pyatlan.model import core as _core_module # noqa: E402
77-
78-
_core_module.msgspec = msgspec # inject into module globals
79-
80-
from pyatlan.model.core import BulkRequest # noqa: E402
81-
82-
_original_process_func = BulkRequest.process_attributes.__func__
83-
84-
85-
def _new_process_attributes(cls, asset):
86-
"""BulkRequest validator that skips v9 models."""
87-
if isinstance(asset, msgspec.Struct):
88-
return asset
89-
# --- original legacy logic (inlined) ---
90-
from pyatlan.model.assets import Asset # noqa: F811
91-
92-
if not isinstance(asset, Asset):
93-
return asset
94-
95-
exclude_attributes = set()
96-
asset.remove_relationship_attributes = {}
97-
asset.append_relationship_attributes = {}
98-
for attribute in asset.attributes.__fields_set__:
99-
exclude_attributes.update(cls.process_relationship_attributes(asset, attribute))
100-
exclude_relationship_attributes = {
101-
key: True
102-
for key in [
103-
"remove_relationship_attributes",
104-
"append_relationship_attributes",
105-
]
106-
if not getattr(asset, key)
107-
}
108-
if exclude_attributes:
109-
exclude_relationship_attributes = {
110-
**{"attributes": exclude_attributes},
111-
**exclude_relationship_attributes,
112-
}
113-
return asset.__class__(
114-
**asset.dict(
115-
by_alias=True,
116-
exclude_unset=True,
117-
exclude=exclude_relationship_attributes,
118-
)
119-
)
120-
121-
122-
_original_process_func.__code__ = _new_process_attributes.__code__
123-
124-
# ---------------------------------------------------------------------------
125-
# NOW import the client (triggers AssetClient import with @validate_arguments)
126-
# ---------------------------------------------------------------------------
127-
from json import load # noqa: E402
128-
from pathlib import Path # noqa: E402
129-
from unittest.mock import patch # noqa: E402
130-
131-
import pytest # noqa: E402
132-
133-
from pyatlan.client.atlan import AtlanClient # noqa: E402
134-
from pyatlan_v9.model.serde import Serde, get_serde # noqa: E402
9+
from json import load
10+
from pathlib import Path
11+
from unittest.mock import patch
12+
13+
import pytest
14+
15+
from pyatlan.client.atlan import AtlanClient
16+
from pyatlan_v9.model.serde import Serde, get_serde
13517

13618
# Use the same test data directory as the original tests
13719
TEST_DATA_DIR = Path(__file__).parent.parent.parent / "tests" / "unit" / "data"

0 commit comments

Comments
 (0)