Skip to content

Commit 5b093b3

Browse files
Aryamanz29claude
andcommitted
test: add unit and integration tests for DataContract delete lifecycle (DQ-665)
- Add DataContractSpec unit tests (parsing, mutation, YAML roundtrip) and ContractClient unit tests (generate_initial_spec, delete, delete_latest_version header assertions) to both pyatlan and pyatlan_v9 test suites - Add sync integration tests for generate_initial_spec, spec-based contract creation, delete_all, and delete_latest_version (xfail: Atlas NPE on dq-dev until backend version-chain schema is deployed) in data_mesh_test.py and v9 - Add async integration tests for the same scenarios in aio/test_client.py for both pyatlan and pyatlan_v9 - Move CONTRACT_DELETE_SCOPE_HEADER constant from contract.py / aio/contract.py into pyatlan/client/constants.py (single source of truth across 4 files) - Add Optional[Dict[str, str]] type hints to extra_headers param in atlan.py, aio/client.py, and protocol.py (ApiCaller + AsyncApiCaller) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 4427d21 commit 5b093b3

14 files changed

Lines changed: 1520 additions & 21 deletions

File tree

pyatlan/client/aio/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from contextlib import _AsyncGeneratorContextManager
1717
from http import HTTPStatus
1818
from types import SimpleNamespace
19-
from typing import Any, Optional
19+
from typing import Any, Dict, Optional
2020

2121
import httpx
2222
from httpx_retries.retry import Retry
@@ -501,7 +501,7 @@ async def _call_api(
501501
request_obj=None,
502502
exclude_unset: bool = True,
503503
text_response=False,
504-
extra_headers=None,
504+
extra_headers: Optional[Dict[str, str]] = None,
505505
):
506506
"""
507507
Async version of _call_api - mirrors sync client structure.

pyatlan/client/aio/contract.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77
from pydantic.v1 import validate_arguments
88

99
from pyatlan.client.common import AsyncApiCaller, ContractInit
10-
from pyatlan.client.constants import CONTRACT_INIT_API, DELETE_ENTITIES_BY_GUIDS
10+
from pyatlan.client.constants import (
11+
CONTRACT_DELETE_SCOPE_HEADER,
12+
CONTRACT_INIT_API,
13+
DELETE_ENTITIES_BY_GUIDS,
14+
)
1115
from pyatlan.errors import ErrorCode
1216
from pyatlan.model.assets import Asset
1317
from pyatlan.model.enums import AtlanDeleteType
1418
from pyatlan.model.response import AssetMutationResponse
1519

16-
CONTRACT_DELETE_SCOPE_HEADER = "x-atlan-contract-delete-scope"
17-
1820

1921
class AsyncContractClient:
2022
"""

pyatlan/client/atlan.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,7 @@ def _call_api(
792792
request_obj=None,
793793
exclude_unset: bool = True,
794794
text_response=False,
795-
extra_headers=None,
795+
extra_headers: Optional[Dict[str, str]] = None,
796796
):
797797
path = self._create_path(api)
798798
params = self._create_params(api, query_params, request_obj, exclude_unset)

pyatlan/client/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,3 +704,5 @@
704704
HTTPStatus.ACCEPTED,
705705
endpoint=EndPoint.CHRONOS,
706706
)
707+
708+
CONTRACT_DELETE_SCOPE_HEADER = "x-atlan-contract-delete-scope"

pyatlan/client/contract.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
from pydantic.v1 import validate_arguments
66

77
from pyatlan.client.common import ApiCaller, ContractInit
8-
from pyatlan.client.constants import CONTRACT_INIT_API, DELETE_ENTITIES_BY_GUIDS
8+
from pyatlan.client.constants import (
9+
CONTRACT_DELETE_SCOPE_HEADER,
10+
CONTRACT_INIT_API,
11+
DELETE_ENTITIES_BY_GUIDS,
12+
)
913
from pyatlan.errors import ErrorCode
1014
from pyatlan.model.assets import Asset
1115
from pyatlan.model.enums import AtlanDeleteType
1216
from pyatlan.model.response import AssetMutationResponse
1317

14-
CONTRACT_DELETE_SCOPE_HEADER = "x-atlan-contract-delete-scope"
15-
1618

