Skip to content

Commit f7ae057

Browse files
authored
Merge pull request #669 from atlanhq/APP-6912
APP-6912: Added support for creating `user-defined` relationships
2 parents 212ce28 + 69e0300 commit f7ae057

File tree

59 files changed

+4634
-13
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+4634
-13
lines changed

.github/workflows/pyatlan-pr.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ jobs:
3030
# Reference: https://github.com/pytest-dev/py
3131
ignore-vulns: |
3232
PYSEC-2022-42969
33+
GHSA-48p4-8xcf-vxj5
34+
GHSA-pq67-6m6q-mj2v
3335
summary: true
3436
vulnerability-service: osv
3537
inputs: requirements.txt requirements-dev.txt

pyatlan/generator/class_generator.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
import networkx as nx
1919
from jinja2 import Environment, PackageLoader
2020

21-
from pyatlan.model.typedef import EntityDef, EnumDef, TypeDefResponse
22-
from pyatlan.model.utils import to_snake_case
21+
from pyatlan.model.typedef import EntityDef, EnumDef, RelationshipDef, TypeDefResponse
22+
from pyatlan.model.utils import to_python_class_name, to_snake_case
2323

2424
REFERENCEABLE = "Referenceable"
2525
TYPE_DEF_FILE = Path(os.getenv("TMPDIR", "/tmp")) / "typedefs.json"
@@ -80,6 +80,7 @@
8080
# across Python versions (e.g: 3.8 and 3.9).
8181
PARENT = Path(__file__).resolve().parent
8282
ASSETS_DIR = PARENT.parent / "model" / "assets"
83+
ASSETS_RELATIONS_DIR = PARENT.parent / "model" / "assets" / "relations"
8384
CORE_ASSETS_DIR = PARENT.parent / "model" / "assets" / "core"
8485
MODEL_DIR = PARENT.parent / "model"
8586
DOCS_DIR = PARENT.parent / "documentation"
@@ -450,6 +451,25 @@ def create_modules(cls):
450451
cls._CORE_ASSETS.add(related_asset.super_class)
451452

452453

454+
class RelationshipDefInfo:
455+
module_name: str
456+
relationship_def_infos: List["RelationshipDefInfo"] = []
457+
458+
def __init__(self, name: str, relationship_def: RelationshipDef):
459+
self.module_name = to_snake_case(name)
460+
self.relationship_def = relationship_def
461+
462+
@classmethod
463+
def create(cls, relationship_defs: List[RelationshipDef]):
464+
for rel_def in relationship_defs:
465+
if rel_def.attribute_defs:
466+
cls.relationship_def_infos.append(
467+
RelationshipDefInfo(
468+
name=to_snake_case(rel_def.name), relationship_def=rel_def
469+
)
470+
)
471+
472+
453473
class AttributeType(Enum):
454474
PRIMITIVE = "PRIMITIVE"
455475
ENUM = "ENUM"
@@ -664,6 +684,7 @@ def __init__(self) -> None:
664684
)
665685
self.environment.filters["to_snake_case"] = to_snake_case
666686
self.environment.filters["get_type"] = get_type
687+
self.environment.filters["to_cls_name"] = to_python_class_name
667688
self.environment.filters["get_search_type"] = get_search_type
668689
self.environment.filters["get_mapped_type"] = get_mapped_type
669690
self.environment.filters["get_class_var_for_attr"] = get_class_var_for_attr
@@ -719,6 +740,21 @@ def render_module(self, asset_info: AssetInfo, enum_defs: List["EnumDefInfo"]):
719740
with (ASSETS_DIR / f"{asset_info.module_name}.py").open("w") as script:
720741
script.write(content)
721742

