diff --git a/pyatlan_v9/model/assets/_overlays/tableau_calculated_field.py b/pyatlan_v9/model/assets/_overlays/tableau_calculated_field.py new file mode 100644 index 000000000..024c44b24 --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/tableau_calculated_field.py @@ -0,0 +1,69 @@ +# STDLIB_IMPORT: from typing import Optional +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + datasource_qualified_name: str, + field_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauCalculatedField": + """ + Builds a field in a published datasource + (default/tableau////) or in an + embedded one (...///). + """ + validate_required_fields( + ["name", "datasource_qualified_name", "field_id"], + [name, datasource_qualified_name, field_id], + ) + fields = datasource_qualified_name.split("/") + if len(fields) not in (6, 7): + raise ValueError( + f"Invalid datasource_qualified_name: {datasource_qualified_name}. " + "Expected format: " + "default/tableau//// " + "(published) or " + "default/tableau///// " + "(embedded)" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + field = cls( + name=name, + qualified_name=f"{datasource_qualified_name}/{field_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qn, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + datasource_qualified_name=datasource_qualified_name, + datasource=RelatedTableauDatasource( + qualified_name=datasource_qualified_name + ), + ) + if len(fields) == 7: + field.workbook_qualified_name = "/".join(fields[:6]) + return field + + @classmethod + def create(cls, **kwargs) -> "TableauCalculatedField": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauCalculatedField": + return cls.updater(**kwargs) diff --git a/pyatlan_v9/model/assets/_overlays/tableau_dashboard.py b/pyatlan_v9/model/assets/_overlays/tableau_dashboard.py new file mode 100644 index 000000000..63c27df5f --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/tableau_dashboard.py @@ -0,0 +1,56 @@ +# STDLIB_IMPORT: from typing import Optional +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + workbook_qualified_name: str, + dashboard_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauDashboard": + validate_required_fields( + ["name", "workbook_qualified_name", "dashboard_id"], + [name, workbook_qualified_name, dashboard_id], + ) + fields = workbook_qualified_name.split("/") + if len(fields) != 6: + raise ValueError( + f"Invalid workbook_qualified_name: {workbook_qualified_name}. " + "Expected format: " + "default/tableau////" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + return cls( + name=name, + qualified_name=f"{workbook_qualified_name}/{dashboard_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qn, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + workbook_qualified_name=workbook_qualified_name, + workbook=RelatedTableauWorkbook(qualified_name=workbook_qualified_name), + ) + + @classmethod + def create(cls, **kwargs) -> "TableauDashboard": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauDashboard": + return cls.updater(**kwargs) diff --git a/pyatlan_v9/model/assets/_overlays/tableau_dashboard_field.py b/pyatlan_v9/model/assets/_overlays/tableau_dashboard_field.py new file mode 100644 index 000000000..157b2156e --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/tableau_dashboard_field.py @@ -0,0 +1,61 @@ +# STDLIB_IMPORT: from typing import Optional +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + dashboard_qualified_name: str, + field_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauDashboardField": + """ + field_id is the id of the datasource field this dashboard field uses. + """ + validate_required_fields( + ["name", "dashboard_qualified_name", "field_id"], + [name, dashboard_qualified_name, field_id], + ) + fields = dashboard_qualified_name.split("/") + if len(fields) != 7: + raise ValueError( + f"Invalid dashboard_qualified_name: {dashboard_qualified_name}. " + "Expected format: default/tableau////" + "/" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + return cls( + name=name, + qualified_name=f"{dashboard_qualified_name}/{field_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + tableau_site_qualified_name=site_qn, + tableau_project_qualified_name=project_qn, + tableau_top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + tableau_dashboard_qualified_name=dashboard_qualified_name, + tableau_dashboard=RelatedTableauDashboard( + qualified_name=dashboard_qualified_name + ), + ) + + @classmethod + def create(cls, **kwargs) -> "TableauDashboardField": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauDashboardField": + return cls.updater(**kwargs) diff --git a/pyatlan_v9/model/assets/_overlays/tableau_datasource.py b/pyatlan_v9/model/assets/_overlays/tableau_datasource.py new file mode 100644 index 000000000..d4027985b --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/tableau_datasource.py @@ -0,0 +1,73 @@ +# STDLIB_IMPORT: from typing import Optional +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + project_qualified_name: str, + datasource_id: str, + workbook_qualified_name: Optional[str] = None, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauDatasource": + """ + Builds a published datasource, or an embedded one when + workbook_qualified_name is given (its qualifiedName then sits under the workbook). + """ + validate_required_fields( + ["name", "project_qualified_name", "datasource_id"], + [name, project_qualified_name, datasource_id], + ) + fields = project_qualified_name.split("/") + if len(fields) != 5: + raise ValueError( + f"Invalid project_qualified_name: {project_qualified_name}. " + "Expected format: default/tableau///" + ) + if workbook_qualified_name is not None and ( + len(workbook_qualified_name.split("/")) != 6 + or not workbook_qualified_name.startswith(f"{project_qualified_name}/") + ): + raise ValueError( + f"Invalid workbook_qualified_name: {workbook_qualified_name}. " + f"Expected format: {project_qualified_name}/" + ) + site_qn = "/".join(fields[:4]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + datasource = cls( + name=name, + qualified_name=f"{workbook_qualified_name or project_qualified_name}/{datasource_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qualified_name, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qualified_name + ), + is_published=workbook_qualified_name is None, + project=RelatedTableauProject(qualified_name=project_qualified_name), + ) + if workbook_qualified_name: + datasource.workbook_qualified_name = workbook_qualified_name + datasource.workbook = RelatedTableauWorkbook( + qualified_name=workbook_qualified_name + ) + return datasource + + @classmethod + def create(cls, **kwargs) -> "TableauDatasource": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauDatasource": + return cls.updater(**kwargs) diff --git a/pyatlan_v9/model/assets/_overlays/tableau_datasource_field.py b/pyatlan_v9/model/assets/_overlays/tableau_datasource_field.py new file mode 100644 index 000000000..20259692c --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/tableau_datasource_field.py @@ -0,0 +1,69 @@ +# STDLIB_IMPORT: from typing import Optional +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + datasource_qualified_name: str, + field_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauDatasourceField": + """ + Builds a field in a published datasource + (default/tableau////) or in an + embedded one (...///). + """ + validate_required_fields( + ["name", "datasource_qualified_name", "field_id"], + [name, datasource_qualified_name, field_id], + ) + fields = datasource_qualified_name.split("/") + if len(fields) not in (6, 7): + raise ValueError( + f"Invalid datasource_qualified_name: {datasource_qualified_name}. " + "Expected format: " + "default/tableau//// " + "(published) or " + "default/tableau///// " + "(embedded)" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + field = cls( + name=name, + qualified_name=f"{datasource_qualified_name}/{field_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qn, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + datasource_qualified_name=datasource_qualified_name, + datasource=RelatedTableauDatasource( + qualified_name=datasource_qualified_name + ), + ) + if len(fields) == 7: + field.workbook_qualified_name = "/".join(fields[:6]) + return field + + @classmethod + def create(cls, **kwargs) -> "TableauDatasourceField": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauDatasourceField": + return cls.updater(**kwargs) diff --git a/pyatlan_v9/model/assets/_overlays/tableau_project.py b/pyatlan_v9/model/assets/_overlays/tableau_project.py new file mode 100644 index 000000000..74f692ff7 --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/tableau_project.py @@ -0,0 +1,67 @@ +# STDLIB_IMPORT: from typing import Optional +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + site_qualified_name: str, + project_id: str, + parent_project_qualified_name: Optional[str] = None, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauProject": + validate_required_fields( + ["name", "site_qualified_name", "project_id"], + [name, site_qualified_name, project_id], + ) + fields = site_qualified_name.split("/") + if len(fields) != 4: + raise ValueError( + f"Invalid site_qualified_name: {site_qualified_name}. " + "Expected format: default/tableau//" + ) + # Tableau project qualifiedNames are flat under the site, even when nested: + # the nesting is carried only by parentProject and the project hierarchy. + for label, project_qn in ( + ("parent_project_qualified_name", parent_project_qualified_name), + ("top_level_project_qualified_name", top_level_project_qualified_name), + ): + if project_qn is not None and ( + len(project_qn.split("/")) != 5 + or not project_qn.startswith(f"{site_qualified_name}/") + ): + raise ValueError( + f"Invalid {label}: {project_qn}. " + f"Expected format: {site_qualified_name}/" + ) + if top_level_project_qualified_name and not parent_project_qualified_name: + raise ValueError( + "top_level_project_qualified_name requires parent_project_qualified_name" + ) + project = cls( + name=name, + qualified_name=f"{site_qualified_name}/{project_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qualified_name, + is_top_level_project=parent_project_qualified_name is None, + site=RelatedTableauSite(qualified_name=site_qualified_name), + ) + if parent_project_qualified_name: + project.top_level_project_qualified_name = ( + top_level_project_qualified_name or parent_project_qualified_name + ) + project.parent_project = RelatedTableauProject( + qualified_name=parent_project_qualified_name + ) + return project + + @classmethod + def create(cls, **kwargs) -> "TableauProject": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauProject": + return cls.updater(**kwargs) diff --git a/pyatlan_v9/model/assets/_overlays/tableau_site.py b/pyatlan_v9/model/assets/_overlays/tableau_site.py new file mode 100644 index 000000000..6b9e926af --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/tableau_site.py @@ -0,0 +1,35 @@ +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + connection_qualified_name: str, + site_id: str, + ) -> "TableauSite": + validate_required_fields( + ["name", "connection_qualified_name", "site_id"], + [name, connection_qualified_name, site_id], + ) + fields = connection_qualified_name.split("/") + if len(fields) != 3: + raise ValueError( + f"Invalid connection_qualified_name: {connection_qualified_name}. " + "Expected format: default/tableau/" + ) + return cls( + name=name, + qualified_name=f"{connection_qualified_name}/{site_id}", + connection_qualified_name=connection_qualified_name, + connector_name=fields[1], + ) + + @classmethod + def create(cls, **kwargs) -> "TableauSite": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauSite": + return cls.updater(**kwargs) diff --git a/pyatlan_v9/model/assets/_overlays/tableau_workbook.py b/pyatlan_v9/model/assets/_overlays/tableau_workbook.py new file mode 100644 index 000000000..fad8e943a --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/tableau_workbook.py @@ -0,0 +1,53 @@ +# STDLIB_IMPORT: from typing import Optional +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + project_qualified_name: str, + workbook_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauWorkbook": + validate_required_fields( + ["name", "project_qualified_name", "workbook_id"], + [name, project_qualified_name, workbook_id], + ) + fields = project_qualified_name.split("/") + if len(fields) != 5: + raise ValueError( + f"Invalid project_qualified_name: {project_qualified_name}. " + "Expected format: default/tableau///" + ) + site_qn = "/".join(fields[:4]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + return cls( + name=name, + qualified_name=f"{project_qualified_name}/{workbook_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qualified_name, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qualified_name + ), + project=RelatedTableauProject(qualified_name=project_qualified_name), + ) + + @classmethod + def create(cls, **kwargs) -> "TableauWorkbook": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauWorkbook": + return cls.updater(**kwargs) diff --git a/pyatlan_v9/model/assets/_overlays/tableau_worksheet.py b/pyatlan_v9/model/assets/_overlays/tableau_worksheet.py new file mode 100644 index 000000000..d465bbc36 --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/tableau_worksheet.py @@ -0,0 +1,56 @@ +# STDLIB_IMPORT: from typing import Optional +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + workbook_qualified_name: str, + worksheet_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauWorksheet": + validate_required_fields( + ["name", "workbook_qualified_name", "worksheet_id"], + [name, workbook_qualified_name, worksheet_id], + ) + fields = workbook_qualified_name.split("/") + if len(fields) != 6: + raise ValueError( + f"Invalid workbook_qualified_name: {workbook_qualified_name}. " + "Expected format: " + "default/tableau////" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + return cls( + name=name, + qualified_name=f"{workbook_qualified_name}/{worksheet_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qn, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + workbook_qualified_name=workbook_qualified_name, + workbook=RelatedTableauWorkbook(qualified_name=workbook_qualified_name), + ) + + @classmethod + def create(cls, **kwargs) -> "TableauWorksheet": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauWorksheet": + return cls.updater(**kwargs) diff --git a/pyatlan_v9/model/assets/_overlays/tableau_worksheet_field.py b/pyatlan_v9/model/assets/_overlays/tableau_worksheet_field.py new file mode 100644 index 000000000..46cdf56af --- /dev/null +++ b/pyatlan_v9/model/assets/_overlays/tableau_worksheet_field.py @@ -0,0 +1,62 @@ +# STDLIB_IMPORT: from typing import Optional +# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields + + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + worksheet_qualified_name: str, + field_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauWorksheetField": + """ + field_id is the id of the datasource or calculated field this worksheet field uses. + """ + validate_required_fields( + ["name", "worksheet_qualified_name", "field_id"], + [name, worksheet_qualified_name, field_id], + ) + fields = worksheet_qualified_name.split("/") + if len(fields) != 7: + raise ValueError( + f"Invalid worksheet_qualified_name: {worksheet_qualified_name}. " + "Expected format: default/tableau////" + "/" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + return cls( + name=name, + qualified_name=f"{worksheet_qualified_name}/{field_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + tableau_site_qualified_name=site_qn, + tableau_project_qualified_name=project_qn, + tableau_top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + tableau_workbook_qualified_name="/".join(fields[:6]), + tableau_worksheet_qualified_name=worksheet_qualified_name, + tableau_worksheet=RelatedTableauWorksheet( + qualified_name=worksheet_qualified_name + ), + ) + + @classmethod + def create(cls, **kwargs) -> "TableauWorksheetField": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauWorksheetField": + return cls.updater(**kwargs) diff --git a/pyatlan_v9/model/assets/tableau_calculated_field.py b/pyatlan_v9/model/assets/tableau_calculated_field.py index 10a4d8d9e..cc11ce2e1 100644 --- a/pyatlan_v9/model/assets/tableau_calculated_field.py +++ b/pyatlan_v9/model/assets/tableau_calculated_field.py @@ -15,7 +15,7 @@ from __future__ import annotations import re -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union from msgspec import UNSET, UnsetType @@ -25,6 +25,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 @@ -370,6 +371,73 @@ def relate(self) -> "RelatedTableauCalculatedField": return RelatedTableauCalculatedField(guid=self.guid) return RelatedTableauCalculatedField(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + datasource_qualified_name: str, + field_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauCalculatedField": + """ + Builds a field in a published datasource + (default/tableau////) or in an + embedded one (...///). + """ + validate_required_fields( + ["name", "datasource_qualified_name", "field_id"], + [name, datasource_qualified_name, field_id], + ) + fields = datasource_qualified_name.split("/") + if len(fields) not in (6, 7): + raise ValueError( + f"Invalid datasource_qualified_name: {datasource_qualified_name}. " + "Expected format: " + "default/tableau//// " + "(published) or " + "default/tableau///// " + "(embedded)" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + field = cls( + name=name, + qualified_name=f"{datasource_qualified_name}/{field_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qn, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + datasource_qualified_name=datasource_qualified_name, + datasource=RelatedTableauDatasource( + qualified_name=datasource_qualified_name + ), + ) + if len(fields) == 7: + field.workbook_qualified_name = "/".join(fields[:6]) + return field + + @classmethod + def create(cls, **kwargs) -> "TableauCalculatedField": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauCalculatedField": + return cls.updater(**kwargs) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/pyatlan_v9/model/assets/tableau_dashboard.py b/pyatlan_v9/model/assets/tableau_dashboard.py index df33c4d03..53a9daa24 100644 --- a/pyatlan_v9/model/assets/tableau_dashboard.py +++ b/pyatlan_v9/model/assets/tableau_dashboard.py @@ -15,7 +15,7 @@ from __future__ import annotations import re -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union from msgspec import UNSET, UnsetType @@ -25,6 +25,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 @@ -356,6 +357,60 @@ def relate(self) -> "RelatedTableauDashboard": return RelatedTableauDashboard(guid=self.guid) return RelatedTableauDashboard(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + workbook_qualified_name: str, + dashboard_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauDashboard": + validate_required_fields( + ["name", "workbook_qualified_name", "dashboard_id"], + [name, workbook_qualified_name, dashboard_id], + ) + fields = workbook_qualified_name.split("/") + if len(fields) != 6: + raise ValueError( + f"Invalid workbook_qualified_name: {workbook_qualified_name}. " + "Expected format: " + "default/tableau////" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + return cls( + name=name, + qualified_name=f"{workbook_qualified_name}/{dashboard_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qn, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + workbook_qualified_name=workbook_qualified_name, + workbook=RelatedTableauWorkbook(qualified_name=workbook_qualified_name), + ) + + @classmethod + def create(cls, **kwargs) -> "TableauDashboard": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauDashboard": + return cls.updater(**kwargs) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/pyatlan_v9/model/assets/tableau_dashboard_field.py b/pyatlan_v9/model/assets/tableau_dashboard_field.py index 475031169..04897bb93 100644 --- a/pyatlan_v9/model/assets/tableau_dashboard_field.py +++ b/pyatlan_v9/model/assets/tableau_dashboard_field.py @@ -15,7 +15,7 @@ from __future__ import annotations import re -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union from msgspec import UNSET, UnsetType @@ -25,6 +25,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 @@ -385,6 +386,65 @@ def relate(self) -> "RelatedTableauDashboardField": return RelatedTableauDashboardField(guid=self.guid) return RelatedTableauDashboardField(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + dashboard_qualified_name: str, + field_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauDashboardField": + """ + field_id is the id of the datasource field this dashboard field uses. + """ + validate_required_fields( + ["name", "dashboard_qualified_name", "field_id"], + [name, dashboard_qualified_name, field_id], + ) + fields = dashboard_qualified_name.split("/") + if len(fields) != 7: + raise ValueError( + f"Invalid dashboard_qualified_name: {dashboard_qualified_name}. " + "Expected format: default/tableau////" + "/" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + return cls( + name=name, + qualified_name=f"{dashboard_qualified_name}/{field_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + tableau_site_qualified_name=site_qn, + tableau_project_qualified_name=project_qn, + tableau_top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + tableau_dashboard_qualified_name=dashboard_qualified_name, + tableau_dashboard=RelatedTableauDashboard( + qualified_name=dashboard_qualified_name + ), + ) + + @classmethod + def create(cls, **kwargs) -> "TableauDashboardField": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauDashboardField": + return cls.updater(**kwargs) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/pyatlan_v9/model/assets/tableau_datasource.py b/pyatlan_v9/model/assets/tableau_datasource.py index 05c141004..7c4b07831 100644 --- a/pyatlan_v9/model/assets/tableau_datasource.py +++ b/pyatlan_v9/model/assets/tableau_datasource.py @@ -15,7 +15,7 @@ from __future__ import annotations import re -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union from msgspec import UNSET, UnsetType @@ -25,6 +25,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 @@ -372,6 +373,77 @@ def relate(self) -> "RelatedTableauDatasource": return RelatedTableauDatasource(guid=self.guid) return RelatedTableauDatasource(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + project_qualified_name: str, + datasource_id: str, + workbook_qualified_name: Optional[str] = None, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauDatasource": + """ + Builds a published datasource, or an embedded one when + workbook_qualified_name is given (its qualifiedName then sits under the workbook). + """ + validate_required_fields( + ["name", "project_qualified_name", "datasource_id"], + [name, project_qualified_name, datasource_id], + ) + fields = project_qualified_name.split("/") + if len(fields) != 5: + raise ValueError( + f"Invalid project_qualified_name: {project_qualified_name}. " + "Expected format: default/tableau///" + ) + if workbook_qualified_name is not None and ( + len(workbook_qualified_name.split("/")) != 6 + or not workbook_qualified_name.startswith(f"{project_qualified_name}/") + ): + raise ValueError( + f"Invalid workbook_qualified_name: {workbook_qualified_name}. " + f"Expected format: {project_qualified_name}/" + ) + site_qn = "/".join(fields[:4]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + datasource = cls( + name=name, + qualified_name=f"{workbook_qualified_name or project_qualified_name}/{datasource_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qualified_name, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qualified_name + ), + is_published=workbook_qualified_name is None, + project=RelatedTableauProject(qualified_name=project_qualified_name), + ) + if workbook_qualified_name: + datasource.workbook_qualified_name = workbook_qualified_name + datasource.workbook = RelatedTableauWorkbook( + qualified_name=workbook_qualified_name + ) + return datasource + + @classmethod + def create(cls, **kwargs) -> "TableauDatasource": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauDatasource": + return cls.updater(**kwargs) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/pyatlan_v9/model/assets/tableau_datasource_field.py b/pyatlan_v9/model/assets/tableau_datasource_field.py index 382494170..8edc9a610 100644 --- a/pyatlan_v9/model/assets/tableau_datasource_field.py +++ b/pyatlan_v9/model/assets/tableau_datasource_field.py @@ -15,7 +15,7 @@ from __future__ import annotations import re -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union from msgspec import UNSET, UnsetType @@ -25,6 +25,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 @@ -390,6 +391,73 @@ def relate(self) -> "RelatedTableauDatasourceField": return RelatedTableauDatasourceField(guid=self.guid) return RelatedTableauDatasourceField(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + datasource_qualified_name: str, + field_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauDatasourceField": + """ + Builds a field in a published datasource + (default/tableau////) or in an + embedded one (...///). + """ + validate_required_fields( + ["name", "datasource_qualified_name", "field_id"], + [name, datasource_qualified_name, field_id], + ) + fields = datasource_qualified_name.split("/") + if len(fields) not in (6, 7): + raise ValueError( + f"Invalid datasource_qualified_name: {datasource_qualified_name}. " + "Expected format: " + "default/tableau//// " + "(published) or " + "default/tableau///// " + "(embedded)" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + field = cls( + name=name, + qualified_name=f"{datasource_qualified_name}/{field_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qn, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + datasource_qualified_name=datasource_qualified_name, + datasource=RelatedTableauDatasource( + qualified_name=datasource_qualified_name + ), + ) + if len(fields) == 7: + field.workbook_qualified_name = "/".join(fields[:6]) + return field + + @classmethod + def create(cls, **kwargs) -> "TableauDatasourceField": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauDatasourceField": + return cls.updater(**kwargs) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/pyatlan_v9/model/assets/tableau_project.py b/pyatlan_v9/model/assets/tableau_project.py index a514acc85..836783f42 100644 --- a/pyatlan_v9/model/assets/tableau_project.py +++ b/pyatlan_v9/model/assets/tableau_project.py @@ -15,7 +15,7 @@ from __future__ import annotations import re -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union from msgspec import UNSET, UnsetType @@ -25,6 +25,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 @@ -345,6 +346,71 @@ def relate(self) -> "RelatedTableauProject": return RelatedTableauProject(guid=self.guid) return RelatedTableauProject(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + site_qualified_name: str, + project_id: str, + parent_project_qualified_name: Optional[str] = None, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauProject": + validate_required_fields( + ["name", "site_qualified_name", "project_id"], + [name, site_qualified_name, project_id], + ) + fields = site_qualified_name.split("/") + if len(fields) != 4: + raise ValueError( + f"Invalid site_qualified_name: {site_qualified_name}. " + "Expected format: default/tableau//" + ) + # Tableau project qualifiedNames are flat under the site, even when nested: + # the nesting is carried only by parentProject and the project hierarchy. + for label, project_qn in ( + ("parent_project_qualified_name", parent_project_qualified_name), + ("top_level_project_qualified_name", top_level_project_qualified_name), + ): + if project_qn is not None and ( + len(project_qn.split("/")) != 5 + or not project_qn.startswith(f"{site_qualified_name}/") + ): + raise ValueError( + f"Invalid {label}: {project_qn}. " + f"Expected format: {site_qualified_name}/" + ) + if top_level_project_qualified_name and not parent_project_qualified_name: + raise ValueError( + "top_level_project_qualified_name requires parent_project_qualified_name" + ) + project = cls( + name=name, + qualified_name=f"{site_qualified_name}/{project_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qualified_name, + is_top_level_project=parent_project_qualified_name is None, + site=RelatedTableauSite(qualified_name=site_qualified_name), + ) + if parent_project_qualified_name: + project.top_level_project_qualified_name = ( + top_level_project_qualified_name or parent_project_qualified_name + ) + project.parent_project = RelatedTableauProject( + qualified_name=parent_project_qualified_name + ) + return project + + @classmethod + def create(cls, **kwargs) -> "TableauProject": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauProject": + return cls.updater(**kwargs) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/pyatlan_v9/model/assets/tableau_site.py b/pyatlan_v9/model/assets/tableau_site.py index 85e8f08f8..945b798e3 100644 --- a/pyatlan_v9/model/assets/tableau_site.py +++ b/pyatlan_v9/model/assets/tableau_site.py @@ -14,6 +14,7 @@ from __future__ import annotations +import re from typing import Any, ClassVar, Dict, List, Union from msgspec import UNSET, UnsetType @@ -24,6 +25,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 @@ -232,6 +234,8 @@ def __post_init__(self) -> None: # SDK Methods # ========================================================================= + _QUALIFIED_NAME_PATTERN: ClassVar[re.Pattern] = re.compile(r"^.+/[^/]+$") + def validate(self, for_creation: bool = False) -> None: """ Dry-run validation of this TableauSite instance. @@ -256,6 +260,11 @@ def validate(self, for_creation: bool = False) -> None: errors.append("name is required") if self.qualified_name is UNSET or self.qualified_name is None: errors.append("qualified_name is required") + elif not self._QUALIFIED_NAME_PATTERN.match(self.qualified_name): + errors.append( + f"qualified_name '{self.qualified_name}' does not match expected " + f"pattern: {self._QUALIFIED_NAME_PATTERN.pattern}" + ) if errors: raise ValueError(f"TableauSite validation failed: {errors}") @@ -288,6 +297,40 @@ def relate(self) -> "RelatedTableauSite": return RelatedTableauSite(guid=self.guid) return RelatedTableauSite(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + connection_qualified_name: str, + site_id: str, + ) -> "TableauSite": + validate_required_fields( + ["name", "connection_qualified_name", "site_id"], + [name, connection_qualified_name, site_id], + ) + fields = connection_qualified_name.split("/") + if len(fields) != 3: + raise ValueError( + f"Invalid connection_qualified_name: {connection_qualified_name}. " + "Expected format: default/tableau/" + ) + return cls( + name=name, + qualified_name=f"{connection_qualified_name}/{site_id}", + connection_qualified_name=connection_qualified_name, + connector_name=fields[1], + ) + + @classmethod + def create(cls, **kwargs) -> "TableauSite": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauSite": + return cls.updater(**kwargs) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/pyatlan_v9/model/assets/tableau_workbook.py b/pyatlan_v9/model/assets/tableau_workbook.py index 899d35922..ea9415256 100644 --- a/pyatlan_v9/model/assets/tableau_workbook.py +++ b/pyatlan_v9/model/assets/tableau_workbook.py @@ -15,7 +15,7 @@ from __future__ import annotations import re -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union from msgspec import UNSET, UnsetType @@ -25,6 +25,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 @@ -345,6 +346,57 @@ def relate(self) -> "RelatedTableauWorkbook": return RelatedTableauWorkbook(guid=self.guid) return RelatedTableauWorkbook(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + project_qualified_name: str, + workbook_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauWorkbook": + validate_required_fields( + ["name", "project_qualified_name", "workbook_id"], + [name, project_qualified_name, workbook_id], + ) + fields = project_qualified_name.split("/") + if len(fields) != 5: + raise ValueError( + f"Invalid project_qualified_name: {project_qualified_name}. " + "Expected format: default/tableau///" + ) + site_qn = "/".join(fields[:4]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + return cls( + name=name, + qualified_name=f"{project_qualified_name}/{workbook_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qualified_name, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qualified_name + ), + project=RelatedTableauProject(qualified_name=project_qualified_name), + ) + + @classmethod + def create(cls, **kwargs) -> "TableauWorkbook": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauWorkbook": + return cls.updater(**kwargs) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/pyatlan_v9/model/assets/tableau_worksheet.py b/pyatlan_v9/model/assets/tableau_worksheet.py index be2aab27d..1f74a7faf 100644 --- a/pyatlan_v9/model/assets/tableau_worksheet.py +++ b/pyatlan_v9/model/assets/tableau_worksheet.py @@ -15,7 +15,7 @@ from __future__ import annotations import re -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union from msgspec import UNSET, UnsetType @@ -25,6 +25,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 @@ -358,6 +359,60 @@ def relate(self) -> "RelatedTableauWorksheet": return RelatedTableauWorksheet(guid=self.guid) return RelatedTableauWorksheet(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + workbook_qualified_name: str, + worksheet_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauWorksheet": + validate_required_fields( + ["name", "workbook_qualified_name", "worksheet_id"], + [name, workbook_qualified_name, worksheet_id], + ) + fields = workbook_qualified_name.split("/") + if len(fields) != 6: + raise ValueError( + f"Invalid workbook_qualified_name: {workbook_qualified_name}. " + "Expected format: " + "default/tableau////" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + return cls( + name=name, + qualified_name=f"{workbook_qualified_name}/{worksheet_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + site_qualified_name=site_qn, + project_qualified_name=project_qn, + top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + workbook_qualified_name=workbook_qualified_name, + workbook=RelatedTableauWorkbook(qualified_name=workbook_qualified_name), + ) + + @classmethod + def create(cls, **kwargs) -> "TableauWorksheet": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauWorksheet": + return cls.updater(**kwargs) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/pyatlan_v9/model/assets/tableau_worksheet_field.py b/pyatlan_v9/model/assets/tableau_worksheet_field.py index 09c60d5a0..003ba029d 100644 --- a/pyatlan_v9/model/assets/tableau_worksheet_field.py +++ b/pyatlan_v9/model/assets/tableau_worksheet_field.py @@ -15,7 +15,7 @@ from __future__ import annotations import re -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union from msgspec import UNSET, UnsetType @@ -25,6 +25,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 @@ -409,6 +410,66 @@ def relate(self) -> "RelatedTableauWorksheetField": return RelatedTableauWorksheetField(guid=self.guid) return RelatedTableauWorksheetField(qualified_name=self.qualified_name) + @classmethod + @init_guid + def creator( + cls, + *, + name: str, + worksheet_qualified_name: str, + field_id: str, + top_level_project_qualified_name: Optional[str] = None, + ) -> "TableauWorksheetField": + """ + field_id is the id of the datasource or calculated field this worksheet field uses. + """ + validate_required_fields( + ["name", "worksheet_qualified_name", "field_id"], + [name, worksheet_qualified_name, field_id], + ) + fields = worksheet_qualified_name.split("/") + if len(fields) != 7: + raise ValueError( + f"Invalid worksheet_qualified_name: {worksheet_qualified_name}. " + "Expected format: default/tableau////" + "/" + ) + site_qn = "/".join(fields[:4]) + project_qn = "/".join(fields[:5]) + if top_level_project_qualified_name is not None and ( + len(top_level_project_qualified_name.split("/")) != 5 + or not top_level_project_qualified_name.startswith(f"{site_qn}/") + ): + raise ValueError( + f"Invalid top_level_project_qualified_name: " + f"{top_level_project_qualified_name}. " + f"Expected format: {site_qn}/" + ) + return cls( + name=name, + qualified_name=f"{worksheet_qualified_name}/{field_id}", + connection_qualified_name="/".join(fields[:3]), + connector_name=fields[1], + tableau_site_qualified_name=site_qn, + tableau_project_qualified_name=project_qn, + tableau_top_level_project_qualified_name=( + top_level_project_qualified_name or project_qn + ), + tableau_workbook_qualified_name="/".join(fields[:6]), + tableau_worksheet_qualified_name=worksheet_qualified_name, + tableau_worksheet=RelatedTableauWorksheet( + qualified_name=worksheet_qualified_name + ), + ) + + @classmethod + def create(cls, **kwargs) -> "TableauWorksheetField": + return cls.creator(**kwargs) + + @classmethod + def create_for_modification(cls, **kwargs) -> "TableauWorksheetField": + return cls.updater(**kwargs) + # ========================================================================= # Optimized Serialization Methods (override Asset base class) # ========================================================================= diff --git a/tests_v9/integration/tableau_asset_test.py b/tests_v9/integration/tableau_asset_test.py new file mode 100644 index 000000000..dede732d5 --- /dev/null +++ b/tests_v9/integration/tableau_asset_test.py @@ -0,0 +1,430 @@ +# 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, + TableauCalculatedField, + TableauDashboard, + TableauDashboardField, + TableauDatasource, + TableauDatasourceField, + TableauProject, + TableauSite, + TableauWorkbook, + TableauWorksheet, + TableauWorksheetField, +) +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("TABLEAU") + +SITE_ID = f"site-{MODULE_NAME}" +PROJECT_ID = f"proj-{MODULE_NAME}" +SUB_PROJECT_ID = f"subproj-{MODULE_NAME}" +WORKBOOK_ID = f"wb-{MODULE_NAME}" +PUBLISHED_DS_ID = f"pds-{MODULE_NAME}" +EMBEDDED_DS_ID = f"eds-{MODULE_NAME}" +DASHBOARD_ID = f"dash-{MODULE_NAME}" +WORKSHEET_ID = f"ws-{MODULE_NAME}" +DS_FIELD_ID = f"dsf-{MODULE_NAME}" +CALC_FIELD_ID = f"cf-{MODULE_NAME}" + + +def _save(client: AtlanClient, to_create, asset_type): + return client.asset.save(to_create).assets_created(asset_type=asset_type)[0] + + +@pytest.fixture(scope="module") +def connection(client: AtlanClient) -> Generator[Connection, None, None]: + result = create_connection( + client=client, name=MODULE_NAME, connector_type=AtlanConnectorType.TABLEAU + ) + yield result + delete_asset(client, guid=result.guid, asset_type=Connection) + + +@pytest.fixture(scope="module") +def site( + client: AtlanClient, connection: Connection +) -> Generator[TableauSite, None, None]: + to_create = TableauSite.creator( + name=f"site_{MODULE_NAME}", + connection_qualified_name=connection.qualified_name, + site_id=SITE_ID, + ) + result = _save(client, to_create, TableauSite) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauSite) + + +@pytest.fixture(scope="module") +def project( + client: AtlanClient, site: TableauSite +) -> Generator[TableauProject, None, None]: + to_create = TableauProject.creator( + name=f"project_{MODULE_NAME}", + site_qualified_name=site.qualified_name, + project_id=PROJECT_ID, + ) + result = _save(client, to_create, TableauProject) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauProject) + + +@pytest.fixture(scope="module") +def sub_project( + client: AtlanClient, site: TableauSite, project: TableauProject +) -> Generator[TableauProject, None, None]: + to_create = TableauProject.creator( + name=f"sub_project_{MODULE_NAME}", + site_qualified_name=site.qualified_name, + project_id=SUB_PROJECT_ID, + parent_project_qualified_name=project.qualified_name, + ) + result = _save(client, to_create, TableauProject) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauProject) + + +@pytest.fixture(scope="module") +def workbook( + client: AtlanClient, project: TableauProject, sub_project: TableauProject +) -> Generator[TableauWorkbook, None, None]: + # Lives in the nested project, so its top-level project is the parent. + to_create = TableauWorkbook.creator( + name=f"workbook_{MODULE_NAME}", + project_qualified_name=sub_project.qualified_name, + workbook_id=WORKBOOK_ID, + top_level_project_qualified_name=project.qualified_name, + ) + result = _save(client, to_create, TableauWorkbook) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauWorkbook) + + +@pytest.fixture(scope="module") +def published_datasource( + client: AtlanClient, project: TableauProject +) -> Generator[TableauDatasource, None, None]: + to_create = TableauDatasource.creator( + name=f"published_ds_{MODULE_NAME}", + project_qualified_name=project.qualified_name, + datasource_id=PUBLISHED_DS_ID, + ) + result = _save(client, to_create, TableauDatasource) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauDatasource) + + +@pytest.fixture(scope="module") +def embedded_datasource( + client: AtlanClient, + project: TableauProject, + sub_project: TableauProject, + workbook: TableauWorkbook, +) -> Generator[TableauDatasource, None, None]: + to_create = TableauDatasource.creator( + name=f"embedded_ds_{MODULE_NAME}", + project_qualified_name=sub_project.qualified_name, + datasource_id=EMBEDDED_DS_ID, + workbook_qualified_name=workbook.qualified_name, + top_level_project_qualified_name=project.qualified_name, + ) + result = _save(client, to_create, TableauDatasource) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauDatasource) + + +@pytest.fixture(scope="module") +def dashboard( + client: AtlanClient, project: TableauProject, workbook: TableauWorkbook +) -> Generator[TableauDashboard, None, None]: + to_create = TableauDashboard.creator( + name=f"dashboard_{MODULE_NAME}", + workbook_qualified_name=workbook.qualified_name, + dashboard_id=DASHBOARD_ID, + top_level_project_qualified_name=project.qualified_name, + ) + result = _save(client, to_create, TableauDashboard) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauDashboard) + + +@pytest.fixture(scope="module") +def worksheet( + client: AtlanClient, project: TableauProject, workbook: TableauWorkbook +) -> Generator[TableauWorksheet, None, None]: + to_create = TableauWorksheet.creator( + name=f"worksheet_{MODULE_NAME}", + workbook_qualified_name=workbook.qualified_name, + worksheet_id=WORKSHEET_ID, + top_level_project_qualified_name=project.qualified_name, + ) + result = _save(client, to_create, TableauWorksheet) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauWorksheet) + + +@pytest.fixture(scope="module") +def datasource_field( + client: AtlanClient, published_datasource: TableauDatasource +) -> Generator[TableauDatasourceField, None, None]: + to_create = TableauDatasourceField.creator( + name=f"ds_field_{MODULE_NAME}", + datasource_qualified_name=published_datasource.qualified_name, + field_id=DS_FIELD_ID, + ) + result = _save(client, to_create, TableauDatasourceField) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauDatasourceField) + + +@pytest.fixture(scope="module") +def calculated_field( + client: AtlanClient, + project: TableauProject, + embedded_datasource: TableauDatasource, +) -> Generator[TableauCalculatedField, None, None]: + to_create = TableauCalculatedField.creator( + name=f"calc_field_{MODULE_NAME}", + datasource_qualified_name=embedded_datasource.qualified_name, + field_id=CALC_FIELD_ID, + top_level_project_qualified_name=project.qualified_name, + ) + result = _save(client, to_create, TableauCalculatedField) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauCalculatedField) + + +@pytest.fixture(scope="module") +def worksheet_field( + client: AtlanClient, + project: TableauProject, + worksheet: TableauWorksheet, + datasource_field: TableauDatasourceField, +) -> Generator[TableauWorksheetField, None, None]: + # The app keys worksheet fields by the id of the datasource field they use. + to_create = TableauWorksheetField.creator( + name=f"ws_field_{MODULE_NAME}", + worksheet_qualified_name=worksheet.qualified_name, + field_id=DS_FIELD_ID, + top_level_project_qualified_name=project.qualified_name, + ) + result = _save(client, to_create, TableauWorksheetField) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauWorksheetField) + + +@pytest.fixture(scope="module") +def dashboard_field( + client: AtlanClient, + project: TableauProject, + dashboard: TableauDashboard, + datasource_field: TableauDatasourceField, +) -> Generator[TableauDashboardField, None, None]: + to_create = TableauDashboardField.creator( + name=f"dash_field_{MODULE_NAME}", + dashboard_qualified_name=dashboard.qualified_name, + field_id=DS_FIELD_ID, + top_level_project_qualified_name=project.qualified_name, + ) + result = _save(client, to_create, TableauDashboardField) + yield result + delete_asset(client, guid=result.guid, asset_type=TableauDashboardField) + + +def _assert_retrieved(client, asset, asset_type, connection, expected_suffix): + retrieved = client.asset.get_by_guid( + asset.guid, asset_type=asset_type, ignore_relationships=False + ) + assert retrieved.qualified_name == f"{connection.qualified_name}/{expected_suffix}" + assert retrieved.name == asset.name + assert retrieved.connector_name == AtlanConnectorType.TABLEAU + assert retrieved.connection_qualified_name == connection.qualified_name + return retrieved + + +def test_site(client: AtlanClient, connection: Connection, site): + _assert_retrieved(client, site, TableauSite, connection, SITE_ID) + + +def test_project(client: AtlanClient, connection: Connection, site, project): + retrieved = _assert_retrieved( + client, project, TableauProject, connection, f"{SITE_ID}/{PROJECT_ID}" + ) + assert retrieved.site.guid == site.guid + assert retrieved.site_qualified_name == site.qualified_name + assert retrieved.is_top_level_project is True + + +def test_sub_project_is_flat( + client: AtlanClient, connection: Connection, site, project, sub_project +): + retrieved = _assert_retrieved( + client, sub_project, TableauProject, connection, f"{SITE_ID}/{SUB_PROJECT_ID}" + ) + assert retrieved.site.guid == site.guid + assert retrieved.parent_project.guid == project.guid + assert retrieved.is_top_level_project is False + assert retrieved.top_level_project_qualified_name == project.qualified_name + + +def test_workbook( + client: AtlanClient, connection: Connection, site, project, sub_project, workbook +): + retrieved = _assert_retrieved( + client, + workbook, + TableauWorkbook, + connection, + f"{SITE_ID}/{SUB_PROJECT_ID}/{WORKBOOK_ID}", + ) + assert retrieved.project.guid == sub_project.guid + assert retrieved.site_qualified_name == site.qualified_name + assert retrieved.project_qualified_name == sub_project.qualified_name + assert retrieved.top_level_project_qualified_name == project.qualified_name + + +def test_published_datasource( + client: AtlanClient, connection: Connection, project, published_datasource +): + retrieved = _assert_retrieved( + client, + published_datasource, + TableauDatasource, + connection, + f"{SITE_ID}/{PROJECT_ID}/{PUBLISHED_DS_ID}", + ) + assert retrieved.is_published is True + assert retrieved.project.guid == project.guid + assert not retrieved.workbook + + +def test_embedded_datasource( + client: AtlanClient, + connection: Connection, + sub_project, + workbook, + embedded_datasource, +): + retrieved = _assert_retrieved( + client, + embedded_datasource, + TableauDatasource, + connection, + f"{SITE_ID}/{SUB_PROJECT_ID}/{WORKBOOK_ID}/{EMBEDDED_DS_ID}", + ) + assert retrieved.is_published is False + assert retrieved.project.guid == sub_project.guid + assert retrieved.workbook.guid == workbook.guid + assert retrieved.workbook_qualified_name == workbook.qualified_name + + +def test_dashboard( + client: AtlanClient, connection: Connection, project, workbook, dashboard +): + retrieved = _assert_retrieved( + client, + dashboard, + TableauDashboard, + connection, + f"{SITE_ID}/{SUB_PROJECT_ID}/{WORKBOOK_ID}/{DASHBOARD_ID}", + ) + assert retrieved.workbook.guid == workbook.guid + assert retrieved.workbook_qualified_name == workbook.qualified_name + assert retrieved.top_level_project_qualified_name == project.qualified_name + + +def test_worksheet( + client: AtlanClient, connection: Connection, project, workbook, worksheet +): + retrieved = _assert_retrieved( + client, + worksheet, + TableauWorksheet, + connection, + f"{SITE_ID}/{SUB_PROJECT_ID}/{WORKBOOK_ID}/{WORKSHEET_ID}", + ) + assert retrieved.workbook.guid == workbook.guid + assert retrieved.workbook_qualified_name == workbook.qualified_name + assert retrieved.top_level_project_qualified_name == project.qualified_name + + +def test_datasource_field( + client: AtlanClient, + connection: Connection, + published_datasource, + datasource_field, +): + retrieved = _assert_retrieved( + client, + datasource_field, + TableauDatasourceField, + connection, + f"{SITE_ID}/{PROJECT_ID}/{PUBLISHED_DS_ID}/{DS_FIELD_ID}", + ) + assert retrieved.datasource.guid == published_datasource.guid + assert retrieved.datasource_qualified_name == published_datasource.qualified_name + assert not retrieved.workbook_qualified_name + + +def test_calculated_field( + client: AtlanClient, + connection: Connection, + workbook, + embedded_datasource, + calculated_field, +): + retrieved = _assert_retrieved( + client, + calculated_field, + TableauCalculatedField, + connection, + f"{SITE_ID}/{SUB_PROJECT_ID}/{WORKBOOK_ID}/{EMBEDDED_DS_ID}/{CALC_FIELD_ID}", + ) + assert retrieved.datasource.guid == embedded_datasource.guid + assert retrieved.datasource_qualified_name == embedded_datasource.qualified_name + assert retrieved.workbook_qualified_name == workbook.qualified_name + + +def test_worksheet_field( + client: AtlanClient, + connection: Connection, + workbook, + worksheet, + worksheet_field, +): + retrieved = _assert_retrieved( + client, + worksheet_field, + TableauWorksheetField, + connection, + f"{SITE_ID}/{SUB_PROJECT_ID}/{WORKBOOK_ID}/{WORKSHEET_ID}/{DS_FIELD_ID}", + ) + assert retrieved.tableau_worksheet.guid == worksheet.guid + assert retrieved.tableau_worksheet_qualified_name == worksheet.qualified_name + assert retrieved.tableau_workbook_qualified_name == workbook.qualified_name + + +def test_dashboard_field( + client: AtlanClient, + connection: Connection, + dashboard, + dashboard_field, +): + retrieved = _assert_retrieved( + client, + dashboard_field, + TableauDashboardField, + connection, + f"{SITE_ID}/{SUB_PROJECT_ID}/{WORKBOOK_ID}/{DASHBOARD_ID}/{DS_FIELD_ID}", + ) + assert retrieved.tableau_dashboard.guid == dashboard.guid + assert retrieved.tableau_dashboard_qualified_name == dashboard.qualified_name diff --git a/tests_v9/unit/model/constants.py b/tests_v9/unit/model/constants.py index 591a313a1..92c397696 100644 --- a/tests_v9/unit/model/constants.py +++ b/tests_v9/unit/model/constants.py @@ -4,3 +4,42 @@ # Import all constants from the original test constants # This ensures parity with existing tests from tests.unit.model.constants import * # noqa: F401, F403 + +# Tableau (FND-3849): ids shaped like the GUIDs atlan-tableau-app puts in qualifiedNames. +TABLEAU_NAME = "Revenue" +TABLEAU_CONNECTION_QUALIFIED_NAME = "default/tableau/1712345678" +TABLEAU_SITE_ID = "a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" +TABLEAU_PROJECT_ID = "5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d" +TABLEAU_PARENT_PROJECT_ID = "0f1e2d3c-2222-4b4a-8c9d-0e1f2a3b4c5d" +TABLEAU_TOP_PROJECT_ID = "9a8b7c6d-3333-4e4f-8a1b-2c3d4e5f6a7b" +TABLEAU_WORKBOOK_ID = "7e8f9a0b-4444-4c1d-8e2f-3a4b5c6d7e8f" +TABLEAU_DATASOURCE_ID = "2b3c4d5e-5555-4f6a-8b7c-8d9e0f1a2b3c" +TABLEAU_DASHBOARD_ID = "3c4d5e6f-6666-4a7b-9c8d-9e0f1a2b3c4d" +TABLEAU_WORKSHEET_ID = "4d5e6f7a-7777-4b8c-8d9e-0f1a2b3c4d5e" +TABLEAU_FIELD_ID = "6f7a8b9c-8888-4d0e-9f1a-2b3c4d5e6f7a" +TABLEAU_SITE_QUALIFIED_NAME = f"{TABLEAU_CONNECTION_QUALIFIED_NAME}/{TABLEAU_SITE_ID}" +TABLEAU_PROJECT_QUALIFIED_NAME = f"{TABLEAU_SITE_QUALIFIED_NAME}/{TABLEAU_PROJECT_ID}" +TABLEAU_PARENT_PROJECT_QUALIFIED_NAME = ( + f"{TABLEAU_SITE_QUALIFIED_NAME}/{TABLEAU_PARENT_PROJECT_ID}" +) +TABLEAU_TOP_PROJECT_QUALIFIED_NAME = ( + f"{TABLEAU_SITE_QUALIFIED_NAME}/{TABLEAU_TOP_PROJECT_ID}" +) +TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME = ( + f"{TABLEAU_CONNECTION_QUALIFIED_NAME}/other-site/{TABLEAU_TOP_PROJECT_ID}" +) +TABLEAU_WORKBOOK_QUALIFIED_NAME = ( + f"{TABLEAU_PROJECT_QUALIFIED_NAME}/{TABLEAU_WORKBOOK_ID}" +) +TABLEAU_PUBLISHED_DATASOURCE_QUALIFIED_NAME = ( + f"{TABLEAU_PROJECT_QUALIFIED_NAME}/{TABLEAU_DATASOURCE_ID}" +) +TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME = ( + f"{TABLEAU_WORKBOOK_QUALIFIED_NAME}/{TABLEAU_DATASOURCE_ID}" +) +TABLEAU_DASHBOARD_QUALIFIED_NAME = ( + f"{TABLEAU_WORKBOOK_QUALIFIED_NAME}/{TABLEAU_DASHBOARD_ID}" +) +TABLEAU_WORKSHEET_QUALIFIED_NAME = ( + f"{TABLEAU_WORKBOOK_QUALIFIED_NAME}/{TABLEAU_WORKSHEET_ID}" +) diff --git a/tests_v9/unit/model/tableau_calculated_field_test.py b/tests_v9/unit/model/tableau_calculated_field_test.py new file mode 100644 index 000000000..ebb4e792d --- /dev/null +++ b/tests_v9/unit/model/tableau_calculated_field_test.py @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for the TableauCalculatedField creator in pyatlan_v9. + +The expected qualifiedNames are the literal strings atlan-tableau-app emits +(FND-3849 analysis) for fields of published and embedded datasources. +""" + +import pytest + +from pyatlan_v9.model import TableauCalculatedField +from tests_v9.unit.model.constants import ( + TABLEAU_CONNECTION_QUALIFIED_NAME, + TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME, + TABLEAU_FIELD_ID, + TABLEAU_NAME, + TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + TABLEAU_PROJECT_QUALIFIED_NAME, + TABLEAU_PUBLISHED_DATASOURCE_QUALIFIED_NAME, + TABLEAU_SITE_QUALIFIED_NAME, + TABLEAU_WORKBOOK_QUALIFIED_NAME, +) + +APP_PUBLISHED_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d/2b3c4d5e-5555-4f6a-8b7c-8d9e0f1a2b3c" + "/6f7a8b9c-8888-4d0e-9f1a-2b3c4d5e6f7a" +) +APP_EMBEDDED_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d/7e8f9a0b-4444-4c1d-8e2f-3a4b5c6d7e8f" + "/2b3c4d5e-5555-4f6a-8b7c-8d9e0f1a2b3c/6f7a8b9c-8888-4d0e-9f1a-2b3c4d5e6f7a" +) +REQUIRED = dict( + datasource_qualified_name=TABLEAU_PUBLISHED_DATASOURCE_QUALIFIED_NAME, + field_id=TABLEAU_FIELD_ID, +) + + +@pytest.mark.parametrize( + "datasource_qn, expected_qn", + [ + (TABLEAU_PUBLISHED_DATASOURCE_QUALIFIED_NAME, APP_PUBLISHED_QUALIFIED_NAME), + (TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME, APP_EMBEDDED_QUALIFIED_NAME), + ], + ids=["published", "embedded"], +) +def test_creator_matches_app_qualified_name(datasource_qn, expected_qn): + sut = TableauCalculatedField.creator( + name=TABLEAU_NAME, + datasource_qualified_name=datasource_qn, + field_id=TABLEAU_FIELD_ID, + ) + + assert sut.qualified_name == expected_qn + assert sut.name == TABLEAU_NAME + assert sut.connection_qualified_name == TABLEAU_CONNECTION_QUALIFIED_NAME + assert sut.connector_name == "tableau" + assert sut._QUALIFIED_NAME_PATTERN.match(sut.qualified_name) + sut.validate(for_creation=True) + + +def test_create_alias(): + sut = TableauCalculatedField.create(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_PUBLISHED_QUALIFIED_NAME + + +@pytest.mark.parametrize("param", ["name", *REQUIRED]) +def test_creator_rejects_missing_or_blank_inputs(param): + args = {"name": TABLEAU_NAME, **REQUIRED} + args[param] = None + with pytest.raises(ValueError, match=f"{param} is required"): + TableauCalculatedField.creator(**args) + args[param] = " " + with pytest.raises(ValueError, match=f"{param} cannot be blank"): + TableauCalculatedField.creator(**args) + + +@pytest.mark.parametrize( + "bad_datasource_qn", + [ + TABLEAU_PROJECT_QUALIFIED_NAME, + f"{TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME}/x", + ], +) +def test_creator_rejects_malformed_datasource_qualified_name(bad_datasource_qn): + with pytest.raises(ValueError, match="Invalid datasource_qualified_name"): + TableauCalculatedField.creator( + name=TABLEAU_NAME, + **{**REQUIRED, "datasource_qualified_name": bad_datasource_qn}, + ) + + +def test_published_datasource_field_sets_ancestors(): + sut = TableauCalculatedField.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.datasource.qualified_name == TABLEAU_PUBLISHED_DATASOURCE_QUALIFIED_NAME + assert sut.datasource_qualified_name == TABLEAU_PUBLISHED_DATASOURCE_QUALIFIED_NAME + assert sut.project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.top_level_project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.site_qualified_name == TABLEAU_SITE_QUALIFIED_NAME + assert not sut.workbook_qualified_name + + +def test_embedded_datasource_field_sets_workbook(): + sut = TableauCalculatedField.creator( + name=TABLEAU_NAME, + datasource_qualified_name=TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME, + field_id=TABLEAU_FIELD_ID, + ) + + assert sut.datasource.qualified_name == TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME + assert sut.workbook_qualified_name == TABLEAU_WORKBOOK_QUALIFIED_NAME + assert sut.project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.site_qualified_name == TABLEAU_SITE_QUALIFIED_NAME + + +def test_creator_rejects_top_level_project_from_other_site(): + with pytest.raises(ValueError, match="Invalid top_level_project_qualified_name"): + TableauCalculatedField.creator( + name=TABLEAU_NAME, + **REQUIRED, + top_level_project_qualified_name=TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + ) + + +def test_updater(): + sut = TableauCalculatedField.updater( + qualified_name=APP_PUBLISHED_QUALIFIED_NAME, name=TABLEAU_NAME + ) + + assert sut.qualified_name == APP_PUBLISHED_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + + +def test_trim_to_required(): + sut = TableauCalculatedField.updater( + qualified_name=APP_PUBLISHED_QUALIFIED_NAME, name=TABLEAU_NAME + ).trim_to_required() + + assert sut.qualified_name == APP_PUBLISHED_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME diff --git a/tests_v9/unit/model/tableau_dashboard_field_test.py b/tests_v9/unit/model/tableau_dashboard_field_test.py new file mode 100644 index 000000000..9347f9f28 --- /dev/null +++ b/tests_v9/unit/model/tableau_dashboard_field_test.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for the TableauDashboardField creator in pyatlan_v9. + +The expected qualifiedName is the literal string atlan-tableau-app emits (FND-3849 +analysis), so a creator change that would re-key existing fields fails here. +""" + +import pytest + +from pyatlan_v9.model import TableauDashboardField +from tests_v9.unit.model.constants import ( + TABLEAU_CONNECTION_QUALIFIED_NAME, + TABLEAU_DASHBOARD_QUALIFIED_NAME, + TABLEAU_FIELD_ID, + TABLEAU_NAME, + TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + TABLEAU_PROJECT_QUALIFIED_NAME, + TABLEAU_SITE_QUALIFIED_NAME, +) + +APP_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d/7e8f9a0b-4444-4c1d-8e2f-3a4b5c6d7e8f" + "/3c4d5e6f-6666-4a7b-9c8d-9e0f1a2b3c4d/6f7a8b9c-8888-4d0e-9f1a-2b3c4d5e6f7a" +) +REQUIRED = dict( + dashboard_qualified_name=TABLEAU_DASHBOARD_QUALIFIED_NAME, + field_id=TABLEAU_FIELD_ID, +) + + +def test_creator_matches_app_qualified_name(): + sut = TableauDashboardField.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + assert sut.connection_qualified_name == TABLEAU_CONNECTION_QUALIFIED_NAME + assert sut.connector_name == "tableau" + assert sut._QUALIFIED_NAME_PATTERN.match(sut.qualified_name) + sut.validate(for_creation=True) + + +def test_create_alias(): + sut = TableauDashboardField.create(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + + +@pytest.mark.parametrize("param", ["name", *REQUIRED]) +def test_creator_rejects_missing_or_blank_inputs(param): + args = {"name": TABLEAU_NAME, **REQUIRED} + args[param] = None + with pytest.raises(ValueError, match=f"{param} is required"): + TableauDashboardField.creator(**args) + args[param] = " " + with pytest.raises(ValueError, match=f"{param} cannot be blank"): + TableauDashboardField.creator(**args) + + +def test_creator_rejects_malformed_dashboard_qualified_name(): + with pytest.raises(ValueError, match="Invalid dashboard_qualified_name"): + TableauDashboardField.creator( + name=TABLEAU_NAME, + **{ + **REQUIRED, + "dashboard_qualified_name": f"{TABLEAU_DASHBOARD_QUALIFIED_NAME}/x", + }, + ) + + +def test_creator_sets_tableau_prefixed_ancestors(): + sut = TableauDashboardField.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.tableau_dashboard.qualified_name == TABLEAU_DASHBOARD_QUALIFIED_NAME + assert sut.tableau_dashboard_qualified_name == TABLEAU_DASHBOARD_QUALIFIED_NAME + assert sut.tableau_project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert ( + sut.tableau_top_level_project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + ) + assert sut.tableau_site_qualified_name == TABLEAU_SITE_QUALIFIED_NAME + + +def test_creator_rejects_top_level_project_from_other_site(): + with pytest.raises(ValueError, match="Invalid top_level_project_qualified_name"): + TableauDashboardField.creator( + name=TABLEAU_NAME, + **REQUIRED, + top_level_project_qualified_name=TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + ) + + +def test_updater(): + sut = TableauDashboardField.updater( + qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME + ) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + + +def test_trim_to_required(): + sut = TableauDashboardField.updater( + qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME + ).trim_to_required() + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME diff --git a/tests_v9/unit/model/tableau_dashboard_test.py b/tests_v9/unit/model/tableau_dashboard_test.py new file mode 100644 index 000000000..500d13ed4 --- /dev/null +++ b/tests_v9/unit/model/tableau_dashboard_test.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for the TableauDashboard creator in pyatlan_v9. + +The expected qualifiedName is the literal string atlan-tableau-app emits (FND-3849 +analysis), so a creator change that would re-key existing dashboards fails here. +""" + +import pytest + +from pyatlan_v9.model import TableauDashboard +from tests_v9.unit.model.constants import ( + TABLEAU_CONNECTION_QUALIFIED_NAME, + TABLEAU_DASHBOARD_ID, + TABLEAU_NAME, + TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + TABLEAU_PROJECT_QUALIFIED_NAME, + TABLEAU_SITE_QUALIFIED_NAME, + TABLEAU_WORKBOOK_QUALIFIED_NAME, +) + +APP_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d/7e8f9a0b-4444-4c1d-8e2f-3a4b5c6d7e8f" + "/3c4d5e6f-6666-4a7b-9c8d-9e0f1a2b3c4d" +) +REQUIRED = dict( + workbook_qualified_name=TABLEAU_WORKBOOK_QUALIFIED_NAME, + dashboard_id=TABLEAU_DASHBOARD_ID, +) + + +def test_creator_matches_app_qualified_name(): + sut = TableauDashboard.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + assert sut.connection_qualified_name == TABLEAU_CONNECTION_QUALIFIED_NAME + assert sut.connector_name == "tableau" + assert sut._QUALIFIED_NAME_PATTERN.match(sut.qualified_name) + sut.validate(for_creation=True) + + +def test_create_alias(): + sut = TableauDashboard.create(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + + +@pytest.mark.parametrize("param", ["name", *REQUIRED]) +def test_creator_rejects_missing_or_blank_inputs(param): + args = {"name": TABLEAU_NAME, **REQUIRED} + args[param] = None + with pytest.raises(ValueError, match=f"{param} is required"): + TableauDashboard.creator(**args) + args[param] = " " + with pytest.raises(ValueError, match=f"{param} cannot be blank"): + TableauDashboard.creator(**args) + + +def test_creator_rejects_malformed_workbook_qualified_name(): + with pytest.raises(ValueError, match="Invalid workbook_qualified_name"): + TableauDashboard.creator( + name=TABLEAU_NAME, + **{**REQUIRED, "workbook_qualified_name": TABLEAU_PROJECT_QUALIFIED_NAME}, + ) + + +def test_creator_sets_ancestors(): + sut = TableauDashboard.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.workbook.qualified_name == TABLEAU_WORKBOOK_QUALIFIED_NAME + assert sut.workbook_qualified_name == TABLEAU_WORKBOOK_QUALIFIED_NAME + assert sut.project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.top_level_project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.site_qualified_name == TABLEAU_SITE_QUALIFIED_NAME + + +def test_creator_rejects_top_level_project_from_other_site(): + with pytest.raises(ValueError, match="Invalid top_level_project_qualified_name"): + TableauDashboard.creator( + name=TABLEAU_NAME, + **REQUIRED, + top_level_project_qualified_name=TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + ) + + +def test_updater(): + sut = TableauDashboard.updater(qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + + +def test_trim_to_required(): + sut = TableauDashboard.updater( + qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME + ).trim_to_required() + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME diff --git a/tests_v9/unit/model/tableau_datasource_field_test.py b/tests_v9/unit/model/tableau_datasource_field_test.py new file mode 100644 index 000000000..1dc4b4254 --- /dev/null +++ b/tests_v9/unit/model/tableau_datasource_field_test.py @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for the TableauDatasourceField creator in pyatlan_v9. + +The expected qualifiedNames are the literal strings atlan-tableau-app emits +(FND-3849 analysis) for fields of published and embedded datasources. +""" + +import pytest + +from pyatlan_v9.model import TableauDatasourceField +from tests_v9.unit.model.constants import ( + TABLEAU_CONNECTION_QUALIFIED_NAME, + TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME, + TABLEAU_FIELD_ID, + TABLEAU_NAME, + TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + TABLEAU_PROJECT_QUALIFIED_NAME, + TABLEAU_PUBLISHED_DATASOURCE_QUALIFIED_NAME, + TABLEAU_SITE_QUALIFIED_NAME, + TABLEAU_WORKBOOK_QUALIFIED_NAME, +) + +APP_PUBLISHED_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d/2b3c4d5e-5555-4f6a-8b7c-8d9e0f1a2b3c" + "/6f7a8b9c-8888-4d0e-9f1a-2b3c4d5e6f7a" +) +APP_EMBEDDED_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d/7e8f9a0b-4444-4c1d-8e2f-3a4b5c6d7e8f" + "/2b3c4d5e-5555-4f6a-8b7c-8d9e0f1a2b3c/6f7a8b9c-8888-4d0e-9f1a-2b3c4d5e6f7a" +) +REQUIRED = dict( + datasource_qualified_name=TABLEAU_PUBLISHED_DATASOURCE_QUALIFIED_NAME, + field_id=TABLEAU_FIELD_ID, +) + + +@pytest.mark.parametrize( + "datasource_qn, expected_qn", + [ + (TABLEAU_PUBLISHED_DATASOURCE_QUALIFIED_NAME, APP_PUBLISHED_QUALIFIED_NAME), + (TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME, APP_EMBEDDED_QUALIFIED_NAME), + ], + ids=["published", "embedded"], +) +def test_creator_matches_app_qualified_name(datasource_qn, expected_qn): + sut = TableauDatasourceField.creator( + name=TABLEAU_NAME, + datasource_qualified_name=datasource_qn, + field_id=TABLEAU_FIELD_ID, + ) + + assert sut.qualified_name == expected_qn + assert sut.name == TABLEAU_NAME + assert sut.connection_qualified_name == TABLEAU_CONNECTION_QUALIFIED_NAME + assert sut.connector_name == "tableau" + assert sut._QUALIFIED_NAME_PATTERN.match(sut.qualified_name) + sut.validate(for_creation=True) + + +def test_create_alias(): + sut = TableauDatasourceField.create(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_PUBLISHED_QUALIFIED_NAME + + +@pytest.mark.parametrize("param", ["name", *REQUIRED]) +def test_creator_rejects_missing_or_blank_inputs(param): + args = {"name": TABLEAU_NAME, **REQUIRED} + args[param] = None + with pytest.raises(ValueError, match=f"{param} is required"): + TableauDatasourceField.creator(**args) + args[param] = " " + with pytest.raises(ValueError, match=f"{param} cannot be blank"): + TableauDatasourceField.creator(**args) + + +@pytest.mark.parametrize( + "bad_datasource_qn", + [ + TABLEAU_PROJECT_QUALIFIED_NAME, + f"{TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME}/x", + ], +) +def test_creator_rejects_malformed_datasource_qualified_name(bad_datasource_qn): + with pytest.raises(ValueError, match="Invalid datasource_qualified_name"): + TableauDatasourceField.creator( + name=TABLEAU_NAME, + **{**REQUIRED, "datasource_qualified_name": bad_datasource_qn}, + ) + + +def test_published_datasource_field_sets_ancestors(): + sut = TableauDatasourceField.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.datasource.qualified_name == TABLEAU_PUBLISHED_DATASOURCE_QUALIFIED_NAME + assert sut.datasource_qualified_name == TABLEAU_PUBLISHED_DATASOURCE_QUALIFIED_NAME + assert sut.project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.top_level_project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.site_qualified_name == TABLEAU_SITE_QUALIFIED_NAME + assert not sut.workbook_qualified_name + + +def test_embedded_datasource_field_sets_workbook(): + sut = TableauDatasourceField.creator( + name=TABLEAU_NAME, + datasource_qualified_name=TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME, + field_id=TABLEAU_FIELD_ID, + ) + + assert sut.datasource.qualified_name == TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME + assert sut.workbook_qualified_name == TABLEAU_WORKBOOK_QUALIFIED_NAME + assert sut.project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.site_qualified_name == TABLEAU_SITE_QUALIFIED_NAME + + +def test_creator_rejects_top_level_project_from_other_site(): + with pytest.raises(ValueError, match="Invalid top_level_project_qualified_name"): + TableauDatasourceField.creator( + name=TABLEAU_NAME, + **REQUIRED, + top_level_project_qualified_name=TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + ) + + +def test_updater(): + sut = TableauDatasourceField.updater( + qualified_name=APP_PUBLISHED_QUALIFIED_NAME, name=TABLEAU_NAME + ) + + assert sut.qualified_name == APP_PUBLISHED_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + + +def test_trim_to_required(): + sut = TableauDatasourceField.updater( + qualified_name=APP_PUBLISHED_QUALIFIED_NAME, name=TABLEAU_NAME + ).trim_to_required() + + assert sut.qualified_name == APP_PUBLISHED_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME diff --git a/tests_v9/unit/model/tableau_datasource_test.py b/tests_v9/unit/model/tableau_datasource_test.py new file mode 100644 index 000000000..14b38b417 --- /dev/null +++ b/tests_v9/unit/model/tableau_datasource_test.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for the TableauDatasource creator in pyatlan_v9. + +The expected qualifiedNames are the literal strings atlan-tableau-app emits +(FND-3849 analysis) for published and embedded datasources. +""" + +import pytest + +from pyatlan_v9.model import TableauDatasource +from tests_v9.unit.model.constants import ( + TABLEAU_CONNECTION_QUALIFIED_NAME, + TABLEAU_DATASOURCE_ID, + TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME, + TABLEAU_NAME, + TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + TABLEAU_PARENT_PROJECT_QUALIFIED_NAME, + TABLEAU_PROJECT_QUALIFIED_NAME, + TABLEAU_SITE_QUALIFIED_NAME, + TABLEAU_WORKBOOK_ID, + TABLEAU_WORKBOOK_QUALIFIED_NAME, +) + +APP_PUBLISHED_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d/2b3c4d5e-5555-4f6a-8b7c-8d9e0f1a2b3c" +) +APP_EMBEDDED_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d/7e8f9a0b-4444-4c1d-8e2f-3a4b5c6d7e8f" + "/2b3c4d5e-5555-4f6a-8b7c-8d9e0f1a2b3c" +) +REQUIRED = dict( + project_qualified_name=TABLEAU_PROJECT_QUALIFIED_NAME, + datasource_id=TABLEAU_DATASOURCE_ID, +) + + +@pytest.mark.parametrize( + "extra, expected_qn", + [ + ({}, APP_PUBLISHED_QUALIFIED_NAME), + ( + {"workbook_qualified_name": TABLEAU_WORKBOOK_QUALIFIED_NAME}, + APP_EMBEDDED_QUALIFIED_NAME, + ), + ], + ids=["published", "embedded"], +) +def test_creator_matches_app_qualified_name(extra, expected_qn): + sut = TableauDatasource.creator(name=TABLEAU_NAME, **REQUIRED, **extra) + + assert sut.qualified_name == expected_qn + assert sut.name == TABLEAU_NAME + assert sut.connection_qualified_name == TABLEAU_CONNECTION_QUALIFIED_NAME + assert sut.connector_name == "tableau" + assert sut._QUALIFIED_NAME_PATTERN.match(sut.qualified_name) + sut.validate(for_creation=True) + + +def test_create_alias(): + sut = TableauDatasource.create(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_PUBLISHED_QUALIFIED_NAME + + +@pytest.mark.parametrize("param", ["name", *REQUIRED]) +def test_creator_rejects_missing_or_blank_inputs(param): + args = {"name": TABLEAU_NAME, **REQUIRED} + args[param] = None + with pytest.raises(ValueError, match=f"{param} is required"): + TableauDatasource.creator(**args) + args[param] = " " + with pytest.raises(ValueError, match=f"{param} cannot be blank"): + TableauDatasource.creator(**args) + + +def test_creator_rejects_malformed_project_qualified_name(): + with pytest.raises(ValueError, match="Invalid project_qualified_name"): + TableauDatasource.creator( + name=TABLEAU_NAME, + **{**REQUIRED, "project_qualified_name": TABLEAU_WORKBOOK_QUALIFIED_NAME}, + ) + + +def test_published_datasource(): + sut = TableauDatasource.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.is_published is True + assert sut.project.qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.top_level_project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.site_qualified_name == TABLEAU_SITE_QUALIFIED_NAME + assert not sut.workbook + assert not sut.workbook_qualified_name + + +def test_embedded_datasource_links_project_and_workbook(): + sut = TableauDatasource.creator( + name=TABLEAU_NAME, + **REQUIRED, + workbook_qualified_name=TABLEAU_WORKBOOK_QUALIFIED_NAME, + ) + + assert sut.qualified_name == TABLEAU_EMBEDDED_DATASOURCE_QUALIFIED_NAME + assert sut.is_published is False + assert sut.project.qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.workbook.qualified_name == TABLEAU_WORKBOOK_QUALIFIED_NAME + assert sut.workbook_qualified_name == TABLEAU_WORKBOOK_QUALIFIED_NAME + + +def test_embedded_datasource_rejects_workbook_in_other_project(): + with pytest.raises(ValueError, match="Invalid workbook_qualified_name"): + TableauDatasource.creator( + name=TABLEAU_NAME, + **REQUIRED, + workbook_qualified_name=( + f"{TABLEAU_PARENT_PROJECT_QUALIFIED_NAME}/{TABLEAU_WORKBOOK_ID}" + ), + ) + + +def test_creator_rejects_top_level_project_from_other_site(): + with pytest.raises(ValueError, match="Invalid top_level_project_qualified_name"): + TableauDatasource.creator( + name=TABLEAU_NAME, + **REQUIRED, + top_level_project_qualified_name=TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + ) + + +def test_updater(): + sut = TableauDatasource.updater( + qualified_name=APP_PUBLISHED_QUALIFIED_NAME, name=TABLEAU_NAME + ) + + assert sut.qualified_name == APP_PUBLISHED_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + + +def test_trim_to_required(): + sut = TableauDatasource.updater( + qualified_name=APP_PUBLISHED_QUALIFIED_NAME, name=TABLEAU_NAME + ).trim_to_required() + + assert sut.qualified_name == APP_PUBLISHED_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME diff --git a/tests_v9/unit/model/tableau_project_test.py b/tests_v9/unit/model/tableau_project_test.py new file mode 100644 index 000000000..ff64f7ac7 --- /dev/null +++ b/tests_v9/unit/model/tableau_project_test.py @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for the TableauProject creator in pyatlan_v9. + +The expected qualifiedName is the literal string atlan-tableau-app emits (FND-3849 +analysis): projects stay flat under the site even when nested. +""" + +import pytest + +from pyatlan_v9.model import TableauProject +from tests_v9.unit.model.constants import ( + TABLEAU_CONNECTION_QUALIFIED_NAME, + TABLEAU_NAME, + TABLEAU_PARENT_PROJECT_QUALIFIED_NAME, + TABLEAU_PROJECT_ID, + TABLEAU_PROJECT_QUALIFIED_NAME, + TABLEAU_SITE_QUALIFIED_NAME, + TABLEAU_TOP_PROJECT_QUALIFIED_NAME, +) + +APP_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d" +) +REQUIRED = dict( + site_qualified_name=TABLEAU_SITE_QUALIFIED_NAME, + project_id=TABLEAU_PROJECT_ID, +) + + +def test_creator_matches_app_qualified_name(): + sut = TableauProject.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + assert sut.connection_qualified_name == TABLEAU_CONNECTION_QUALIFIED_NAME + assert sut.connector_name == "tableau" + assert sut._QUALIFIED_NAME_PATTERN.match(sut.qualified_name) + sut.validate(for_creation=True) + + +def test_create_alias(): + sut = TableauProject.create(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + + +@pytest.mark.parametrize("param", ["name", *REQUIRED]) +def test_creator_rejects_missing_or_blank_inputs(param): + args = {"name": TABLEAU_NAME, **REQUIRED} + args[param] = None + with pytest.raises(ValueError, match=f"{param} is required"): + TableauProject.creator(**args) + args[param] = " " + with pytest.raises(ValueError, match=f"{param} cannot be blank"): + TableauProject.creator(**args) + + +@pytest.mark.parametrize( + "bad_site_qn", + [TABLEAU_CONNECTION_QUALIFIED_NAME, TABLEAU_PROJECT_QUALIFIED_NAME], +) +def test_creator_rejects_malformed_site_qualified_name(bad_site_qn): + with pytest.raises(ValueError, match="Invalid site_qualified_name"): + TableauProject.creator( + name=TABLEAU_NAME, **{**REQUIRED, "site_qualified_name": bad_site_qn} + ) + + +def test_top_level_project_has_site_and_no_parent(): + sut = TableauProject.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.site.qualified_name == TABLEAU_SITE_QUALIFIED_NAME + assert sut.site_qualified_name == TABLEAU_SITE_QUALIFIED_NAME + assert sut.is_top_level_project is True + assert not sut.parent_project + assert not sut.top_level_project_qualified_name + + +def test_nested_project_stays_flat_and_sets_parent(): + sut = TableauProject.creator( + name=TABLEAU_NAME, + **REQUIRED, + parent_project_qualified_name=TABLEAU_PARENT_PROJECT_QUALIFIED_NAME, + top_level_project_qualified_name=TABLEAU_TOP_PROJECT_QUALIFIED_NAME, + ) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.is_top_level_project is False + assert sut.parent_project.qualified_name == TABLEAU_PARENT_PROJECT_QUALIFIED_NAME + assert sut.top_level_project_qualified_name == TABLEAU_TOP_PROJECT_QUALIFIED_NAME + + +def test_nested_project_top_level_defaults_to_parent(): + sut = TableauProject.creator( + name=TABLEAU_NAME, + **REQUIRED, + parent_project_qualified_name=TABLEAU_PARENT_PROJECT_QUALIFIED_NAME, + ) + + assert sut.top_level_project_qualified_name == TABLEAU_PARENT_PROJECT_QUALIFIED_NAME + + +def test_creator_rejects_top_level_without_parent(): + with pytest.raises(ValueError, match="requires parent_project_qualified_name"): + TableauProject.creator( + name=TABLEAU_NAME, + **REQUIRED, + top_level_project_qualified_name=TABLEAU_TOP_PROJECT_QUALIFIED_NAME, + ) + + +def test_updater(): + sut = TableauProject.updater(qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + + +def test_trim_to_required(): + sut = TableauProject.updater( + qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME + ).trim_to_required() + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME diff --git a/tests_v9/unit/model/tableau_site_test.py b/tests_v9/unit/model/tableau_site_test.py new file mode 100644 index 000000000..159d443a6 --- /dev/null +++ b/tests_v9/unit/model/tableau_site_test.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for the TableauSite creator in pyatlan_v9. + +The expected qualifiedName is the literal string atlan-tableau-app emits (FND-3849 +analysis), so a creator change that would re-key existing sites fails here. +""" + +import pytest + +from pyatlan_v9.model import TableauSite +from tests_v9.unit.model.constants import ( + TABLEAU_CONNECTION_QUALIFIED_NAME, + TABLEAU_NAME, + TABLEAU_SITE_ID, + TABLEAU_SITE_QUALIFIED_NAME, +) + +APP_QUALIFIED_NAME = "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" +REQUIRED = dict( + connection_qualified_name=TABLEAU_CONNECTION_QUALIFIED_NAME, + site_id=TABLEAU_SITE_ID, +) + + +def test_creator_matches_app_qualified_name(): + sut = TableauSite.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + assert sut.connection_qualified_name == TABLEAU_CONNECTION_QUALIFIED_NAME + assert sut.connector_name == "tableau" + assert sut._QUALIFIED_NAME_PATTERN.match(sut.qualified_name) + sut.validate(for_creation=True) + + +def test_create_alias(): + sut = TableauSite.create(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + + +@pytest.mark.parametrize("param", ["name", *REQUIRED]) +def test_creator_rejects_missing_or_blank_inputs(param): + args = {"name": TABLEAU_NAME, **REQUIRED} + args[param] = None + with pytest.raises(ValueError, match=f"{param} is required"): + TableauSite.creator(**args) + args[param] = " " + with pytest.raises(ValueError, match=f"{param} cannot be blank"): + TableauSite.creator(**args) + + +def test_creator_rejects_malformed_connection_qualified_name(): + with pytest.raises(ValueError, match="Invalid connection_qualified_name"): + TableauSite.creator( + name=TABLEAU_NAME, + **{**REQUIRED, "connection_qualified_name": TABLEAU_SITE_QUALIFIED_NAME}, + ) + + +def test_updater(): + sut = TableauSite.updater(qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + + +def test_trim_to_required(): + sut = TableauSite.updater( + qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME + ).trim_to_required() + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME diff --git a/tests_v9/unit/model/tableau_workbook_test.py b/tests_v9/unit/model/tableau_workbook_test.py new file mode 100644 index 000000000..56ad81c89 --- /dev/null +++ b/tests_v9/unit/model/tableau_workbook_test.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for the TableauWorkbook creator in pyatlan_v9. + +The expected qualifiedName is the literal string atlan-tableau-app emits (FND-3849 +analysis), so a creator change that would re-key existing workbooks fails here. +""" + +import pytest + +from pyatlan_v9.model import TableauWorkbook +from tests_v9.unit.model.constants import ( + TABLEAU_CONNECTION_QUALIFIED_NAME, + TABLEAU_NAME, + TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + TABLEAU_PROJECT_QUALIFIED_NAME, + TABLEAU_SITE_QUALIFIED_NAME, + TABLEAU_TOP_PROJECT_QUALIFIED_NAME, + TABLEAU_WORKBOOK_ID, + TABLEAU_WORKBOOK_QUALIFIED_NAME, +) + +APP_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d/7e8f9a0b-4444-4c1d-8e2f-3a4b5c6d7e8f" +) +REQUIRED = dict( + project_qualified_name=TABLEAU_PROJECT_QUALIFIED_NAME, + workbook_id=TABLEAU_WORKBOOK_ID, +) + + +def test_creator_matches_app_qualified_name(): + sut = TableauWorkbook.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + assert sut.connection_qualified_name == TABLEAU_CONNECTION_QUALIFIED_NAME + assert sut.connector_name == "tableau" + assert sut._QUALIFIED_NAME_PATTERN.match(sut.qualified_name) + sut.validate(for_creation=True) + + +def test_create_alias(): + sut = TableauWorkbook.create(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + + +@pytest.mark.parametrize("param", ["name", *REQUIRED]) +def test_creator_rejects_missing_or_blank_inputs(param): + args = {"name": TABLEAU_NAME, **REQUIRED} + args[param] = None + with pytest.raises(ValueError, match=f"{param} is required"): + TableauWorkbook.creator(**args) + args[param] = " " + with pytest.raises(ValueError, match=f"{param} cannot be blank"): + TableauWorkbook.creator(**args) + + +@pytest.mark.parametrize( + "bad_project_qn", [TABLEAU_SITE_QUALIFIED_NAME, TABLEAU_WORKBOOK_QUALIFIED_NAME] +) +def test_creator_rejects_malformed_project_qualified_name(bad_project_qn): + with pytest.raises(ValueError, match="Invalid project_qualified_name"): + TableauWorkbook.creator( + name=TABLEAU_NAME, + **{**REQUIRED, "project_qualified_name": bad_project_qn}, + ) + + +def test_creator_sets_project_ancestors(): + sut = TableauWorkbook.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.project.qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.site_qualified_name == TABLEAU_SITE_QUALIFIED_NAME + assert sut.top_level_project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + + +def test_top_level_project_override_is_kept(): + sut = TableauWorkbook.creator( + name=TABLEAU_NAME, + **REQUIRED, + top_level_project_qualified_name=TABLEAU_TOP_PROJECT_QUALIFIED_NAME, + ) + + assert sut.top_level_project_qualified_name == TABLEAU_TOP_PROJECT_QUALIFIED_NAME + + +def test_creator_rejects_top_level_project_from_other_site(): + with pytest.raises(ValueError, match="Invalid top_level_project_qualified_name"): + TableauWorkbook.creator( + name=TABLEAU_NAME, + **REQUIRED, + top_level_project_qualified_name=TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + ) + + +def test_updater(): + sut = TableauWorkbook.updater(qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + + +def test_trim_to_required(): + sut = TableauWorkbook.updater( + qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME + ).trim_to_required() + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME diff --git a/tests_v9/unit/model/tableau_worksheet_field_test.py b/tests_v9/unit/model/tableau_worksheet_field_test.py new file mode 100644 index 000000000..2d3a5fe40 --- /dev/null +++ b/tests_v9/unit/model/tableau_worksheet_field_test.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for the TableauWorksheetField creator in pyatlan_v9. + +The expected qualifiedName is the literal string atlan-tableau-app emits (FND-3849 +analysis), so a creator change that would re-key existing fields fails here. +""" + +import pytest + +from pyatlan_v9.model import TableauWorksheetField +from tests_v9.unit.model.constants import ( + TABLEAU_CONNECTION_QUALIFIED_NAME, + TABLEAU_FIELD_ID, + TABLEAU_NAME, + TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + TABLEAU_PROJECT_QUALIFIED_NAME, + TABLEAU_SITE_QUALIFIED_NAME, + TABLEAU_WORKBOOK_QUALIFIED_NAME, + TABLEAU_WORKSHEET_QUALIFIED_NAME, +) + +APP_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d/7e8f9a0b-4444-4c1d-8e2f-3a4b5c6d7e8f" + "/4d5e6f7a-7777-4b8c-8d9e-0f1a2b3c4d5e/6f7a8b9c-8888-4d0e-9f1a-2b3c4d5e6f7a" +) +REQUIRED = dict( + worksheet_qualified_name=TABLEAU_WORKSHEET_QUALIFIED_NAME, + field_id=TABLEAU_FIELD_ID, +) + + +def test_creator_matches_app_qualified_name(): + sut = TableauWorksheetField.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + assert sut.connection_qualified_name == TABLEAU_CONNECTION_QUALIFIED_NAME + assert sut.connector_name == "tableau" + assert sut._QUALIFIED_NAME_PATTERN.match(sut.qualified_name) + sut.validate(for_creation=True) + + +def test_create_alias(): + sut = TableauWorksheetField.create(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + + +@pytest.mark.parametrize("param", ["name", *REQUIRED]) +def test_creator_rejects_missing_or_blank_inputs(param): + args = {"name": TABLEAU_NAME, **REQUIRED} + args[param] = None + with pytest.raises(ValueError, match=f"{param} is required"): + TableauWorksheetField.creator(**args) + args[param] = " " + with pytest.raises(ValueError, match=f"{param} cannot be blank"): + TableauWorksheetField.creator(**args) + + +def test_creator_rejects_malformed_worksheet_qualified_name(): + with pytest.raises(ValueError, match="Invalid worksheet_qualified_name"): + TableauWorksheetField.creator( + name=TABLEAU_NAME, + **{**REQUIRED, "worksheet_qualified_name": TABLEAU_WORKBOOK_QUALIFIED_NAME}, + ) + + +def test_creator_sets_tableau_prefixed_ancestors(): + sut = TableauWorksheetField.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.tableau_worksheet.qualified_name == TABLEAU_WORKSHEET_QUALIFIED_NAME + assert sut.tableau_worksheet_qualified_name == TABLEAU_WORKSHEET_QUALIFIED_NAME + assert sut.tableau_workbook_qualified_name == TABLEAU_WORKBOOK_QUALIFIED_NAME + assert sut.tableau_project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert ( + sut.tableau_top_level_project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + ) + assert sut.tableau_site_qualified_name == TABLEAU_SITE_QUALIFIED_NAME + + +def test_creator_rejects_top_level_project_from_other_site(): + with pytest.raises(ValueError, match="Invalid top_level_project_qualified_name"): + TableauWorksheetField.creator( + name=TABLEAU_NAME, + **REQUIRED, + top_level_project_qualified_name=TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + ) + + +def test_updater(): + sut = TableauWorksheetField.updater( + qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME + ) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + + +def test_trim_to_required(): + sut = TableauWorksheetField.updater( + qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME + ).trim_to_required() + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME diff --git a/tests_v9/unit/model/tableau_worksheet_test.py b/tests_v9/unit/model/tableau_worksheet_test.py new file mode 100644 index 000000000..6014c8fec --- /dev/null +++ b/tests_v9/unit/model/tableau_worksheet_test.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Atlan Pte. Ltd. + +"""Unit tests for the TableauWorksheet creator in pyatlan_v9. + +The expected qualifiedName is the literal string atlan-tableau-app emits (FND-3849 +analysis), so a creator change that would re-key existing worksheets fails here. +""" + +import pytest + +from pyatlan_v9.model import TableauWorksheet +from tests_v9.unit.model.constants import ( + TABLEAU_CONNECTION_QUALIFIED_NAME, + TABLEAU_NAME, + TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + TABLEAU_PROJECT_QUALIFIED_NAME, + TABLEAU_SITE_QUALIFIED_NAME, + TABLEAU_WORKBOOK_QUALIFIED_NAME, + TABLEAU_WORKSHEET_ID, + TABLEAU_WORKSHEET_QUALIFIED_NAME, +) + +APP_QUALIFIED_NAME = ( + "default/tableau/1712345678/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" + "/5c6d7e8f-1111-4a2b-9c3d-4e5f6a7b8c9d/7e8f9a0b-4444-4c1d-8e2f-3a4b5c6d7e8f" + "/4d5e6f7a-7777-4b8c-8d9e-0f1a2b3c4d5e" +) +REQUIRED = dict( + workbook_qualified_name=TABLEAU_WORKBOOK_QUALIFIED_NAME, + worksheet_id=TABLEAU_WORKSHEET_ID, +) + + +def test_creator_matches_app_qualified_name(): + sut = TableauWorksheet.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + assert sut.connection_qualified_name == TABLEAU_CONNECTION_QUALIFIED_NAME + assert sut.connector_name == "tableau" + assert sut._QUALIFIED_NAME_PATTERN.match(sut.qualified_name) + sut.validate(for_creation=True) + + +def test_create_alias(): + sut = TableauWorksheet.create(name=TABLEAU_NAME, **REQUIRED) + + assert sut.qualified_name == APP_QUALIFIED_NAME + + +@pytest.mark.parametrize("param", ["name", *REQUIRED]) +def test_creator_rejects_missing_or_blank_inputs(param): + args = {"name": TABLEAU_NAME, **REQUIRED} + args[param] = None + with pytest.raises(ValueError, match=f"{param} is required"): + TableauWorksheet.creator(**args) + args[param] = " " + with pytest.raises(ValueError, match=f"{param} cannot be blank"): + TableauWorksheet.creator(**args) + + +def test_creator_rejects_malformed_workbook_qualified_name(): + with pytest.raises(ValueError, match="Invalid workbook_qualified_name"): + TableauWorksheet.creator( + name=TABLEAU_NAME, + **{**REQUIRED, "workbook_qualified_name": TABLEAU_WORKSHEET_QUALIFIED_NAME}, + ) + + +def test_creator_sets_ancestors(): + sut = TableauWorksheet.creator(name=TABLEAU_NAME, **REQUIRED) + + assert sut.workbook.qualified_name == TABLEAU_WORKBOOK_QUALIFIED_NAME + assert sut.workbook_qualified_name == TABLEAU_WORKBOOK_QUALIFIED_NAME + assert sut.project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.top_level_project_qualified_name == TABLEAU_PROJECT_QUALIFIED_NAME + assert sut.site_qualified_name == TABLEAU_SITE_QUALIFIED_NAME + + +def test_creator_rejects_top_level_project_from_other_site(): + with pytest.raises(ValueError, match="Invalid top_level_project_qualified_name"): + TableauWorksheet.creator( + name=TABLEAU_NAME, + **REQUIRED, + top_level_project_qualified_name=TABLEAU_OTHER_SITE_PROJECT_QUALIFIED_NAME, + ) + + +def test_updater(): + sut = TableauWorksheet.updater(qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME) + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME + + +def test_trim_to_required(): + sut = TableauWorksheet.updater( + qualified_name=APP_QUALIFIED_NAME, name=TABLEAU_NAME + ).trim_to_required() + + assert sut.qualified_name == APP_QUALIFIED_NAME + assert sut.name == TABLEAU_NAME