1719
class ContractClient:
1820
"""

pyatlan/client/protocol.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from contextlib import _AsyncGeneratorContextManager, _GeneratorContextManager
6-
from typing import Any, Protocol, runtime_checkable
6+
from typing import Any, Dict, Optional, Protocol, runtime_checkable
77

88
from httpx_retries import Retry
99

@@ -26,7 +26,7 @@ def _call_api(
2626
request_obj=None,
2727
exclude_unset: bool = True,
2828
text_response: bool = False,
29-
extra_headers=None,
29+
extra_headers: Optional[Dict[str, str]] = None,
3030
):
3131
pass
3232

@@ -57,7 +57,7 @@ async def _call_api(
5757
request_obj=None,
5858
exclude_unset: bool = True,
5959
text_response: bool = False,
60-
extra_headers=None,
60+
extra_headers: Optional[Dict[str, str]] = None,
6161
) -> Any:
6262
pass
6363

pyatlan_v9/client/aio/contract.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
from typing import Optional
66

77
from pyatlan.client.common import AsyncApiCaller
8-
from pyatlan.client.constants import CONTRACT_INIT_API, DELETE_ENTITIES_BY_GUIDS
8+
from pyatlan.client.constants import (
9+
CONTRACT_DELETE_SCOPE_HEADER,
10+
CONTRACT_INIT_API,
11+
DELETE_ENTITIES_BY_GUIDS,
12+
)
913
from pyatlan.errors import ErrorCode
1014
from pyatlan_v9.client.asset import _parse_mutation_response
1115
from pyatlan_v9.model.assets import Asset
@@ -14,8 +18,6 @@
1418
from pyatlan_v9.model.response import AssetMutationResponse
1519
from pyatlan_v9.validate import validate_arguments
1620

17-
CONTRACT_DELETE_SCOPE_HEADER = "x-atlan-contract-delete-scope"
18-
1921

2022
class V9AsyncContractClient:
2123
"""

pyatlan_v9/client/contract.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
from typing import Optional
44

55
from pyatlan.client.common import ApiCaller
6-
from pyatlan.client.constants import CONTRACT_INIT_API, DELETE_ENTITIES_BY_GUIDS
6+
from pyatlan.client.constants import (
7+
CONTRACT_DELETE_SCOPE_HEADER,
8+
CONTRACT_INIT_API,
9+
DELETE_ENTITIES_BY_GUIDS,
10+
)
711
from pyatlan.errors import ErrorCode
812
from pyatlan_v9.client.asset import _parse_mutation_response
913
from pyatlan_v9.model.assets import Asset
@@ -12,8 +16,6 @@
1216
from pyatlan_v9.model.response import AssetMutationResponse
1317
from pyatlan_v9.validate import validate_arguments
1418

15-
CONTRACT_DELETE_SCOPE_HEADER = "x-atlan-contract-delete-scope"
16-
1719