743+
def render_custom_relationship_module(
744+
self, relationship_def_info: RelationshipDefInfo
745+
):
746+
template = self.environment.get_template("custom_relationship.jinja2")
747+
content = template.render(
748+
{
749+
"relationship_info": relationship_info.relationship_def,
750+
"templates_path": TEMPLATES_DIR.absolute().as_posix(),
751+
}
752+
)
753+
with (ASSETS_RELATIONS_DIR / f"{relationship_def_info.module_name}.py").open(
754+
"w"
755+
) as script:
756+
script.write(content)
757+
722758
def render_core_module(self, asset_info: AssetInfo, enum_defs: List["EnumDefInfo"]):
723759
template = self.environment.get_template("module.jinja2")
724760
content = template.render(
@@ -769,6 +805,22 @@ def render_mypy_init(self, assets: List[AssetInfo]):
769805
with init_path.open("w") as script:
770806
script.write(content)
771807

808+
def render_relations_init(self, relation_def_infos: List[RelationshipDefInfo]):
809+
template = self.environment.get_template("relations_init.jinja2")
810+
content = template.render({"relation_def_infos": relation_def_infos})
811+
812+
init_path = ASSETS_RELATIONS_DIR / "__init__.py"
813+
with init_path.open("w") as script:
814+
script.write(content)
815+
816+
def render_relations_mypy_init(self, relation_def_infos: List[RelationshipDefInfo]):
817+
template = self.environment.get_template("relations_mypy_init.jinja2")
818+
content = template.render({"relation_def_infos": relation_def_infos})
819+
820+
init_path = ASSETS_RELATIONS_DIR / "__init__.pyi"
821+
with init_path.open("w") as script:
822+
script.write(content)
823+
772824
def render_structs(self, struct_defs):
773825
template = self.environment.get_template("structs.jinja2")
774826
content = template.render({"struct_defs": struct_defs})
@@ -933,8 +985,10 @@ def filter_attributes_of_custom_entity_type():
933985
filter_attributes_of_custom_entity_type()
934986
AssetInfo.sub_type_names_to_ignore = type_defs.custom_entity_def_names
935987
AssetInfo.set_entity_defs(type_defs.reserved_entity_defs)
988+
RelationshipDefInfo.create(type_defs.relationship_defs)
936989
AssetInfo.update_all_circular_dependencies()
937990
AssetInfo.create_modules()
991+
938992
for file in (ASSETS_DIR).glob("*.py"):
939993
file.unlink()
940994
for file in (CORE_ASSETS_DIR).glob("*.py"):
@@ -944,14 +998,27 @@ def filter_attributes_of_custom_entity_type():
944998
file.unlink()
945999
generator = Generator()
9461000
EnumDefInfo.create(type_defs.enum_defs)
1001+
9471002
for asset_info in ModuleInfo.assets.values():
9481003
if asset_info.is_core_asset or asset_info.name in asset_info._CORE_ASSETS:
9491004
generator.render_core_module(asset_info, EnumDefInfo.enum_def_info)
9501005
else:
9511006
generator.render_module(asset_info, EnumDefInfo.enum_def_info)
1007+
1008+
for file in (ASSETS_RELATIONS_DIR).glob("*.py"):
1009+
# Ignore non-generator files
1010+
if file.name in ("relationship_attributes.py", "indistinct_relationship.py"):
1011+
continue
1012+
file.unlink()
1013+
1014+
for relationship_info in RelationshipDefInfo.relationship_def_infos:
1015+
generator.render_custom_relationship_module(relationship_info)
1016+
9521017
generator.render_init(ModuleInfo.assets.values()) # type: ignore
9531018
generator.render_core_init(ModuleInfo.assets.values()) # type: ignore
9541019
generator.render_mypy_init(ModuleInfo.assets.values()) # type: ignore
1020+
generator.render_relations_init(RelationshipDefInfo.relationship_def_infos) # type: ignore
1021+
generator.render_relations_mypy_init(RelationshipDefInfo.relationship_def_infos) # type: ignore
9551022
generator.render_structs(type_defs.struct_defs)
9561023
generator.render_enums(EnumDefInfo.enum_def_info)
9571024
generator.render_docs_struct_snippets(type_defs.struct_defs)
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Optional
4+
5+
from pydantic.v1 import Field, validator
6+
7+
from pyatlan.model.assets import Asset
8+
from pyatlan.model.assets.relations import RelationshipAttributes
9+
from pyatlan.model.core import AtlanObject
10+
from pyatlan.model.enums import SaveSemantic, AtlasGlossaryTermRelationshipStatus
11+
12+
13+
class {{ relationship_info.name | to_cls_name }}(RelationshipAttributes):
14+
type_name: str = Field(
15+
allow_mutation=False,
16+
default="{{ relationship_info.name }}",
17+
description="{{ relationship_info.description }}",
18+
)
19+
attributes: {{ relationship_info.name | to_cls_name }}.Attributes = Field( # type: ignore[name-defined]
20+
default_factory=lambda: {{ relationship_info.name | to_cls_name }}.Attributes(),
21+
description="Map of attributes in the instance and their values",
22+
)
23+
24+
class Attributes(AtlanObject):
25+
{% for attribute_def in relationship_info.attribute_defs -%}
26+
{{ attribute_def["name"] | to_snake_case }}: Optional[{{ attribute_def["typeName"] | get_type }}] = Field(
27+
default=None,
28+
description="{{ attribute_def["description"] }}",
29+
)
30+
{% endfor %}
31+
32+
def __init__(__pydantic_self__, **data: Any) -> None:
33+
if "attributes" not in data:
34+
data = {"attributes": data}
35+
super().__init__(**data)
36+
__pydantic_self__.__fields_set__.update(["attributes", "type_name"])
37+
38+
class {{ relationship_info.end_def1["name"] | to_cls_name }}(Asset):
39+
type_name: str = Field(
40+
default="{{ relationship_info.name }}",
41+
description="{{ relationship_info.end_def1["description"] or 'Name of the relationship type that defines the relationship.'}}",
42+
)
43+
relationship_type: str = Field(
44+
default="{{ relationship_info.name }}",
45+
description="Fixed typeName for {{ relationship_info.name }}.",
46+
)
47+
relationship_attributes: {{ relationship_info.name | to_cls_name }} = Field(
48+
default=None,
49+
description="Attributes of the {{ relationship_info.name }}.",
50+
)
51+
52+
@validator("type_name")
53+
def validate_type_name(cls, v):
54+
return v
55+
56+
def __init__(__pydantic_self__, **data: Any) -> None:
57+
super().__init__(**data)
58+
__pydantic_self__.__fields_set__.update(["type_name", "relationship_type"])
59+
60+
{% if relationship_info.end_def1 != relationship_info.end_def2 %}
61+
class {{ relationship_info.end_def2["name"] | to_cls_name }}(Asset):
62+
type_name: str = Field(
63+
default="{{ relationship_info.name }}",
64+
description="{{ relationship_info.end_def2["description"] or 'Name of the relationship type that defines the relationship.'}}",
65+
)
66+
relationship_type: str = Field(
67+
default="{{ relationship_info.name }}",
68+
description="Fixed typeName for {{ relationship_info.name }}.",
69+
)
70+
relationship_attributes: {{ relationship_info.name | to_cls_name }} = Field(
71+
default=None,
72+
description="Attributes of the {{ relationship_info.name }}.",
73+
)
74+
75+
@validator("type_name")
76+
def validate_type_name(cls, v):
77+
return v
78+
79+
def __init__(__pydantic_self__, **data: Any) -> None:
80+
super().__init__(**data)
81+
__pydantic_self__.__fields_set__.update(["type_name", "relationship_type"])
82+
{% endif %}
83+
84+
def {{ relationship_info.end_def1["name"] | to_snake_case }}(
85+
self, related: Asset, semantic: SaveSemantic = SaveSemantic.REPLACE
86+
) -> {{ relationship_info.name | to_cls_name }}.{{ relationship_info.end_def1["name"] | to_cls_name }}:
87+
"""
88+
Build the {{ relationship_info.name | to_cls_name }} relationship (with attributes) into a related object.
89+
90+
:param: related asset to which to build the detailed relationship
91+
:param: semantic to use for saving the relationship
92+
:returns: a detailed Atlan relationship that conforms
93+
to the necessary interface for a related asset
94+
"""
95+
if related.guid:
96+
return {{ relationship_info.name | to_cls_name }}.{{ relationship_info.end_def1["name"] | to_cls_name }}._create_ref(
97+
type_name=related.type_name,
98+
guid=related.guid,
99+
semantic=semantic,
100+
relationship_attributes=self,
101+
)
102+
103+
# If the related asset does not have a GUID, we use qualifiedName
104+
return {{ relationship_info.name | to_cls_name }}.{{ relationship_info.end_def1["name"] | to_cls_name }}._create_ref(
105+
type_name=related.type_name,
106+
unique_attributes={"qualifiedName": related.qualified_name},
107+
semantic=semantic,
108+
relationship_attributes=self,
109+
)
110+
111+
{% if relationship_info.end_def1 != relationship_info.end_def2 %}
112+
def {{ relationship_info.end_def2["name"] | to_snake_case }}(
113+
self, related: Asset, semantic: SaveSemantic = SaveSemantic.REPLACE
114+
) -> {{ relationship_info.name | to_cls_name }}.{{ relationship_info.end_def2["name"] | to_cls_name }}:
115+
"""
116+
Build the {{ relationship_info.name | to_cls_name }} relationship (with attributes) into a related object.
117+
118+
:param: related asset to which to build the detailed relationship
119+
:param: semantic to use for saving the relationship
120+
:returns: a detailed Atlan relationship that conforms
121+
to the necessary interface for a related asset
122+
"""
123+
if related.guid:
124+
return {{ relationship_info.name | to_cls_name }}.{{ relationship_info.end_def2["name"] | to_cls_name }}._create_ref(
125+
type_name=related.type_name,
126+
guid=related.guid,
127+
semantic=semantic,
128+
relationship_attributes=self,
129+
)
130+
131+
# If the related asset does not have a GUID, we use qualifiedName
132+
return {{ relationship_info.name | to_cls_name }}.{{ relationship_info.end_def2["name"] | to_cls_name }}._create_ref(
133+
type_name=related.type_name,
134+
unique_attributes={"qualifiedName": related.qualified_name},
135+
semantic=semantic,
136+
relationship_attributes=self,
137+
)
138+
{% endif %}
139+
140+
141+
{{ relationship_info.name | to_cls_name }}.{{ relationship_info.end_def1["name"] | to_cls_name }}.update_forward_refs()
142+
{% if relationship_info.end_def1 != relationship_info.end_def2 -%}
143+
{{ relationship_info.name | to_cls_name }}.{{ relationship_info.end_def2["name"] | to_cls_name }}.update_forward_refs()
144+
{% endif -%}
145+
{{ relationship_info.name | to_cls_name }}.update_forward_refs()

pyatlan/generator/templates/imports.jinja2

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,4 @@ from pyatlan.model.data_mesh import DataProductsAssetsDSL
127127
from pyatlan.model.contract import DataContractSpec
128128
from pyatlan.model.lineage_ref import LineageRef
129129
from pyatlan.model.utils import construct_object_key
130+
from pyatlan.model.assets.relations import RelationshipAttributes

pyatlan/generator/templates/referenceable_attributes.jinja2

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
{%- set type = attribute_def.typeName | get_type %}
1010
{{attribute_def.name | to_snake_case }}: {% if attribute_def.isOptional %}Optional[{% endif %}{{type}}{% if attribute_def.isOptional %}]{% endif %} = Field({% if attribute_def.isOptional %}default=None,{% endif %} description='') # relationship
1111
{%- endfor %}
12+
relationship_attributes: Optional[Union[RelationshipAttributes, Dict[str, Any]]] = Field(
13+
default=None,
14+
description="Map of relationships for the entity. The specific keys of this map will vary by type, "
15+
"so are described in the sub-types of this schema.",
16+
)
1217

1318
def validate_required(self):
1419
pass
@@ -122,10 +127,10 @@
122127
)
123128
is_incomplete: Optional[bool] = Field(default=None, description="", example=True)
124129
labels: Optional[List[str]] = Field(default=None, description='Arbitrary textual labels for the asset.')
125-
relationship_attributes: Optional[Dict[str, Any]] = Field(
130+
relationship_attributes: Optional[Union[RelationshipAttributes, Dict[str, Any]]] = Field(
126131
default=None,
127-
description='Map of relationships for the entity. The specific keys of this map will vary by type, '
128-
'so are described in the sub-types of this schema.',
132+
description="Map of relationships for the entity. The specific keys of this map will vary by type, "
133+
"so are described in the sub-types of this schema.",
129134
)
130135
status: Optional[EntityStatus] = Field(
131136
default=None,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright 2025 Atlan Pte. Ltd.
2+
# isort: skip_file
3+
import lazy_loader as lazy
4+
5+
__PYATLAN_ASSET_RELATIONS__ = {
6+
"relationship_attributes": ["RelationshipAttributes"],
7+
"indistinct_relationship": ["IndistinctRelationship"],
8+
{% for relation in relation_def_infos -%}
9+
"{{ relation.relationship_def.name | to_snake_case }}": ["{{ relation.relationship_def.name | to_cls_name}}"],
10+
{% endfor %}
11+
}
12+
13+
lazy_loader = lazy.attach(__name__, submod_attrs=__PYATLAN_ASSET_RELATIONS__)
14+
__getattr__, __dir__, __all__ = lazy_loader
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2025 Atlan Pte. Ltd.
2+
3+
__all__ = [
4+
"RelationshipAttributes",
5+
"IndistinctRelationship",
6+
{% for relation in relation_def_infos -%}
7+
"{{ relation.relationship_def.name | to_cls_name}}",
8+
{% endfor %}
9+
]
10+
11+
from .relationship_attributes import RelationshipAttributes
12+
from .indistinct_relationship import IndistinctRelationship
13+
{% for relation in relation_def_infos -%}
14+
from .{{ relation.relationship_def.name | to_snake_case }} import {{ relation.relationship_def.name | to_cls_name }}
15+
{% endfor %}

pyatlan/generator/templates/structs.jinja2

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ from pyatlan.model.enums import (
1313
BadgeComparisonOperator,
1414
BadgeConditionColor,
1515
SourceCostUnitType,
16-
alpha_DQRuleThresholdUnit,
1716
FormFieldDimension,
1817
FormFieldType
1918
)

0 commit comments

Comments
 (0)