Skip to content
Open
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
54 changes: 47 additions & 7 deletions pyatlan/model/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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",
)
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/test_core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import json

from typing import no_type_check
from unittest.mock import MagicMock

Expand Down Expand Up @@ -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
Expand Down