1820
class V9ContractClient:
1921
"""

tests/integration/aio/test_client.py

Lines changed: 223 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,23 @@
2727
AtlasGlossary,
2828
AtlasGlossaryCategory,
2929
AtlasGlossaryTerm,
30+
Connection,
3031
Database,
32+
DataContract,
3133
Schema,
3234
Table,
3335
)
3436
from pyatlan.model.audit import AuditSearchRequest
37+
from pyatlan.model.contract import DataContractSpec
3538
from pyatlan.model.core import Announcement
36-
from pyatlan.model.enums import AnnouncementType, AtlanConnectorType, SortOrder, UTMTags
39+
from pyatlan.model.enums import (
40+
AnnouncementType,
41+
AtlanConnectorType,
42+
DataContractStatus,
43+
EntityStatus,
44+
SortOrder,
45+
UTMTags,
46+
)
3747
from pyatlan.model.fluent_search import CompoundQuery, FluentSearch
3848
from pyatlan.model.search import (
3949
DSL,
@@ -1691,3 +1701,215 @@ async def doit(asset: Asset):
16911701

16921702
processed_count = await client.asset.process_assets(search=search, func=doit)
16931703
assert processed_count == expected_count
1704+
1705+
1706+
# ---------------------------------------------------------------------------
1707+
# Async contract tests — client.contracts (generate_initial_spec, delete,
1708+
# delete_latest_version)
1709+
# ---------------------------------------------------------------------------
1710+
1711+
1712+
@pytest_asyncio.fixture(scope="module")
1713+
async def async_spec_contract(
1714+
client: AsyncAtlanClient,
1715+
table: Table,
1716+
connection: Connection,
1717+
):
1718+
"""Contract created via DataContractSpec model using the async client."""
1719+
assert table and table.qualified_name and table.type_name
1720+
1721+
spec = DataContractSpec(
1722+
kind="DataContract",
1723+
template_version="0.0.2",
1724+
status=DataContractStatus.DRAFT,
1725+
type=table.type_name,
1726+
dataset=table.name or table.qualified_name.split("/")[-1],
1727+
data_source=connection.name,
1728+
description="Automated testing of the Python SDK - async spec-based.",
1729+
)
1730+
contract = DataContract.creator(
1731+
asset_qualified_name=table.qualified_name,
1732+
contract_spec=spec,
1733+
)
1734+
response = await client.asset.save(contract)
1735+
created = response.assets_created(asset_type=DataContract)
1736+
updated = response.assets_updated(asset_type=DataContract)
1737+
result = (created or updated)[0]
1738+
yield result
1739+
await delete_asset_async(client, guid=result.guid, asset_type=DataContract)
1740+
1741+
1742+
@pytest.mark.order(before="test_async_contract_from_spec")
1743+
async def test_async_generate_initial_spec(client: AsyncAtlanClient, table: Table):
1744+
"""client.contracts.generate_initial_spec() returns parseable YAML (async)."""
1745+
assert table and table.qualified_name
1746+
1747+
yaml_spec = await client.contracts.generate_initial_spec(table)
1748+
1749+
assert yaml_spec, "Expected a non-empty YAML string from generate_initial_spec"
1750+
spec = DataContractSpec.from_yaml(yaml_spec)
1751+
assert spec.kind == "DataContract"
1752+
assert spec.type
1753+
assert spec.dataset
1754+
1755+
1756+
async def test_async_contract_from_spec(
1757+
client: AsyncAtlanClient, table: Table, async_spec_contract: DataContract
1758+
):
1759+
"""Async: DataContract created from DataContractSpec has the right asset linkage."""
1760+
assert async_spec_contract and async_spec_contract.guid
1761+
assert async_spec_contract.qualified_name
1762+
assert table.qualified_name
1763+
assert async_spec_contract.qualified_name.startswith(table.qualified_name)
1764+
assert "/contract" in async_spec_contract.qualified_name
1765+
1766+
fetched = await client.asset.get_by_guid(
1767+
async_spec_contract.guid, asset_type=DataContract, ignore_relationships=False
1768+
)
1769+
assert fetched
1770+
assert fetched.data_contract_asset_guid == table.guid
1771+
assert fetched.data_contract_spec or fetched.data_contract_json
1772+
assert fetched.data_contract_version and fetched.data_contract_version >= 1
1773+
1774+
# hasContract / dataContractLatest / dataContractLatestCertified
1775+
table_state = await client.asset.get_by_guid(
1776+
table.guid, asset_type=Table, ignore_relationships=False
1777+
)
1778+
assert table_state.has_contract is True
1779+
assert table_state.data_contract_latest is not None
1780+
assert table_state.data_contract_latest.guid == async_spec_contract.guid
1781+
assert table_state.data_contract_latest_certified is None # DRAFT not certified
1782+
1783+
1784+
@pytest_asyncio.fixture(scope="module")
1785+
async def async_multi_version_contract(
1786+
client: AsyncAtlanClient,
1787+
table: Table,
1788+
connection: Connection,
1789+
):
1790+
"""V1 DRAFT → V1 VERIFIED → V2 DRAFT lifecycle (async)."""
1791+
import asyncio
1792+
1793+
assert table and table.qualified_name and table.type_name
1794+
asset_qn = table.qualified_name
1795+
dataset_name = table.name or asset_qn.split("/")[-1]
1796+
1797+
table_with_rels = await client.asset.get_by_guid(
1798+
table.guid, asset_type=Table, ignore_relationships=False
1799+
)
1800+
if table_with_rels.data_contract_latest:
1801+
try:
1802+
await client.contracts.delete(table_with_rels.data_contract_latest.guid)
1803+
await asyncio.sleep(2)
1804+
except Exception:
1805+
pass
1806+
1807+
async def _save_spec(status_str: str, description: str) -> DataContract:
1808+
spec = DataContractSpec(
1809+
kind="DataContract",
1810+
template_version="0.0.2",
1811+
status=status_str,
1812+
type=table.type_name,
1813+
dataset=dataset_name,
1814+
data_source=connection.name,
1815+
description=description,
1816+
)
1817+
contract = DataContract.creator(
1818+
asset_qualified_name=asset_qn,
1819+
contract_spec=spec,
1820+
)
1821+
response = await client.asset.save(contract)
1822+
created = response.assets_created(asset_type=DataContract)
1823+
updated = response.assets_updated(asset_type=DataContract)
1824+
return (created or updated)[0]
1825+
1826+
v1 = await _save_spec("draft", "E2E test - DRAFT v1")
1827+
await asyncio.sleep(3)
1828+
await _save_spec("VERIFIED", "E2E test - VERIFIED v1")
1829+
await asyncio.sleep(2)
1830+
v2 = await _save_spec("draft", "E2E test - DRAFT v2")
1831+
1832+
yield {"v1_guid": v1.guid, "v2_guid": v2.guid}
1833+
1834+
try:
1835+
await client.contracts.delete(v2.guid)
1836+
except Exception:
1837+
pass
1838+
1839+
1840+
@pytest.mark.order(before="test_async_delete_all_contract_versions")
1841+
@pytest.mark.xfail(
1842+
strict=False,
1843+
reason=(
1844+
"delete_latest_version triggers an Atlas NPE on dq-dev: "
1845+
"getRelationshipEdgeLabel() is null for contract version-chain attributes "
1846+
"not yet deployed in this environment. Remove xfail once backend is updated."
1847+
),
1848+
)
1849+
async def test_async_delete_latest_version_restores_previous(
1850+
client: AsyncAtlanClient, table: Table, async_multi_version_contract: dict
1851+
):
1852+
"""Async: deleting latest DRAFT restores VERIFIED v1 as dataContractLatest."""
1853+
v2_guid = async_multi_version_contract["v2_guid"]
1854+
assert v2_guid
1855+
1856+
response = await client.contracts.delete_latest_version(v2_guid)
1857+
1858+
assert response
1859+
deleted = response.assets_deleted(asset_type=DataContract)
1860+
assert deleted and len(deleted) == 1
1861+
assert deleted[0].guid == v2_guid
1862+
assert deleted[0].status == EntityStatus.DELETED
1863+
1864+
table_after = await client.asset.get_by_guid(
1865+
table.guid, asset_type=Table, ignore_relationships=False
1866+
)
1867+
assert table_after.has_contract is True
1868+
assert table_after.data_contract_latest is not None
1869+
assert table_after.data_contract_latest.guid != v2_guid
1870+
assert table_after.data_contract_latest_certified is not None
1871+
assert table_after.data_contract_latest_certified.guid != v2_guid
1872+
1873+
1874+
@pytest.mark.order(before="test_async_contract_from_spec")
1875+
async def test_async_delete_all_contract_versions(
1876+
client: AsyncAtlanClient, table: Table, connection: Connection
1877+
):
1878+
"""Async: client.contracts.delete() purges all versions and clears asset attrs."""
1879+
assert table and table.qualified_name and table.type_name
1880+
dataset_name = table.name or table.qualified_name.split("/")[-1]
1881+
1882+
spec = DataContractSpec(
1883+
kind="DataContract",
1884+
template_version="0.0.2",
1885+
status=DataContractStatus.DRAFT,
1886+
type=table.type_name,
1887+
dataset=dataset_name,
1888+
data_source=connection.name,
1889+
description="Automated testing - async delete-all scenario.",
1890+
)
1891+
contract = DataContract.creator(
1892+
asset_qualified_name=table.qualified_name,
1893+
contract_spec=spec,
1894+
)
1895+
response = await client.asset.save(contract)
1896+
created = response.assets_created(asset_type=DataContract)
1897+
updated = response.assets_updated(asset_type=DataContract)
1898+
saved = (created or updated)[0]
1899+
assert saved and saved.guid
1900+
1901+
del_response = await client.contracts.delete(saved.guid)
1902+
1903+
assert del_response
1904+
deleted = del_response.assets_deleted(asset_type=DataContract)
1905+
assert deleted and len(deleted) >= 1
1906+
assert saved.guid in {a.guid for a in deleted}
1907+
for asset in deleted:
1908+
assert asset.status == EntityStatus.DELETED
1909+
1910+
table_after = await client.asset.get_by_guid(
1911+
table.guid, asset_type=Table, ignore_relationships=False
1912+
)
1913+
assert not table_after.has_contract
1914+
assert table_after.data_contract_latest is None
1915+
assert table_after.data_contract_latest_certified is None

0 commit comments

Comments
 (0)