From 716a27775bfae1ca1ef4438ee5dadcb87bfb1a17 Mon Sep 17 00:00:00 2001 From: Aryamanz29 Date: Fri, 7 Aug 2026 00:20:48 +0530 Subject: [PATCH] fix: AtlanTag propagation defaults now reach the wire (BLDX-1589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AtlanTag declared propagate=False (and three sibling flags) but pyatlan serializes requests with exclude_unset=True, so a tag built without those flags sent nothing — and the server default won (propagate=True). The documented default was unenforceable: customers constructing AtlanTag(type_name=t) got propagation they never asked for. Adds AtlanObjectWithDefaults, a base that marks every field with a non-None declared default as set at construction so exclude_unset keeps it; AtlanTag now extends it. None/default_factory fields stay unset and omitted — nothing new leaks onto the wire. Asset models must NOT extend this base (their fields must stay omitted when untouched or partial updates would stomp server values). BREAKING BEHAVIOR CHANGE: callers who construct tags without propagation flags and relied on the server default (propagate) will now get the SDK's documented default (no propagation). Tags parsed from server responses round-trip their real values unchanged (test-covered). Verified live: bare AtlanTag(type_name=t) previously stored propagate=True on the server; with this change the wire carries all four flags and the server stores propagate=False. --- pyatlan/model/core.py | 54 +++++++++++++++++++++++++++++++++------ tests/unit/test_core.py | 56 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/pyatlan/model/core.py b/pyatlan/model/core.py index 49694cb4b..ebdbd0a0a 100644 --- a/pyatlan/model/core.py +++ b/pyatlan/model/core.py @@ -273,7 +273,36 @@ class Announcement: announcement_message: Optional[str] = Field(default=None) -class AtlanTag(AtlanObject): +class AtlanObjectWithDefaults(AtlanObject): + """An AtlanObject whose declared (non-None) field defaults actually reach + the wire. + + pyatlan serializes requests with ``exclude_unset=True`` — necessary for + partial updates, but it silently strips model defaults the caller never + assigned. For request models that DECLARE meaningful defaults, that meant + the documented default never applied: the field was omitted and the + server's own default won (BLDX-1589: ``AtlanTag.propagate`` documented + ``False``, wire carried nothing, server stored ``True``). + + Extending this base marks every field with a non-None declared default as + "set" at construction, so ``exclude_unset`` keeps it. Fields defaulting to + ``None`` (or using ``default_factory``) remain unset and omitted — nothing + new leaks onto the wire. + + Do NOT extend this from ``Asset`` models: their fields must stay omitted + when untouched, or partial updates would stomp server-side values. + """ + + def __init__(self, **data: Any) -> None: + super().__init__(**data) + self.__fields_set__.update( + name + for name, field in self.__fields__.items() + if field.default is not None + ) + + +class AtlanTag(AtlanObjectWithDefaults): class Config: extra = "forbid" @@ -301,26 +330,37 @@ class Config: ) propagate: Optional[bool] = Field( default=False, - description="whether to propagate the Atlan tag (True) or not (False)", + description=( + "whether to propagate the Atlan tag (True) or not (False). " + "The default (False) is always sent in the request — pass " + "propagate=True to propagate. (Before 9.12 the default was " + "silently omitted and the server default — propagate — won.)" + ), ) remove_propagations_on_entity_delete: Optional[bool] = Field( default=True, description=( "whether to remove the propagated Atlan tags when the Atlan tag " - "is removed from this asset (True) or not (False)" + "is removed from this asset (True) or not (False). The default " + "(True) is always sent in the request." ), alias="removePropagationsOnEntityDelete", ) restrict_propagation_through_lineage: Optional[bool] = Field( default=False, - description="whether to avoid propagating through lineage (True) or do propagate through lineage (False)", + description=( + "whether to avoid propagating through lineage (True) or do " + "propagate through lineage (False). The default (False) is " + "always sent in the request." + ), alias="restrictPropagationThroughLineage", ) restrict_propagation_through_hierarchy: Optional[bool] = Field( default=False, description=( "Whether to prevent this Atlan tag from propagating through " - "hierarchy (True) or allow it to propagate through hierarchy (False)" + "hierarchy (True) or allow it to propagate through hierarchy " + "(False). The default (False) is always sent in the request." ), alias="restrictPropagationThroughHierarchy", ) @@ -347,7 +387,7 @@ def of( :param entity_guid: unique identifier (GUID) of the entity to which the Atlan tag is to be assigned :param source_tag_attachment: (optional) source-specific details for the tag :param client: (optional) client instance used for translating source-specific details - :return: an Atlan tag assignment with default settings for propagation and a specific entity assignment + :return: an Atlan tag assignment with the SDK's declared propagation defaults sent explicitly (propagate=False); set propagate=True on the returned tag to propagate :raises InvalidRequestError: if client is not provided and source_tag_attachment is specified """ tag = AtlanTag(type_name=atlan_tag_name) # type: ignore[call-arg] @@ -382,7 +422,7 @@ async def of_async( :param entity_guid: unique identifier (GUID) of the entity to which the Atlan tag is to be assigned :param source_tag_attachment: (optional) source-specific details for the tag :param client: (optional) async client instance used for translating source-specific details - :return: an Atlan tag assignment with default settings for propagation and a specific entity assignment + :return: an Atlan tag assignment with the SDK's declared propagation defaults sent explicitly (propagate=False); set propagate=True on the returned tag to propagate :raises InvalidRequestError: if client is not provided and source_tag_attachment is specified """ tag = AtlanTag(type_name=atlan_tag_name) # type: ignore[call-arg] diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index 490e213ca..214b59f77 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + from typing import no_type_check from unittest.mock import MagicMock @@ -42,6 +44,60 @@ def test_atlan_tag_when_tag_name_is_empty_then_sentinel_is_returned( assert sut.type_name == AtlanTagName.get_deleted_sentinel() + def test_declared_propagation_defaults_reach_the_wire(self): + """A tag built without propagation flags sends the SDK's declared + defaults — previously they were omitted and the server default + (propagate=True) silently won (BLDX-1589).""" + sut = AtlanTag(**{"typeName": "123"}) + + wire = json.loads(sut.json(by_alias=True, exclude_unset=True)) + assert wire == { + "typeName": "123", + "propagate": False, + "removePropagationsOnEntityDelete": True, + "restrictPropagationThroughLineage": False, + "restrictPropagationThroughHierarchy": False, + } + + def test_explicit_propagation_values_override_defaults(self): + """Explicitly-set propagation flags reach the wire unchanged + (BLDX-1589).""" + sut = AtlanTag( + **{"typeName": "123"}, + propagate=True, + remove_propagations_on_entity_delete=False, + restrict_propagation_through_lineage=True, + restrict_propagation_through_hierarchy=True, + ) + + wire = json.loads(sut.json(by_alias=True, exclude_unset=True)) + assert wire == { + "typeName": "123", + "propagate": True, + "removePropagationsOnEntityDelete": False, + "restrictPropagationThroughLineage": True, + "restrictPropagationThroughHierarchy": True, + } + + def test_server_parsed_propagation_values_roundtrip_unchanged(self): + """Tags parsed from a server response keep the server's values on + re-serialization — the wire defaults never stomp them (BLDX-1589).""" + sut = AtlanTag( + **{ + "typeName": "123", + "propagate": True, + "removePropagationsOnEntityDelete": False, + "restrictPropagationThroughLineage": True, + "restrictPropagationThroughHierarchy": True, + } + ) + + wire = json.loads(sut.json(by_alias=True, exclude_unset=True)) + assert wire["propagate"] is True + assert wire["removePropagationsOnEntityDelete"] is False + assert wire["restrictPropagationThroughLineage"] is True + assert wire["restrictPropagationThroughHierarchy"] is True + class TestAtlanObjectExtraFields: @no_type_check