Skip to content

Commit 1d269c5

Browse files
Aryamanz29claude
andcommitted
fix: distinguish deleted tag (keep sentinel) from never-existed (raise); consolidate tests
Refine BLDX-1530: get_id_for_name returns None for both a soft-deleted tag and a name that never existed, but the cache tracks deleted names separately (deleted_names). Use it so the retranslator only RAISES for a name that never existed; a known-deleted tag (or the (DELETED) sentinel from a read round-trip) keeps the sentinel — lossless, since the tag really existed. Applied to both the sync and async retranslators. Move the retranslator unit tests into tests/unit/test_atlan_tag_name.py (+ aio), alongside the existing tag-serde tests, and drop the standalone test_retranslators files. Adds the deleted-tag-keeps-sentinel case so both behaviours are covered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8eaf948 commit 1d269c5

6 files changed

Lines changed: 140 additions & 121 deletions

File tree

pyatlan/model/aio/retranslators.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,18 @@ async def retranslate(self, data: Dict[str, Any]) -> Dict[str, Any]:
9898
if not raw_name:
9999
continue
100100
tag_name = str(raw_name)
101-
tag_id = await self.client.atlan_tag_cache.get_id_for_name(tag_name)
102-
if not tag_id and tag_name != DELETED_:
101+
cache = self.client.atlan_tag_cache
102+
tag_id = await cache.get_id_for_name(tag_name)
103+
# get_id_for_name returns None for BOTH a soft-deleted tag and
104+
# a name that never existed. Distinguish them: a known-deleted
105+
# tag (in deleted_names) — or the (DELETED) sentinel from a read
106+
# round-trip — keeps the sentinel (lossless; the tag really
107+
# existed), whereas a name that never existed is a client error.
108+
if (
109+
not tag_id
110+
and tag_name != DELETED_
111+
and tag_name not in cache.deleted_names
112+
):
103113
raise ErrorCode.ATLAN_TAG_NOT_FOUND_BY_NAME.exception_with_parameters(
104114
tag_name
105115
)

pyatlan/model/retranslators.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,18 @@ def retranslate(self, data: Dict[str, Any]) -> Dict[str, Any]:
105105
if not raw_name:
106106
continue
107107
tag_name = str(raw_name)
108-
tag_id = self.client.atlan_tag_cache.get_id_for_name(tag_name)
109-
if not tag_id and tag_name != DELETED_:
108+
cache = self.client.atlan_tag_cache
109+
tag_id = cache.get_id_for_name(tag_name)
110+
# get_id_for_name returns None for BOTH a soft-deleted tag and
111+
# a name that never existed. Distinguish them: a known-deleted
112+
# tag (in deleted_names) — or the (DELETED) sentinel from a read
113+
# round-trip — keeps the sentinel (lossless; the tag really
114+
# existed), whereas a name that never existed is a client error.
115+
if (
116+
not tag_id
117+
and tag_name != DELETED_
118+
and tag_name not in cache.deleted_names
119+
):
110120
raise ErrorCode.ATLAN_TAG_NOT_FOUND_BY_NAME.exception_with_parameters(
111121
tag_name
112122
)

tests/unit/aio/test_atlan_tag_name.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
# SPDX-License-Identifier: Apache-2.0
22
# Copyright 2025 Atlan Pte. Ltd.
3+
from unittest.mock import AsyncMock, MagicMock
4+
35
import pytest
46
from pydantic.v1 import parse_obj_as
57

68
import pyatlan.cache.aio.atlan_tag_cache
79
from pyatlan.client.aio.client import AsyncAtlanClient
10+
from pyatlan.errors import NotFoundError
811
from pyatlan.model.aio.core import AsyncAtlanRequest, AsyncAtlanResponse
12+
from pyatlan.model.aio.retranslators import AsyncAtlanTagRetranslator
913
from pyatlan.model.assets import Purpose
1014
from pyatlan.model.constants import DELETED_
1115
from pyatlan.model.core import AtlanTagName
@@ -262,3 +266,46 @@ async def get_source_tags_attr_id(_, tag_id):
262266
_assert_asset_tags(
263267
purpose_without_translation_and_retranslation, is_retranslated=True
264268
)
269+
270+
271+
# --- AsyncAtlanTagRetranslator: tag name → ID resolution on the write path (BLDX-1530) ---
272+
def _tag_retranslator(name_to_id, deleted_names=None):
273+
client = MagicMock()
274+
client.atlan_tag_cache.get_id_for_name = AsyncMock(
275+
side_effect=lambda name: name_to_id.get(name)
276+
)
277+
client.atlan_tag_cache.get_source_tags_attr_id = AsyncMock(return_value=None)
278+
client.atlan_tag_cache.deleted_names = set(deleted_names or ())
279+
return AsyncAtlanTagRetranslator(client)
280+
281+
282+
async def test_retranslate_resolves_existing_tag_name_to_id():
283+
retranslator = _tag_retranslator({"PII": "abc123"})
284+
out = await retranslator.retranslate({"classifications": [{"typeName": "PII"}]})
285+
assert out["classifications"][0]["typeName"] == "abc123"
286+
287+
288+
async def test_retranslate_deleted_tag_keeps_sentinel_not_raises():
289+
retranslator = _tag_retranslator({}, deleted_names={"WasDeleted"})
290+
out = await retranslator.retranslate(
291+
{"classifications": [{"typeName": "WasDeleted"}]}
292+
)
293+
assert out["classifications"][0]["typeName"] == DELETED_
294+
295+
296+
async def test_retranslate_missing_tag_raises_named_not_found():
297+
retranslator = _tag_retranslator({})
298+
with pytest.raises(NotFoundError) as exc:
299+
await retranslator.retranslate(
300+
{"addOrUpdateClassifications": [{"typeName": "Alert: DQ"}]}
301+
)
302+
message = str(exc.value)
303+
assert "Alert: DQ" in message
304+
assert "ATLAN-PYTHON-404-006" in message
305+
assert DELETED_ not in message
306+
307+
308+
async def test_retranslate_deleted_sentinel_round_trip_is_preserved():
309+
retranslator = _tag_retranslator({})
310+
out = await retranslator.retranslate({"classifications": [{"typeName": DELETED_}]})
311+
assert out["classifications"][0]["typeName"] == DELETED_

