From 505073f8209f875420c876b423ed976cf61d984c Mon Sep 17 00:00:00 2001 From: Christopher Grote Date: Wed, 14 Oct 2026 12:41:20 +0100 Subject: [PATCH 1/5] feat(v9): add creators for TagAttachment, S3Prefix and FlowControlOperation Shared types built by more than one app (FND-3846). Each creator reproduces every app's existing qualifiedName, so no app changes identity: - TagAttachment.creator(qn_style=...): tag_object (databricks), tag_attachments_object (dbt), object_tag (bridge). Passthrough type, so the creator is hand-added to tag_attachment.py; overlay kept for when passthrough types are generated. - S3Prefix.creator: {bucket_qn}/{prefix}/ with trailing slash (s3, bridge); sets bucket relationship, parent prefix qualified name and hierarchy. - FlowControlOperation.creator: {conn}/{scope}/{source_id} (fabric notebook, sagemaker) or {parent_qn}/jobs/{source_id} with flow_controlled_by (fabric Spark job). Parity tests cover every app template. A strict xfail records that the generated S3Prefix _QUALIFIED_NAME_PATTERN rejects the trailing slash until qualifiedNameFormula is added in atlanhq/models. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wc98jPXjP7pdqJoAh3vtfY --- .../_overlays/flow_control_operation.py | 77 ++++++++++++ .../model/assets/_overlays/s3_prefix.py | 89 +++++++++++++ .../model/assets/_overlays/tag_attachment.py | 98 +++++++++++++++ .../model/assets/flow_control_operation.py | 77 ++++++++++++ pyatlan_v9/model/assets/s3_prefix.py | 89 +++++++++++++ pyatlan_v9/model/assets/tag_attachment.py | 98 +++++++++++++++ .../unit/model/flow_control_operation_test.py | 109 ++++++++++++++++ tests_v9/unit/model/s3_prefix_test.py | 117 ++++++++++++++++++ tests_v9/unit/model/tag_attachment_test.py | 110 ++++++++++++++++ 9 files changed, 864 insertions(+) create mode 100644 pyatlan_v9/model/assets/_overlays/flow_control_operation.py create mode 100644 pyatlan_v9/model/assets/_overlays/s3_prefix.py create mode 100644 pyatlan_v9/model/assets/_overlays/tag_attachment.py create mode 100644 tests_v9/unit/model/flow_control_operation_test.py create mode 100644 tests_v9/unit/model/s3_prefix_test.py create mode 100644 tests_v9/unit/model/tag_attachment_test.py diff --git a/pyatlan_v9/model/assets/_overlays/flow_control_operation.py b/pyatlan_v9/model/assets/_overlays/flow_control_operation.py new file mode 100644 index 000000000..0d95b69e5 --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/flow_control_operation.py @@ -0,0 +1,77 @@ +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + connection_qualified_name: str, + source_id: str, + scope: str | None = None, + parent_qualified_name: str | None = None, + flow_id: str | None = None, + ) -> "FlowControlOperation": + """ + Create a new FlowControlOperation asset. + + Exactly one of ``scope`` or ``parent_qualified_name`` must be given: + + - top-level operation: ``{connection_qualified_name}/{scope}/{source_id}`` + (e.g. scope = Fabric workspace ID, or SageMaker region) + - child operation: ``{parent_qualified_name}/jobs/{source_id}``, linked to + its parent through ``flow_controlled_by`` + + Args: + name: Name of the operation + connection_qualified_name: Unique name of the connection + source_id: Identifier of the operation in the source system; the last + segment of the qualified name + scope: Source-system container of a top-level operation + parent_qualified_name: Unique name of the controlling operation + flow_id: Value for ``flow_id`` (e.g. an ARN); not part of the + qualified name + + Returns: + FlowControlOperation instance ready to be created + + Raises: + ValueError: If required parameters are missing or invalid + """ + validate_required_fields( + ["name", "connection_qualified_name", "source_id"], + [name, connection_qualified_name, source_id], + ) + fields = connection_qualified_name.split("/") + if len(fields) != 3: + raise ValueError("Invalid connection_qualified_name") + if fields[0].replace(" ", "") == "" or fields[2].replace(" ", "") == "": + raise ValueError("Invalid connection_qualified_name") + if (scope is None) == (parent_qualified_name is None): + raise ValueError( + "Exactly one of scope or parent_qualified_name must be provided" + ) + + flow_controlled_by: RelatedFlowControlOperation | UnsetType = UNSET + if parent_qualified_name is not None: + validate_required_fields(["parent_qualified_name"], [parent_qualified_name]) + if not parent_qualified_name.startswith(f"{connection_qualified_name}/"): + raise ValueError( + "parent_qualified_name must belong to connection_qualified_name" + ) + qualified_name = f"{parent_qualified_name}/jobs/{source_id}" + flow_controlled_by = RelatedFlowControlOperation( + qualified_name=parent_qualified_name + ) + else: + validate_required_fields(["scope"], [scope]) + qualified_name = f"{connection_qualified_name}/{scope}/{source_id}" + + return cls( + name=name, + qualified_name=qualified_name, + connection_qualified_name=connection_qualified_name, + connector_name=fields[1], + flow_id=flow_id if flow_id is not None else UNSET, + flow_controlled_by=flow_controlled_by, + ) diff --git a/pyatlan_v9/model/assets/_overlays/s3_prefix.py b/pyatlan_v9/model/assets/_overlays/s3_prefix.py new file mode 100644 index 000000000..04b83c429 --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/s3_prefix.py @@ -0,0 +1,89 @@ +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + connection_qualified_name: str, + s3_bucket_name: str, + s3_bucket_qualified_name: str, + prefix: str, + name: str | None = None, + ) -> "S3Prefix": + """ + Create a new S3Prefix asset. + + The qualified name is ``{s3_bucket_qualified_name}/{prefix}/`` — the full + key from the bucket root, always with a trailing slash (native S3 + convention, shared by every app that builds prefixes). + + The parent prefix is recorded by qualified name + (``s3_parent_prefix_qualified_name`` and ``s3_prefix_hierarchy``); the + ``s3_parent_prefix`` relationship is left for the caller to set once the + parent prefix exists. + + Args: + connection_qualified_name: Unique name of the connection + s3_bucket_name: Simple name of the bucket + s3_bucket_qualified_name: Unique name of the bucket + prefix: Full key of the prefix from the bucket root, e.g. ``a/b/`` + (leading and trailing slashes are optional) + name: Name of the prefix; defaults to the last segment of ``prefix`` + + Returns: + S3Prefix instance ready to be created + + Raises: + ValueError: If required parameters are missing or invalid + """ + validate_required_fields( + [ + "connection_qualified_name", + "s3_bucket_name", + "s3_bucket_qualified_name", + "prefix", + ], + [ + connection_qualified_name, + s3_bucket_name, + s3_bucket_qualified_name, + prefix, + ], + ) + fields = connection_qualified_name.split("/") + if len(fields) != 3: + raise ValueError("Invalid connection_qualified_name") + if fields[0].replace(" ", "") == "" or fields[2].replace(" ", "") == "": + raise ValueError("Invalid connection_qualified_name") + if fields[1].lower() != "s3": + raise ValueError("Invalid connection_qualified_name") + + segments = prefix.strip("/").split("/") + if any(segment.strip() == "" for segment in segments): + raise ValueError("prefix cannot contain empty segments") + if name is None: + name = segments[-1] + validate_required_fields(["name"], [name]) + + def _prefix_qn(depth: int) -> str: + return f"{s3_bucket_qualified_name}/{'/'.join(segments[:depth])}/" + + # Ancestors from immediate parent up to the root prefix. + hierarchy = [ + {"name": segments[depth - 1], "qualifiedName": _prefix_qn(depth)} + for depth in range(len(segments) - 1, 0, -1) + ] + return cls( + name=name, + qualified_name=_prefix_qn(len(segments)), + connection_qualified_name=connection_qualified_name, + connector_name=fields[1], + s3_bucket_name=s3_bucket_name, + s3_bucket_qualified_name=s3_bucket_qualified_name, + s3_bucket=RelatedS3Bucket(qualified_name=s3_bucket_qualified_name), + s3_parent_prefix_qualified_name=( + hierarchy[0]["qualifiedName"] if hierarchy else UNSET + ), + s3_prefix_hierarchy=hierarchy or UNSET, + ) diff --git a/pyatlan_v9/model/assets/_overlays/tag_attachment.py b/pyatlan_v9/model/assets/_overlays/tag_attachment.py new file mode 100644 index 000000000..f65434d22 --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/tag_attachment.py @@ -0,0 +1,98 @@ +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + _QN_STYLES: ClassVar[tuple[str, ...]] = ( + "tag_object", + "tag_attachments_object", + "object_tag", + ) + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + connection_qualified_name: str, + tag_qualified_name: str, + object_qualified_name: str, + qn_style: str = "tag_object", + object_type_name: str | None = None, + source_tag_type_name: str | None = None, + value: str | None = None, + ) -> "TagAttachment": + """ + Create a new TagAttachment asset, linking a source tag to an asset. + + Apps already in production build the qualified name in different shapes, + so ``qn_style`` selects one; changing it changes the attachment's identity: + + - ``tag_object`` (default): ``{tag_qualified_name}/{object_qualified_name}`` + - ``tag_attachments_object``: + ``{tag_qualified_name}/attachments/{object_qualified_name}`` + - ``object_tag``: ``{object_qualified_name}/{name}`` + + Args: + name: Name of the source tag + connection_qualified_name: Unique name of the connection + tag_qualified_name: Unique name of the source tag + object_qualified_name: Unique name of the asset the tag is attached to + qn_style: Shape of the qualified name (see above) + object_type_name: Type name of the asset the tag is attached to + source_tag_type_name: Type name of the source tag + value: String value of the tag on this asset, if any + + Returns: + TagAttachment instance ready to be created + + Raises: + ValueError: If required parameters are missing or invalid + """ + validate_required_fields( + [ + "name", + "connection_qualified_name", + "tag_qualified_name", + "object_qualified_name", + ], + [ + name, + connection_qualified_name, + tag_qualified_name, + object_qualified_name, + ], + ) + fields = connection_qualified_name.split("/") + if len(fields) != 3: + raise ValueError("Invalid connection_qualified_name") + if fields[0].replace(" ", "") == "" or fields[2].replace(" ", "") == "": + raise ValueError("Invalid connection_qualified_name") + + if qn_style == "tag_object": + qualified_name = f"{tag_qualified_name}/{object_qualified_name}" + elif qn_style == "tag_attachments_object": + qualified_name = f"{tag_qualified_name}/attachments/{object_qualified_name}" + elif qn_style == "object_tag": + qualified_name = f"{object_qualified_name}/{name}" + else: + raise ValueError(f"qn_style must be one of {cls._QN_STYLES}") + + return cls( + name=name, + qualified_name=qualified_name, + connection_qualified_name=connection_qualified_name, + connector_name=fields[1], + tag_qualified_name=tag_qualified_name, + source_tag_qualified_name=tag_qualified_name, + source_tag_display_name=name, + source_tag_type_name=( + source_tag_type_name if source_tag_type_name is not None else UNSET + ), + object_qualified_name=object_qualified_name, + object_type_name=object_type_name + if object_type_name is not None + else UNSET, + value_type="NONE" if value is None else "STRING", + value=value, + tag_attachment_string_value=value if value is not None else UNSET, + propagate=False, + ) diff --git a/pyatlan_v9/model/assets/flow_control_operation.py b/pyatlan_v9/model/assets/flow_control_operation.py index 4d33ed011..361844f30 100644 --- a/pyatlan_v9/model/assets/flow_control_operation.py +++ b/pyatlan_v9/model/assets/flow_control_operation.py @@ -49,6 +49,7 @@ ) from pyatlan_v9.model.serde import Serde, get_serde from pyatlan_v9.model.transform import register_asset +from pyatlan_v9.utils import init_guid, validate_required_fields from .flow_related import RelatedFlowControlOperation @@ -315,6 +316,82 @@ def relate(self) -> "RelatedFlowControlOperation": return RelatedFlowControlOperation(guid=self.guid) return RelatedFlowControlOperation(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + connection_qualified_name: str, + source_id: str, + scope: str | None = None, + parent_qualified_name: str | None = None, + flow_id: str | None = None, + ) -> "FlowControlOperation": + """ + Create a new FlowControlOperation asset. + + Exactly one of ``scope`` or ``parent_qualified_name`` must be given: + + - top-level operation: ``{connection_qualified_name}/{scope}/{source_id}`` + (e.g. scope = Fabric workspace ID, or SageMaker region) + - child operation: ``{parent_qualified_name}/jobs/{source_id}``, linked to + its parent through ``flow_controlled_by`` + + Args: + name: Name of the operation + connection_qualified_name: Unique name of the connection + source_id: Identifier of the operation in the source system; the last + segment of the qualified name + scope: Source-system container of a top-level operation + parent_qualified_name: Unique name of the controlling operation + flow_id: Value for ``flow_id`` (e.g. an ARN); not part of the + qualified name + + Returns: + FlowControlOperation instance ready to be created + + Raises: + ValueError: If required parameters are missing or invalid + """ + validate_required_fields( + ["name", "connection_qualified_name", "source_id"], + [name, connection_qualified_name, source_id], + ) + fields = connection_qualified_name.split("/") + if len(fields) != 3: + raise ValueError("Invalid connection_qualified_name") + if fields[0].replace(" ", "") == "" or fields[2].replace(" ", "") == "": + raise ValueError("Invalid connection_qualified_name") + if (scope is None) == (parent_qualified_name is None): + raise ValueError( + "Exactly one of scope or parent_qualified_name must be provided" + ) + + flow_controlled_by: RelatedFlowControlOperation | UnsetType = UNSET + if parent_qualified_name is not None: + validate_required_fields(["parent_qualified_name"], [parent_qualified_name]) + if not parent_qualified_name.startswith(f"{connection_qualified_name}/"): + raise ValueError( + "parent_qualified_name must belong to connection_qualified_name" + ) + qualified_name = f"{parent_qualified_name}/jobs/{source_id}" + flow_controlled_by = RelatedFlowControlOperation( + qualified_name=parent_qualified_name + ) + else: + validate_required_fields(["scope"], [scope]) + qualified_name = f"{connection_qualified_name}/{scope}/{source_id}" + + return cls( + name=name, + qualified_name=qualified_name, + connection_qualified_name=connection_qualified_name, + connector_name=fields[1], + flow_id=flow_id if flow_id is not None else UNSET, + flow_controlled_by=flow_controlled_by, + ) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/pyatlan_v9/model/assets/s3_prefix.py b/pyatlan_v9/model/assets/s3_prefix.py index cf28dc5ce..b3523defc 100644 --- a/pyatlan_v9/model/assets/s3_prefix.py +++ b/pyatlan_v9/model/assets/s3_prefix.py @@ -54,6 +54,7 @@ ) from pyatlan_v9.model.serde import Serde, get_serde from pyatlan_v9.model.transform import register_asset +from pyatlan_v9.utils import init_guid, validate_required_fields from .s3_related import RelatedS3Bucket, RelatedS3Object, RelatedS3Prefix @@ -382,6 +383,94 @@ def relate(self) -> "RelatedS3Prefix": return RelatedS3Prefix(guid=self.guid) return RelatedS3Prefix(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + connection_qualified_name: str, + s3_bucket_name: str, + s3_bucket_qualified_name: str, + prefix: str, + name: str | None = None, + ) -> "S3Prefix": + """ + Create a new S3Prefix asset. + + The qualified name is ``{s3_bucket_qualified_name}/{prefix}/`` — the full + key from the bucket root, always with a trailing slash (native S3 + convention, shared by every app that builds prefixes). + + The parent prefix is recorded by qualified name + (``s3_parent_prefix_qualified_name`` and ``s3_prefix_hierarchy``); the + ``s3_parent_prefix`` relationship is left for the caller to set once the + parent prefix exists. + + Args: + connection_qualified_name: Unique name of the connection + s3_bucket_name: Simple name of the bucket + s3_bucket_qualified_name: Unique name of the bucket + prefix: Full key of the prefix from the bucket root, e.g. ``a/b/`` + (leading and trailing slashes are optional) + name: Name of the prefix; defaults to the last segment of ``prefix`` + + Returns: + S3Prefix instance ready to be created + + Raises: + ValueError: If required parameters are missing or invalid + """ + validate_required_fields( + [ + "connection_qualified_name", + "s3_bucket_name", + "s3_bucket_qualified_name", + "prefix", + ], + [ + connection_qualified_name, + s3_bucket_name, + s3_bucket_qualified_name, + prefix, + ], + ) + fields = connection_qualified_name.split("/") + if len(fields) != 3: + raise ValueError("Invalid connection_qualified_name") + if fields[0].replace(" ", "") == "" or fields[2].replace(" ", "") == "": + raise ValueError("Invalid connection_qualified_name") + if fields[1].lower() != "s3": + raise ValueError("Invalid connection_qualified_name") + + segments = prefix.strip("/").split("/") + if any(segment.strip() == "" for segment in segments): + raise ValueError("prefix cannot contain empty segments") + if name is None: + name = segments[-1] + validate_required_fields(["name"], [name]) + + def _prefix_qn(depth: int) -> str: + return f"{s3_bucket_qualified_name}/{'/'.join(segments[:depth])}/" + + # Ancestors from immediate parent up to the root prefix. + hierarchy = [ + {"name": segments[depth - 1], "qualifiedName": _prefix_qn(depth)} + for depth in range(len(segments) - 1, 0, -1) + ] + return cls( + name=name, + qualified_name=_prefix_qn(len(segments)), + connection_qualified_name=connection_qualified_name, + connector_name=fields[1], + s3_bucket_name=s3_bucket_name, + s3_bucket_qualified_name=s3_bucket_qualified_name, + s3_bucket=RelatedS3Bucket(qualified_name=s3_bucket_qualified_name), + s3_parent_prefix_qualified_name=( + hierarchy[0]["qualifiedName"] if hierarchy else UNSET + ), + s3_prefix_hierarchy=hierarchy or UNSET, + ) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/pyatlan_v9/model/assets/tag_attachment.py b/pyatlan_v9/model/assets/tag_attachment.py index 2ddf1f0d3..fb75564fd 100644 --- a/pyatlan_v9/model/assets/tag_attachment.py +++ b/pyatlan_v9/model/assets/tag_attachment.py @@ -24,6 +24,7 @@ ) from pyatlan_v9.model.serde import Serde, get_serde from pyatlan_v9.model.transform import register_asset +from pyatlan_v9.utils import init_guid, validate_required_fields from .airflow_related import RelatedAirflowTask from .anomalo_related import RelatedAnomaloCheck @@ -312,6 +313,103 @@ def relate(self) -> "RelatedTagAttachment": return RelatedTagAttachment(guid=self.guid) return RelatedTagAttachment(qualified_name=self.qualified_name) + _QN_STYLES: ClassVar[tuple[str, ...]] = ( + "tag_object", + "tag_attachments_object", + "object_tag", + ) + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + connection_qualified_name: str, + tag_qualified_name: str, + object_qualified_name: str, + qn_style: str = "tag_object", + object_type_name: str | None = None, + source_tag_type_name: str | None = None, + value: str | None = None, + ) -> "TagAttachment": + """ + Create a new TagAttachment asset, linking a source tag to an asset. + + Apps already in production build the qualified name in different shapes, + so ``qn_style`` selects one; changing it changes the attachment's identity: + + - ``tag_object`` (default): ``{tag_qualified_name}/{object_qualified_name}`` + - ``tag_attachments_object``: + ``{tag_qualified_name}/attachments/{object_qualified_name}`` + - ``object_tag``: ``{object_qualified_name}/{name}`` + + Args: + name: Name of the source tag + connection_qualified_name: Unique name of the connection + tag_qualified_name: Unique name of the source tag + object_qualified_name: Unique name of the asset the tag is attached to + qn_style: Shape of the qualified name (see above) + object_type_name: Type name of the asset the tag is attached to + source_tag_type_name: Type name of the source tag + value: String value of the tag on this asset, if any + + Returns: + TagAttachment instance ready to be created + + Raises: + ValueError: If required parameters are missing or invalid + """ + validate_required_fields( + [ + "name", + "connection_qualified_name", + "tag_qualified_name", + "object_qualified_name", + ], + [ + name, + connection_qualified_name, + tag_qualified_name, + object_qualified_name, + ], + ) + fields = connection_qualified_name.split("/") + if len(fields) != 3: + raise ValueError("Invalid connection_qualified_name") + if fields[0].replace(" ", "") == "" or fields[2].replace(" ", "") == "": + raise ValueError("Invalid connection_qualified_name") + + if qn_style == "tag_object": + qualified_name = f"{tag_qualified_name}/{object_qualified_name}" + elif qn_style == "tag_attachments_object": + qualified_name = f"{tag_qualified_name}/attachments/{object_qualified_name}" + elif qn_style == "object_tag": + qualified_name = f"{object_qualified_name}/{name}" + else: + raise ValueError(f"qn_style must be one of {cls._QN_STYLES}") + + return cls( + name=name, + qualified_name=qualified_name, + connection_qualified_name=connection_qualified_name, + connector_name=fields[1], + tag_qualified_name=tag_qualified_name, + source_tag_qualified_name=tag_qualified_name, + source_tag_display_name=name, + source_tag_type_name=( + source_tag_type_name if source_tag_type_name is not None else UNSET + ), + object_qualified_name=object_qualified_name, + object_type_name=object_type_name + if object_type_name is not None + else UNSET, + value_type="NONE" if value is None else "STRING", + value=value, + tag_attachment_string_value=value if value is not None else UNSET, + propagate=False, + ) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/tests_v9/unit/model/flow_control_operation_test.py b/tests_v9/unit/model/flow_control_operation_test.py new file mode 100644 index 000000000..222928545 --- /dev/null +++ b/tests_v9/unit/model/flow_control_operation_test.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for FlowControlOperation.creator in pyatlan_v9.""" + +import pytest +from msgspec import UNSET + +from pyatlan_v9.model import FlowControlOperation + +FABRIC_CONNECTION_QN = "default/fabric/1700000000" +SAGEMAKER_CONNECTION_QN = "default/sagemaker/1700000000" +WORKSPACE_ID = "11111111-2222-3333-4444-555555555555" +NOTEBOOK_ID = "66666666-7777-8888-9999-000000000000" +NOTEBOOK_QN = f"{FABRIC_CONNECTION_QN}/{WORKSPACE_ID}/{NOTEBOOK_ID}" + + +@pytest.mark.parametrize( + "kwargs, message", + [ + ({"name": None}, "name is required"), + ({"connection_qualified_name": None}, "connection_qualified_name is required"), + ({"source_id": " "}, "source_id cannot be blank"), + ({"scope": None}, "Exactly one of scope or parent_qualified_name"), + ({"parent_qualified_name": NOTEBOOK_QN}, "Exactly one of scope"), + ({"scope": ""}, "scope cannot be blank"), + ({"connection_qualified_name": "default/fabric"}, "Invalid connection"), + ], +) +def test_creator_with_invalid_parameters_raises_value_error(kwargs, message): + params = { + "name": "nb", + "connection_qualified_name": FABRIC_CONNECTION_QN, + "source_id": NOTEBOOK_ID, + "scope": WORKSPACE_ID, + **kwargs, + } + with pytest.raises(ValueError, match=message): + FlowControlOperation.creator(**params) + + +def test_creator_child_requires_parent_in_same_connection(): + with pytest.raises(ValueError, match="must belong to connection_qualified_name"): + FlowControlOperation.creator( + name="job", + connection_qualified_name=FABRIC_CONNECTION_QN, + source_id="abc", + parent_qualified_name=f"{SAGEMAKER_CONNECTION_QN}/x/y", + ) + + +def test_parity_fabric_notebook(): + """atlan-fabric-app: {conn}/{workspace_id}/{notebook_id}.""" + sut = FlowControlOperation.creator( + name="My notebook", + connection_qualified_name=FABRIC_CONNECTION_QN, + scope=WORKSPACE_ID, + source_id=NOTEBOOK_ID, + flow_id=NOTEBOOK_ID, + ) + assert sut.qualified_name == NOTEBOOK_QN + assert sut.name == "My notebook" + assert sut.connection_qualified_name == FABRIC_CONNECTION_QN + assert sut.connector_name == "fabric" + assert sut.flow_id == NOTEBOOK_ID + assert sut.flow_controlled_by is UNSET + sut.validate(for_creation=True) + + +def test_parity_fabric_spark_job(): + """atlan-fabric-app: {notebook_qn}/jobs/{sha256(namespace\\0jobName)}.""" + key = "a" * 64 + sut = FlowControlOperation.creator( + name="job", + connection_qualified_name=FABRIC_CONNECTION_QN, + parent_qualified_name=NOTEBOOK_QN, + source_id=key, + flow_id=key, + ) + assert sut.qualified_name == f"{NOTEBOOK_QN}/jobs/{key}" + assert sut.flow_controlled_by.qualified_name == NOTEBOOK_QN + assert sut.flow_id == key + sut.validate(for_creation=True) + + +def test_parity_sagemaker_job(): + """atlan-sagemaker-app: {conn}/{region}/{job_name}; flowId is the job ARN.""" + arn = "arn:aws:sagemaker:us-east-1:000000000000:training-job/example-job" + sut = FlowControlOperation.creator( + name="example-job", + connection_qualified_name=SAGEMAKER_CONNECTION_QN, + scope="us-east-1", + source_id="example-job", + flow_id=arn, + ) + assert sut.qualified_name == f"{SAGEMAKER_CONNECTION_QN}/us-east-1/example-job" + assert sut.connector_name == "sagemaker" + assert sut.flow_id == arn + sut.validate(for_creation=True) + + +def test_flow_id_optional(): + sut = FlowControlOperation.creator( + name="j", + connection_qualified_name=SAGEMAKER_CONNECTION_QN, + scope="us-east-1", + source_id="j", + ) + assert sut.flow_id is UNSET diff --git a/tests_v9/unit/model/s3_prefix_test.py b/tests_v9/unit/model/s3_prefix_test.py new file mode 100644 index 000000000..6ced7ef8b --- /dev/null +++ b/tests_v9/unit/model/s3_prefix_test.py @@ -0,0 +1,117 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for S3Prefix.creator in pyatlan_v9.""" + +import pytest +from msgspec import UNSET + +from pyatlan_v9.model import S3Prefix + +CONNECTION_QN = "default/s3/1700000000" +BUCKET_NAME = "example-bucket" +BUCKET_QN = f"{CONNECTION_QN}/{BUCKET_NAME}" + + +def _create(prefix: str, **kwargs) -> S3Prefix: + return S3Prefix.creator( + connection_qualified_name=CONNECTION_QN, + s3_bucket_name=BUCKET_NAME, + s3_bucket_qualified_name=BUCKET_QN, + prefix=prefix, + **kwargs, + ) + + +@pytest.mark.parametrize( + "connection_qualified_name, s3_bucket_name, s3_bucket_qualified_name, prefix, message", + [ + (None, BUCKET_NAME, BUCKET_QN, "a/", "connection_qualified_name is required"), + (CONNECTION_QN, None, BUCKET_QN, "a/", "s3_bucket_name is required"), + ( + CONNECTION_QN, + BUCKET_NAME, + None, + "a/", + "s3_bucket_qualified_name is required", + ), + (CONNECTION_QN, BUCKET_NAME, BUCKET_QN, None, "prefix is required"), + (CONNECTION_QN, BUCKET_NAME, BUCKET_QN, " ", "prefix cannot be blank"), + (CONNECTION_QN, BUCKET_NAME, BUCKET_QN, "a//b/", "empty segments"), + (CONNECTION_QN, BUCKET_NAME, BUCKET_QN, "/", "empty segments"), + ("default/s3", BUCKET_NAME, BUCKET_QN, "a/", "Invalid connection"), + ("default/gcs/1", BUCKET_NAME, BUCKET_QN, "a/", "Invalid connection"), + ], +) +def test_creator_with_invalid_parameters_raises_value_error( + connection_qualified_name, s3_bucket_name, s3_bucket_qualified_name, prefix, message +): + with pytest.raises(ValueError, match=message): + S3Prefix.creator( + connection_qualified_name=connection_qualified_name, + s3_bucket_name=s3_bucket_name, + s3_bucket_qualified_name=s3_bucket_qualified_name, + prefix=prefix, + ) + + +def test_creator_root_prefix(): + sut = _create("raw/") + + assert sut.name == "raw" + assert sut.qualified_name == f"{BUCKET_QN}/raw/" + assert sut.connection_qualified_name == CONNECTION_QN + assert sut.connector_name == "s3" + assert sut.s3_bucket_name == BUCKET_NAME + assert sut.s3_bucket_qualified_name == BUCKET_QN + assert sut.s3_bucket.qualified_name == BUCKET_QN + assert sut.s3_parent_prefix_qualified_name is UNSET + assert sut.s3_prefix_hierarchy is UNSET + + +def test_creator_nested_prefix_sets_parent_and_hierarchy(): + sut = _create("raw/2025/data/") + + assert sut.name == "data" + assert sut.qualified_name == f"{BUCKET_QN}/raw/2025/data/" + assert sut.s3_parent_prefix_qualified_name == f"{BUCKET_QN}/raw/2025/" + assert sut.s3_prefix_hierarchy == [ + {"name": "2025", "qualifiedName": f"{BUCKET_QN}/raw/2025/"}, + {"name": "raw", "qualifiedName": f"{BUCKET_QN}/raw/"}, + ] + + +@pytest.mark.parametrize("prefix", ["a/b", "a/b/", "/a/b/"]) +def test_creator_normalises_slashes(prefix): + assert _create(prefix).qualified_name == f"{BUCKET_QN}/a/b/" + + +def test_creator_explicit_name(): + assert _create("a/b/", name="B folder").name == "B folder" + + +def test_parity_s3_app(): + """atlan-s3-app: {bucket_qn}/{prefix_key} where prefix_key keeps its trailing slash.""" + prefix_key = "a/b/" + sut = _create(prefix_key) + assert sut.qualified_name == f"{BUCKET_QN}/{prefix_key}" + assert sut.name == prefix_key.split("/")[-2] + assert sut.s3_parent_prefix_qualified_name == f"{BUCKET_QN}/a/" + + +def test_parity_bridge_app(): + """atlan-bridge-app: {conn}/{bucket}/{parent_folder_path}/{name}/.""" + parent_folder_path, name = "/raw/2025/", "data" + full_path = f"{parent_folder_path.strip('/')}/{name.strip('/')}".strip("/") + sut = _create(full_path) + assert sut.qualified_name == f"{CONNECTION_QN}/{BUCKET_NAME}/{full_path}/" + assert sut.name == name + + +@pytest.mark.xfail( + strict=True, + reason="Generated _QUALIFIED_NAME_PATTERN rejects the trailing slash; needs " + "qualifiedNameFormula on S3Prefix in atlanhq/models (FND-3846)", +) +def test_creator_output_passes_generated_validation(): + _create("raw/2025/").validate(for_creation=True) diff --git a/tests_v9/unit/model/tag_attachment_test.py b/tests_v9/unit/model/tag_attachment_test.py new file mode 100644 index 000000000..4c59179eb --- /dev/null +++ b/tests_v9/unit/model/tag_attachment_test.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for TagAttachment.creator in pyatlan_v9.""" + +import pytest +from msgspec import UNSET + +from pyatlan_v9.model import TagAttachment + +DATABRICKS_CONN = "default/databricks/1700000000" +DBT_CONN = "default/dbt/1700000000" +BRIDGE_CONN = "default/example-provider/1700000000" + + +def _create(**kwargs) -> TagAttachment: + params = { + "name": "pii", + "connection_qualified_name": DATABRICKS_CONN, + "tag_qualified_name": f"{DATABRICKS_CONN}/tag/pii", + "object_qualified_name": f"{DATABRICKS_CONN}/cat/sch/tbl", + **kwargs, + } + return TagAttachment.creator(**params) + + +@pytest.mark.parametrize( + "kwargs, message", + [ + ({"name": None}, "name is required"), + ({"name": " "}, "name cannot be blank"), + ({"connection_qualified_name": None}, "connection_qualified_name is required"), + ({"tag_qualified_name": ""}, "tag_qualified_name cannot be blank"), + ({"object_qualified_name": None}, "object_qualified_name is required"), + ({"connection_qualified_name": "default/dbt"}, "Invalid connection"), + ({"qn_style": "unknown"}, "qn_style must be one of"), + ], +) +def test_creator_with_invalid_parameters_raises_value_error(kwargs, message): + with pytest.raises(ValueError, match=message): + _create(**kwargs) + + +def test_parity_databricks(): + """atlan-databricks-app: {conn}/tag/{tag_name}/{object_qn} (default style).""" + tag_qn = f"{DATABRICKS_CONN}/tag/pii" + obj_qn = f"{DATABRICKS_CONN}/cat/sch/tbl/col" + sut = _create( + object_qualified_name=obj_qn, + object_type_name="Column", + source_tag_type_name="DatabricksUnityCatalogTag", + value="high", + ) + assert sut.qualified_name == f"{tag_qn}/{obj_qn}" + assert sut.name == "pii" + assert sut.connector_name == "databricks" + assert sut.connection_qualified_name == DATABRICKS_CONN + assert sut.tag_qualified_name == tag_qn + assert sut.source_tag_qualified_name == tag_qn + assert sut.object_qualified_name == obj_qn + assert sut.object_type_name == "Column" + assert sut.source_tag_type_name == "DatabricksUnityCatalogTag" + assert sut.value == "high" + assert sut.value_type == "STRING" + assert sut.tag_attachment_string_value == "high" + assert sut.propagate is False + sut.validate(for_creation=True) + + +def test_parity_dbt(): + """atlan-dbt-app: {dbt_tag_qn}/attachments/{object_qn}.""" + tag_qn = f"{DBT_CONN}/account/1/project/2/tag/finance" + obj_qn = f"{DBT_CONN}/account/1/project/2/env/3/model.pkg.orders" + sut = _create( + name="finance", + connection_qualified_name=DBT_CONN, + tag_qualified_name=tag_qn, + object_qualified_name=obj_qn, + qn_style="tag_attachments_object", + source_tag_type_name="DbtTag", + ) + assert sut.qualified_name == f"{tag_qn}/attachments/{obj_qn}" + assert sut.connector_name == "dbt" + assert sut.value is None + assert sut.value_type == "NONE" + assert sut.tag_attachment_string_value is UNSET + + +def test_parity_bridge(): + """atlan-bridge-app: {object_qn}/{tag_name}.""" + tag_qn = f"{BRIDGE_CONN}/source-tag/42" + obj_qn = f"{BRIDGE_CONN}/db/sch/tbl" + sut = _create( + name="Confidential", + connection_qualified_name=BRIDGE_CONN, + tag_qualified_name=tag_qn, + object_qualified_name=obj_qn, + qn_style="object_tag", + source_tag_type_name="SourceTag", + ) + assert sut.qualified_name == f"{obj_qn}/Confidential" + assert sut.source_tag_display_name == "Confidential" + assert sut.connector_name == "example-provider" + + +def test_round_trip_json(): + sut = _create(value="v") + restored = TagAttachment.from_json(sut.to_json()) + assert restored.qualified_name == sut.qualified_name + assert restored.tag_qualified_name == sut.tag_qualified_name From f3c2840d365cb746b103c780d4e6d22b277526f7 Mon Sep 17 00:00:00 2001 From: Christopher Grote Date: Mon, 19 Oct 2026 14:50:52 +0100 Subject: [PATCH 2/5] FND-3846: regenerate S3Prefix pattern from models, add integration tests - s3_prefix.py regenerated from atlanhq/models#2093 with this branch's overlays: _QUALIFIED_NAME_PATTERN is now ^.+/[^/]+/$, so creator output passes validate(for_creation=True); the strict xfail becomes a real test. flow_control_operation.py and tag_attachment.py regenerate byte-identical. - Integration tests: S3Prefix (root + nested, parent relationship), FlowControlOperation (Fabric notebook + Spark job, SageMaker job), TagAttachment (all three qn_style shapes). - AtlanConnectorType.FABRIC ("fabric", bi) and SAGEMAKER ("sagemaker", SaaS), matching the connector values in atlan-fabric-app / atlan-sagemaker-app. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wc98jPXjP7pdqJoAh3vtfY --- pyatlan/model/enums.py | 2 + pyatlan_v9/model/assets/s3_prefix.py | 2 +- .../flow_control_operation_asset_test.py | 134 ++++++++++++++++++ tests_v9/integration/s3_asset_test.py | 69 ++++++++- .../integration/tag_attachment_asset_test.py | 83 +++++++++++ tests_v9/unit/model/s3_prefix_test.py | 20 ++- 6 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 tests_v9/integration/flow_control_operation_asset_test.py create mode 100644 tests_v9/integration/tag_attachment_asset_test.py diff --git a/pyatlan/model/enums.py b/pyatlan/model/enums.py index 28b30f197..c02489928 100644 --- a/pyatlan/model/enums.py +++ b/pyatlan/model/enums.py @@ -395,6 +395,8 @@ def get_connector_name( SAP_ECC = ("sap-ecc", AtlanConnectionCategory.ERP) CLOUDSQL_POSTGRES = ("cloudsql-postgres", AtlanConnectionCategory.DATABASE) ALLOYDB_POSTGRES = ("alloydb-postgres", AtlanConnectionCategory.DATABASE) + FABRIC = ("fabric", AtlanConnectionCategory.BI) + SAGEMAKER = ("sagemaker", AtlanConnectionCategory.SAAS) class AtlanCustomAttributePrimitiveType(str, Enum): diff --git a/pyatlan_v9/model/assets/s3_prefix.py b/pyatlan_v9/model/assets/s3_prefix.py index 73e66f48b..3040ef280 100644 --- a/pyatlan_v9/model/assets/s3_prefix.py +++ b/pyatlan_v9/model/assets/s3_prefix.py @@ -311,7 +311,7 @@ def __post_init__(self) -> None: # SDK Methods # ========================================================================= - _QUALIFIED_NAME_PATTERN: ClassVar[re.Pattern] = re.compile(r"^.+/[^/]+/[^/]+$") + _QUALIFIED_NAME_PATTERN: ClassVar[re.Pattern] = re.compile(r"^.+/[^/]+/$") def validate(self, for_creation: bool = False) -> None: """ diff --git a/tests_v9/integration/flow_control_operation_asset_test.py b/tests_v9/integration/flow_control_operation_asset_test.py new file mode 100644 index 000000000..41de36d32 --- /dev/null +++ b/tests_v9/integration/flow_control_operation_asset_test.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +from typing import Generator + +import pytest + +from pyatlan_v9.client.atlan import AtlanClient +from pyatlan_v9.model.assets import Connection, FlowControlOperation +from pyatlan_v9.model.enums import AtlanConnectorType +from tests_v9.integration.client import TestId, delete_asset +from tests_v9.integration.connection_test import create_connection + +MODULE_NAME = TestId.make_unique("FCO") +WORKSPACE_ID = f"ws{MODULE_NAME}" +NOTEBOOK_ID = f"nb{MODULE_NAME}" +JOB_KEY = f"job{MODULE_NAME}" +REGION = "us-east-1" +JOB_NAME = f"training-{MODULE_NAME}" + + +@pytest.fixture(scope="module") +def fabric_connection(client: AtlanClient) -> Generator[Connection, None, None]: + result = create_connection( + client=client, name=MODULE_NAME, connector_type=AtlanConnectorType.FABRIC + ) + yield result + delete_asset(client, guid=result.guid, asset_type=Connection) + + +@pytest.fixture(scope="module") +def sagemaker_connection(client: AtlanClient) -> Generator[Connection, None, None]: + result = create_connection( + client=client, name=MODULE_NAME, connector_type=AtlanConnectorType.SAGEMAKER + ) + yield result + delete_asset(client, guid=result.guid, asset_type=Connection) + + +def _save(client: AtlanClient, to_create: FlowControlOperation) -> FlowControlOperation: + return client.asset.save(to_create).assets_created(asset_type=FlowControlOperation)[ + 0 + ] + + +@pytest.fixture(scope="module") +def notebook( + client: AtlanClient, fabric_connection: Connection +) -> Generator[FlowControlOperation, None, None]: + result = _save( + client, + FlowControlOperation.creator( + name=f"notebook_{MODULE_NAME}", + connection_qualified_name=fabric_connection.qualified_name, + scope=WORKSPACE_ID, + source_id=NOTEBOOK_ID, + flow_id=NOTEBOOK_ID, + ), + ) + yield result + delete_asset(client, guid=result.guid, asset_type=FlowControlOperation) + + +@pytest.fixture(scope="module") +def spark_job( + client: AtlanClient, fabric_connection: Connection, notebook: FlowControlOperation +) -> Generator[FlowControlOperation, None, None]: + result = _save( + client, + FlowControlOperation.creator( + name=f"job_{MODULE_NAME}", + connection_qualified_name=fabric_connection.qualified_name, + parent_qualified_name=notebook.qualified_name, + source_id=JOB_KEY, + flow_id=JOB_KEY, + ), + ) + yield result + delete_asset(client, guid=result.guid, asset_type=FlowControlOperation) + + +@pytest.fixture(scope="module") +def sagemaker_job( + client: AtlanClient, sagemaker_connection: Connection +) -> Generator[FlowControlOperation, None, None]: + result = _save( + client, + FlowControlOperation.creator( + name=JOB_NAME, + connection_qualified_name=sagemaker_connection.qualified_name, + scope=REGION, + source_id=JOB_NAME, + ), + ) + yield result + delete_asset(client, guid=result.guid, asset_type=FlowControlOperation) + + +def test_fabric_notebook_and_spark_job( + client: AtlanClient, + fabric_connection: Connection, + notebook: FlowControlOperation, + spark_job: FlowControlOperation, +): + conn_qn = fabric_connection.qualified_name + assert notebook.guid + assert notebook.qualified_name == f"{conn_qn}/{WORKSPACE_ID}/{NOTEBOOK_ID}" + assert spark_job.qualified_name == f"{notebook.qualified_name}/jobs/{JOB_KEY}" + + restored = client.asset.get_by_qualified_name( + asset_type=FlowControlOperation, + qualified_name=spark_job.qualified_name, + ignore_relationships=False, + ) + assert restored.guid == spark_job.guid + assert restored.connection_qualified_name == conn_qn + assert restored.connector_name == "fabric" + assert restored.flow_id == JOB_KEY + assert restored.flow_controlled_by.guid == notebook.guid + + +def test_sagemaker_job( + client: AtlanClient, + sagemaker_connection: Connection, + sagemaker_job: FlowControlOperation, +): + conn_qn = sagemaker_connection.qualified_name + assert sagemaker_job.guid + assert sagemaker_job.qualified_name == f"{conn_qn}/{REGION}/{JOB_NAME}" + restored = client.asset.get_by_qualified_name( + asset_type=FlowControlOperation, qualified_name=sagemaker_job.qualified_name + ) + assert restored.guid == sagemaker_job.guid + assert restored.connector_name == "sagemaker" diff --git a/tests_v9/integration/s3_asset_test.py b/tests_v9/integration/s3_asset_test.py index 69debb438..37aee9132 100644 --- a/tests_v9/integration/s3_asset_test.py +++ b/tests_v9/integration/s3_asset_test.py @@ -5,7 +5,7 @@ import pytest from pyatlan_v9.client.atlan import AtlanClient -from pyatlan_v9.model.assets import Connection, S3Bucket, S3Object +from pyatlan_v9.model.assets import Connection, S3Bucket, S3Object, S3Prefix from pyatlan_v9.model.core import Announcement from pyatlan_v9.model.enums import ( AnnouncementType, @@ -419,3 +419,70 @@ def test_restore_object_with_name( s3object_with_name: S3Object, ): _assert_restore_object(client, s3object_with_name) + + +ROOT_PREFIX = f"raw_{MODULE_NAME}/" +NESTED_PREFIX = f"{ROOT_PREFIX}2025/" + + +@pytest.fixture(scope="module") +def root_prefix( + client: AtlanClient, connection: Connection, bucket_with_name: S3Bucket +) -> Generator[S3Prefix, None, None]: + to_create = S3Prefix.creator( + connection_qualified_name=connection.qualified_name, + s3_bucket_name=bucket_with_name.name, + s3_bucket_qualified_name=bucket_with_name.qualified_name, + prefix=ROOT_PREFIX, + ) + result = client.asset.save(to_create).assets_created(asset_type=S3Prefix)[0] + yield result + delete_asset(client, guid=result.guid, asset_type=S3Prefix) + + +@pytest.fixture(scope="module") +def nested_prefix( + client: AtlanClient, + connection: Connection, + bucket_with_name: S3Bucket, + root_prefix: S3Prefix, +) -> Generator[S3Prefix, None, None]: + to_create = S3Prefix.creator( + connection_qualified_name=connection.qualified_name, + s3_bucket_name=bucket_with_name.name, + s3_bucket_qualified_name=bucket_with_name.qualified_name, + prefix=NESTED_PREFIX, + ) + to_create.s3_parent_prefix = root_prefix.relate() + result = client.asset.save(to_create).assets_created(asset_type=S3Prefix)[0] + yield result + delete_asset(client, guid=result.guid, asset_type=S3Prefix) + + +def test_s3_prefix( + client: AtlanClient, + connection: Connection, + bucket_with_name: S3Bucket, + root_prefix: S3Prefix, + nested_prefix: S3Prefix, +): + assert root_prefix.guid + assert ( + root_prefix.qualified_name == f"{bucket_with_name.qualified_name}/{ROOT_PREFIX}" + ) + assert root_prefix.name == f"raw_{MODULE_NAME}" + assert nested_prefix.qualified_name == ( + f"{bucket_with_name.qualified_name}/{NESTED_PREFIX}" + ) + + restored = client.asset.get_by_qualified_name( + asset_type=S3Prefix, + qualified_name=nested_prefix.qualified_name, + ignore_relationships=False, + ) + assert restored.guid == nested_prefix.guid + assert restored.connection_qualified_name == connection.qualified_name + assert restored.s3_bucket_qualified_name == bucket_with_name.qualified_name + assert restored.s3_parent_prefix_qualified_name == root_prefix.qualified_name + assert restored.s3_bucket.guid == bucket_with_name.guid + assert restored.s3_parent_prefix.guid == root_prefix.guid diff --git a/tests_v9/integration/tag_attachment_asset_test.py b/tests_v9/integration/tag_attachment_asset_test.py new file mode 100644 index 000000000..beee5c2ec --- /dev/null +++ b/tests_v9/integration/tag_attachment_asset_test.py @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +from typing import Generator + +import pytest + +from pyatlan_v9.client.atlan import AtlanClient +from pyatlan_v9.model.assets import Connection, TagAttachment +from pyatlan_v9.model.enums import AtlanConnectorType +from tests_v9.integration.client import TestId, delete_asset +from tests_v9.integration.connection_test import create_connection + +MODULE_NAME = TestId.make_unique("TAGATT") +TAG_NAME = f"pii_{MODULE_NAME}" + +# (qn_style, object path under the connection, expected qn builder) +STYLES = [ + ("tag_object", "cat/sch/tbl", lambda tag, obj: f"{tag}/{obj}"), + ( + "tag_attachments_object", + "cat/sch/view", + lambda tag, obj: f"{tag}/attachments/{obj}", + ), + ("object_tag", "cat/sch/col", lambda tag, obj: f"{obj}/{TAG_NAME}"), +] + + +@pytest.fixture(scope="module") +def connection(client: AtlanClient) -> Generator[Connection, None, None]: + result = create_connection( + client=client, name=MODULE_NAME, connector_type=AtlanConnectorType.DATABRICKS + ) + yield result + delete_asset(client, guid=result.guid, asset_type=Connection) + + +@pytest.fixture(scope="module") +def attachments( + client: AtlanClient, connection: Connection +) -> Generator[dict, None, None]: + conn_qn = connection.qualified_name + tag_qn = f"{conn_qn}/tag/{TAG_NAME}" + created = {} + for qn_style, path, _ in STYLES: + to_create = TagAttachment.creator( + name=TAG_NAME, + connection_qualified_name=conn_qn, + tag_qualified_name=tag_qn, + object_qualified_name=f"{conn_qn}/{path}", + qn_style=qn_style, + value="high", + ) + created[qn_style] = client.asset.save(to_create).assets_created( + asset_type=TagAttachment + )[0] + yield created + for result in created.values(): + delete_asset(client, guid=result.guid, asset_type=TagAttachment) + + +@pytest.mark.parametrize("qn_style, path, expected", STYLES) +def test_tag_attachment( + client: AtlanClient, + connection: Connection, + attachments: dict, + qn_style: str, + path: str, + expected, +): + conn_qn = connection.qualified_name + tag_qn = f"{conn_qn}/tag/{TAG_NAME}" + created = attachments[qn_style] + assert created.guid + assert created.qualified_name == expected(tag_qn, f"{conn_qn}/{path}") + + restored = client.asset.get_by_qualified_name( + asset_type=TagAttachment, qualified_name=created.qualified_name + ) + assert restored.guid == created.guid + assert restored.connection_qualified_name == conn_qn + assert restored.tag_qualified_name == tag_qn + assert restored.tag_attachment_string_value == "high" diff --git a/tests_v9/unit/model/s3_prefix_test.py b/tests_v9/unit/model/s3_prefix_test.py index 6ced7ef8b..c45250faa 100644 --- a/tests_v9/unit/model/s3_prefix_test.py +++ b/tests_v9/unit/model/s3_prefix_test.py @@ -108,10 +108,16 @@ def test_parity_bridge_app(): assert sut.name == name -@pytest.mark.xfail( - strict=True, - reason="Generated _QUALIFIED_NAME_PATTERN rejects the trailing slash; needs " - "qualifiedNameFormula on S3Prefix in atlanhq/models (FND-3846)", -) -def test_creator_output_passes_generated_validation(): - _create("raw/2025/").validate(for_creation=True) +@pytest.mark.parametrize("prefix", ["raw/", "raw/2025/data/"]) +def test_creator_output_passes_generated_validation(prefix): + _create(prefix).validate(for_creation=True) + + +def test_parity_s3_app_literal_qualified_name(): + sut = S3Prefix.creator( + connection_qualified_name="default/s3/1700000000", + s3_bucket_name="example-bucket", + s3_bucket_qualified_name="default/s3/1700000000/example-bucket", + prefix="a/b/", + ) + assert sut.qualified_name == "default/s3/1700000000/example-bucket/a/b/" From d79b089e6ba594161bb470753dc43aab3c4f95a5 Mon Sep 17 00:00:00 2001 From: Christopher Grote Date: Mon, 19 Oct 2026 15:54:36 +0100 Subject: [PATCH 3/5] FND-3846: restore S3Object.create_with_prefix alias lost in the overlay migration 76c5b1780 added `create_with_prefix = creator_with_prefix` to the hand-written s3_object.py; 65ac20ec9 moved the class to Pkl generation + overlays without carrying the alias into _overlays/s3_object.py, so it disappeared and tests_v9/integration/s3_asset_test.py (which uses it) has errored since. Re-add it in the overlay (same alias style as AirflowDag.create), regenerated from models#2093: the diff in s3_object.py is exactly the alias. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wc98jPXjP7pdqJoAh3vtfY --- pyatlan_v9/model/assets/_overlays/s3_object.py | 4 ++++ pyatlan_v9/model/assets/s3_object.py | 4 ++++ tests_v9/unit/model/s3_object_test.py | 16 ++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/pyatlan_v9/model/assets/_overlays/s3_object.py b/pyatlan_v9/model/assets/_overlays/s3_object.py index c7492799d..2f5f529a6 100644 --- a/pyatlan_v9/model/assets/_overlays/s3_object.py +++ b/pyatlan_v9/model/assets/_overlays/s3_object.py @@ -124,6 +124,10 @@ def creator_with_prefix( s3_bucket_qualified_name=s3_bucket_qualified_name, ) + @classmethod + def create_with_prefix(cls, **kwargs) -> "S3Object": + return cls.creator_with_prefix(**kwargs) + @classmethod def updater(cls, *, qualified_name: str, name: str) -> "S3Object": """ diff --git a/pyatlan_v9/model/assets/s3_object.py b/pyatlan_v9/model/assets/s3_object.py index 8bfa2d257..299b63710 100644 --- a/pyatlan_v9/model/assets/s3_object.py +++ b/pyatlan_v9/model/assets/s3_object.py @@ -531,6 +531,10 @@ def creator_with_prefix( s3_bucket_qualified_name=s3_bucket_qualified_name, ) + @classmethod + def create_with_prefix(cls, **kwargs) -> "S3Object": + return cls.creator_with_prefix(**kwargs) + @classmethod def updater(cls, *, qualified_name: str, name: str) -> "S3Object": """ diff --git a/tests_v9/unit/model/s3_object_test.py b/tests_v9/unit/model/s3_object_test.py index 2856ae042..5d0a44784 100644 --- a/tests_v9/unit/model/s3_object_test.py +++ b/tests_v9/unit/model/s3_object_test.py @@ -359,6 +359,22 @@ def test_creator_with_prefix( assert attributes.s3_bucket_qualified_name == s3_bucket_qualified_name +def test_create_with_prefix_is_alias_for_creator_with_prefix(): + """create_with_prefix (kept for pyatlan compatibility) matches creator_with_prefix.""" + kwargs = dict( + name=S3_OBJECT_NAME, + connection_qualified_name=S3_CONNECTION_QUALIFIED_NAME, + prefix=S3_OBJECT_PREFIX, + s3_bucket_name=BUCKET_NAME, + s3_bucket_qualified_name=BUCKET_QUALIFIED_NAME, + ) + alias = S3Object.create_with_prefix(**kwargs) + expected = S3Object.creator_with_prefix(**kwargs) + assert alias.qualified_name == expected.qualified_name + assert alias.s3_object_key == expected.s3_object_key + assert alias.s3_bucket_qualified_name == expected.s3_bucket_qualified_name + + @pytest.mark.parametrize( "qualified_name, name, message", [ From dcb9b7630c512638ba1f6c1670988ce330509841 Mon Sep 17 00:00:00 2001 From: Christopher Grote Date: Mon, 19 Oct 2026 16:25:25 +0100 Subject: [PATCH 4/5] FND-3846: S3Object creators set the bucket relationship; add delete_handler - S3Object.creator / creator_with_prefix now set bucket=RelatedS3Bucket(qualified_name=s3_bucket_qualified_name), matching pyatlan's creators; without it bucket.objects came back empty. - delete_handler (Atlas deleteHandler) on Entity, ReferenceableNested and the nested conversions, regenerated from models#2093's renderer change. Only the target files are regenerated here (entity, referenceable, s3_object and the three FND-3846 types); the sync bot regenerates the rest after models merges. - s3_asset_test.py: v9 returns relationships as RelatedS3Object, so assert that type. The delete tests now pass and confirm deleteHandler is in the payload. The file passes in full against a tenant (17 passed). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wc98jPXjP7pdqJoAh3vtfY --- .../model/assets/_overlays/s3_object.py | 2 ++ pyatlan_v9/model/assets/entity.py | 3 ++ .../model/assets/flow_control_operation.py | 2 ++ pyatlan_v9/model/assets/referenceable.py | 3 ++ pyatlan_v9/model/assets/s3_object.py | 4 +++ pyatlan_v9/model/assets/s3_prefix.py | 2 ++ pyatlan_v9/model/assets/tag_attachment.py | 2 ++ tests_v9/integration/s3_asset_test.py | 10 ++++-- tests_v9/unit/model/s3_object_test.py | 35 +++++++++++++++++++ 9 files changed, 61 insertions(+), 2 deletions(-) diff --git a/pyatlan_v9/model/assets/_overlays/s3_object.py b/pyatlan_v9/model/assets/_overlays/s3_object.py index 2f5f529a6..805b5cd37 100644 --- a/pyatlan_v9/model/assets/_overlays/s3_object.py +++ b/pyatlan_v9/model/assets/_overlays/s3_object.py @@ -61,6 +61,7 @@ def creator( aws_arn=aws_arn, s3_bucket_name=s3_bucket_name, s3_bucket_qualified_name=s3_bucket_qualified_name, + bucket=RelatedS3Bucket(qualified_name=s3_bucket_qualified_name), ) @classmethod @@ -122,6 +123,7 @@ def creator_with_prefix( connector_name=connector_name, s3_bucket_name=s3_bucket_name, s3_bucket_qualified_name=s3_bucket_qualified_name, + bucket=RelatedS3Bucket(qualified_name=s3_bucket_qualified_name), ) @classmethod diff --git a/pyatlan_v9/model/assets/entity.py b/pyatlan_v9/model/assets/entity.py index c37568320..610f04a27 100644 --- a/pyatlan_v9/model/assets/entity.py +++ b/pyatlan_v9/model/assets/entity.py @@ -199,6 +199,9 @@ class Entity(msgspec.Struct, kw_only=True, omit_defaults=True, rename="camel"): home_id: Union[str, UnsetType] = UNSET """Home identifier for distributed Atlas systems.""" + delete_handler: Union[str, UnsetType] = UNSET + """Handler used to delete this entity (e.g. SOFT, HARD, PURGE); set on deleted entities.""" + # Lineage-specific fields (only populated in lineage API responses) depth: Union[int, None, UnsetType] = UNSET """Depth of this asset within lineage. Only available in assets retrieved via lineage.""" diff --git a/pyatlan_v9/model/assets/flow_control_operation.py b/pyatlan_v9/model/assets/flow_control_operation.py index 0e2aa062a..ed9bb90b2 100644 --- a/pyatlan_v9/model/assets/flow_control_operation.py +++ b/pyatlan_v9/model/assets/flow_control_operation.py @@ -724,6 +724,7 @@ def _flow_control_operation_to_nested( is_incomplete=flow_control_operation.is_incomplete, provenance_type=flow_control_operation.provenance_type, home_id=flow_control_operation.home_id, + delete_handler=flow_control_operation.delete_handler, depth=flow_control_operation.depth, immediate_upstream=flow_control_operation.immediate_upstream, immediate_downstream=flow_control_operation.immediate_downstream, @@ -774,6 +775,7 @@ def _flow_control_operation_from_nested( "is_incomplete": nested.is_incomplete, "provenance_type": nested.provenance_type, "home_id": nested.home_id, + "delete_handler": nested.delete_handler, "depth": nested.depth, "immediate_upstream": nested.immediate_upstream, "immediate_downstream": nested.immediate_downstream, diff --git a/pyatlan_v9/model/assets/referenceable.py b/pyatlan_v9/model/assets/referenceable.py index 4527ccbd7..90cfca210 100644 --- a/pyatlan_v9/model/assets/referenceable.py +++ b/pyatlan_v9/model/assets/referenceable.py @@ -306,6 +306,7 @@ class ReferenceableNested( is_incomplete: Union[Any, UnsetType] = UNSET provenance_type: Union[Any, UnsetType] = UNSET home_id: Union[Any, UnsetType] = UNSET + delete_handler: Union[Any, UnsetType] = UNSET depth: Union[Any, UnsetType] = UNSET immediate_upstream: Union[Any, UnsetType] = UNSET immediate_downstream: Union[Any, UnsetType] = UNSET @@ -384,6 +385,7 @@ def _referenceable_to_nested(referenceable: Referenceable) -> ReferenceableNeste is_incomplete=referenceable.is_incomplete, provenance_type=referenceable.provenance_type, home_id=referenceable.home_id, + delete_handler=referenceable.delete_handler, depth=referenceable.depth, immediate_upstream=referenceable.immediate_upstream, immediate_downstream=referenceable.immediate_downstream, @@ -432,6 +434,7 @@ def _referenceable_from_nested(nested: ReferenceableNested) -> Referenceable: "is_incomplete": nested.is_incomplete, "provenance_type": nested.provenance_type, "home_id": nested.home_id, + "delete_handler": nested.delete_handler, "depth": nested.depth, "immediate_upstream": nested.immediate_upstream, "immediate_downstream": nested.immediate_downstream, diff --git a/pyatlan_v9/model/assets/s3_object.py b/pyatlan_v9/model/assets/s3_object.py index 299b63710..3783600c1 100644 --- a/pyatlan_v9/model/assets/s3_object.py +++ b/pyatlan_v9/model/assets/s3_object.py @@ -468,6 +468,7 @@ def creator( aws_arn=aws_arn, s3_bucket_name=s3_bucket_name, s3_bucket_qualified_name=s3_bucket_qualified_name, + bucket=RelatedS3Bucket(qualified_name=s3_bucket_qualified_name), ) @classmethod @@ -529,6 +530,7 @@ def creator_with_prefix( connector_name=connector_name, s3_bucket_name=s3_bucket_name, s3_bucket_qualified_name=s3_bucket_qualified_name, + bucket=RelatedS3Bucket(qualified_name=s3_bucket_qualified_name), ) @classmethod @@ -975,6 +977,7 @@ def _s3_object_to_nested(s3_object: S3Object) -> S3ObjectNested: is_incomplete=s3_object.is_incomplete, provenance_type=s3_object.provenance_type, home_id=s3_object.home_id, + delete_handler=s3_object.delete_handler, depth=s3_object.depth, immediate_upstream=s3_object.immediate_upstream, immediate_downstream=s3_object.immediate_downstream, @@ -1021,6 +1024,7 @@ def _s3_object_from_nested(nested: S3ObjectNested) -> S3Object: "is_incomplete": nested.is_incomplete, "provenance_type": nested.provenance_type, "home_id": nested.home_id, + "delete_handler": nested.delete_handler, "depth": nested.depth, "immediate_upstream": nested.immediate_upstream, "immediate_downstream": nested.immediate_downstream, diff --git a/pyatlan_v9/model/assets/s3_prefix.py b/pyatlan_v9/model/assets/s3_prefix.py index 3040ef280..792651866 100644 --- a/pyatlan_v9/model/assets/s3_prefix.py +++ b/pyatlan_v9/model/assets/s3_prefix.py @@ -850,6 +850,7 @@ def _s3_prefix_to_nested(s3_prefix: S3Prefix) -> S3PrefixNested: is_incomplete=s3_prefix.is_incomplete, provenance_type=s3_prefix.provenance_type, home_id=s3_prefix.home_id, + delete_handler=s3_prefix.delete_handler, depth=s3_prefix.depth, immediate_upstream=s3_prefix.immediate_upstream, immediate_downstream=s3_prefix.immediate_downstream, @@ -896,6 +897,7 @@ def _s3_prefix_from_nested(nested: S3PrefixNested) -> S3Prefix: "is_incomplete": nested.is_incomplete, "provenance_type": nested.provenance_type, "home_id": nested.home_id, + "delete_handler": nested.delete_handler, "depth": nested.depth, "immediate_upstream": nested.immediate_upstream, "immediate_downstream": nested.immediate_downstream, diff --git a/pyatlan_v9/model/assets/tag_attachment.py b/pyatlan_v9/model/assets/tag_attachment.py index 2246e8589..cbbb69781 100644 --- a/pyatlan_v9/model/assets/tag_attachment.py +++ b/pyatlan_v9/model/assets/tag_attachment.py @@ -643,6 +643,7 @@ def _tag_attachment_to_nested(tag_attachment: TagAttachment) -> TagAttachmentNes is_incomplete=tag_attachment.is_incomplete, provenance_type=tag_attachment.provenance_type, home_id=tag_attachment.home_id, + delete_handler=tag_attachment.delete_handler, depth=tag_attachment.depth, immediate_upstream=tag_attachment.immediate_upstream, immediate_downstream=tag_attachment.immediate_downstream, @@ -691,6 +692,7 @@ def _tag_attachment_from_nested(nested: TagAttachmentNested) -> TagAttachment: "is_incomplete": nested.is_incomplete, "provenance_type": nested.provenance_type, "home_id": nested.home_id, + "delete_handler": nested.delete_handler, "depth": nested.depth, "immediate_upstream": nested.immediate_upstream, "immediate_downstream": nested.immediate_downstream, diff --git a/tests_v9/integration/s3_asset_test.py b/tests_v9/integration/s3_asset_test.py index 37aee9132..da16843fb 100644 --- a/tests_v9/integration/s3_asset_test.py +++ b/tests_v9/integration/s3_asset_test.py @@ -5,7 +5,13 @@ import pytest from pyatlan_v9.client.atlan import AtlanClient -from pyatlan_v9.model.assets import Connection, S3Bucket, S3Object, S3Prefix +from pyatlan_v9.model.assets import ( + Connection, + RelatedS3Object, + S3Bucket, + S3Object, + S3Prefix, +) from pyatlan_v9.model.core import Announcement from pyatlan_v9.model.enums import ( AnnouncementType, @@ -191,7 +197,7 @@ def _assert_retrieve_bucket(client, bucket, s3object, with_name=False): assert b.certificate_status_message == CERTIFICATE_MESSAGE assert b.objects assert len(b.objects) == 1 - assert isinstance(b.objects[0], S3Object) + assert isinstance(b.objects[0], RelatedS3Object) assert b.objects[0].guid == s3object.guid if with_name: assert not b.aws_arn diff --git a/tests_v9/unit/model/s3_object_test.py b/tests_v9/unit/model/s3_object_test.py index 5d0a44784..1f23bf3e3 100644 --- a/tests_v9/unit/model/s3_object_test.py +++ b/tests_v9/unit/model/s3_object_test.py @@ -404,3 +404,38 @@ def test_trim_to_required(): ).trim_to_required() assert sut.qualified_name == S3_OBJECT_QUALIFIED_NAME assert sut.name == S3_OBJECT_NAME + + +def test_creators_set_bucket_relationship(): + """Both creators link the object to its bucket, as pyatlan's creators do.""" + by_arn = S3Object.creator( + name=S3_OBJECT_NAME, + connection_qualified_name=S3_CONNECTION_QUALIFIED_NAME, + aws_arn=AWS_ARN, + s3_bucket_name=BUCKET_NAME, + s3_bucket_qualified_name=BUCKET_QUALIFIED_NAME, + ) + by_prefix = S3Object.creator_with_prefix( + name=S3_OBJECT_NAME, + connection_qualified_name=S3_CONNECTION_QUALIFIED_NAME, + prefix=S3_OBJECT_PREFIX, + s3_bucket_name=BUCKET_NAME, + s3_bucket_qualified_name=BUCKET_QUALIFIED_NAME, + ) + for sut in (by_arn, by_prefix): + assert sut.bucket.qualified_name == BUCKET_QUALIFIED_NAME + by_prefix.validate(for_creation=True) + + +def test_delete_handler_round_trips_through_nested_json(): + """deleteHandler is a top-level Atlas entity field (outside attributes).""" + payload = ( + '{"typeName": "S3Object", "guid": "1234", "status": "DELETED",' + ' "deleteHandler": "SOFT",' + f' "attributes": {{"qualifiedName": "{S3_OBJECT_QUALIFIED_NAME}",' + f' "name": "{S3_OBJECT_NAME}"}}}}' + ) + sut = S3Object.from_json(payload) + assert sut.delete_handler == "SOFT" + assert sut.status == "DELETED" + assert '"deleteHandler":"SOFT"' in sut.to_json() From 860ab7a7be71f2f98c98b1480c9c8d256cb72870 Mon Sep 17 00:00:00 2001 From: Christopher Grote Date: Tue, 20 Oct 2026 17:12:22 +0100 Subject: [PATCH 5/5] FND-3846: drop duplicate FABRIC connector type now on main Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wc98jPXjP7pdqJoAh3vtfY --- pyatlan/model/enums.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyatlan/model/enums.py b/pyatlan/model/enums.py index 156fc6bf2..9faeb7c55 100644 --- a/pyatlan/model/enums.py +++ b/pyatlan/model/enums.py @@ -400,7 +400,6 @@ def get_connector_name( SAP_ECC = ("sap-ecc", AtlanConnectionCategory.ERP) CLOUDSQL_POSTGRES = ("cloudsql-postgres", AtlanConnectionCategory.DATABASE) ALLOYDB_POSTGRES = ("alloydb-postgres", AtlanConnectionCategory.DATABASE) - FABRIC = ("fabric", AtlanConnectionCategory.BI) SAGEMAKER = ("sagemaker", AtlanConnectionCategory.SAAS)