tests/unit/aio/test_retranslators.py

Lines changed: 0 additions & 47 deletions
This file was deleted.

tests/unit/test_atlan_tag_name.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
# SPDX-License-Identifier: Apache-2.0
22
# Copyright 2022 Atlan Pte. Ltd.
3+
from unittest.mock import MagicMock
4+
35
import pytest
46
from pydantic.v1 import parse_obj_as
57

68
import pyatlan.cache.atlan_tag_cache
79
from pyatlan.client.atlan import AtlanClient
10+
from pyatlan.errors import NotFoundError
811
from pyatlan.model.assets import Purpose
912
from pyatlan.model.constants import DELETED_
1013
from pyatlan.model.core import AtlanRequest, AtlanResponse, AtlanTagName
14+
from pyatlan.model.retranslators import AtlanTagRetranslator
1115

1216
ATLAN_TAG_ID = "yiB7RLvdC2yeryLPjaDeHM"
1317

@@ -237,3 +241,68 @@ def get_source_tags_attr_id(_, tag_id):
237241
_assert_asset_tags(
238242
purpose_without_translation_and_retranslation, is_retranslated=True
239243
)
244+
245+
246+
# --- AtlanTagRetranslator: tag name → ID resolution on the write path (BLDX-1530) ---
247+
# Covers BOTH behaviours: an existing tag still resolves + the deleted sentinel
248+
# still round-trips (no regression), AND a genuinely unresolvable name now raises
249+
# a clear NotFoundError instead of shipping the (DELETED) sentinel to Atlas.
250+
def _tag_retranslator(name_to_id, deleted_names=None):
251+
client = MagicMock()
252+
client.atlan_tag_cache.get_id_for_name.side_effect = lambda name: name_to_id.get(
253+
name
254+
)
255+
client.atlan_tag_cache.get_source_tags_attr_id.return_value = None
256+
client.atlan_tag_cache.deleted_names = set(deleted_names or ())
257+
return AtlanTagRetranslator(client)
258+
259+
260+
def test_retranslate_resolves_existing_tag_name_to_id():
261+
# No regression: a real tag still maps name → hashed id.
262+
retranslator = _tag_retranslator({"PII": "abc123"})
263+
out = retranslator.retranslate({"classifications": [{"typeName": "PII"}]})
264+
assert out["classifications"][0]["typeName"] == "abc123"
265+
266+
267+
def test_retranslate_deleted_tag_keeps_sentinel_not_raises():
268+
# A KNOWN-deleted tag (tracked in the cache's deleted_names) referenced by its
269+
# original name keeps the (DELETED) sentinel — the tag really existed and was
270+
# deleted, so this is not the "never existed" client error.
271+
retranslator = _tag_retranslator({}, deleted_names={"WasDeleted"})
272+
out = retranslator.retranslate({"classifications": [{"typeName": "WasDeleted"}]})
273+
assert out["classifications"][0]["typeName"] == DELETED_
274+
275+
276+
def test_retranslate_missing_tag_raises_named_not_found():
277+
# New behaviour: add_atlan_tags(["Alert: DQ"]) on a tenant without that tag
278+
# now fails fast with a clear, named client-side error (no sentinel leak).
279+
retranslator = _tag_retranslator({})
280+
with pytest.raises(NotFoundError) as exc:
281+
retranslator.retranslate(
282+
{"addOrUpdateClassifications": [{"typeName": "Alert: DQ"}]}
283+
)
284+
message = str(exc.value)
285+
assert "Alert: DQ" in message
286+
assert "ATLAN-PYTHON-404-006" in message
287+
assert DELETED_ not in message
288+
289+
290+
def test_retranslate_remove_missing_tag_also_raises():
291+
retranslator = _tag_retranslator({})
292+
with pytest.raises(NotFoundError):
293+
retranslator.retranslate({"removeClassifications": [{"typeName": "GhostTag"}]})
294+
295+
296+
def test_retranslate_deleted_sentinel_round_trip_is_preserved():
297+
# No regression: an asset READ with an already-deleted tag surfaces (DELETED);
298+
# re-serializing it stays lossless (no raise).
299+
retranslator = _tag_retranslator({})
300+
out = retranslator.retranslate({"classifications": [{"typeName": DELETED_}]})
301+
assert out["classifications"][0]["typeName"] == DELETED_
302+
303+
304+
def test_retranslate_names_path_policy_serde_stays_tolerant():
305+
# No regression: purpose/persona policy names round-trip through the sentinel.
306+
retranslator = _tag_retranslator({})
307+
out = retranslator.retranslate({"purposeClassifications": ["ghost-policy-tag"]})
308+
assert out["purposeClassifications"] == [DELETED_]

tests/unit/test_retranslators.py

Lines changed: 0 additions & 70 deletions
This file was deleted.

0 commit comments

Comments
 (0)