diff --git a/cognite/client/data_classes/capabilities.py b/cognite/client/data_classes/capabilities.py index 35536f3a19..529a89a1aa 100644 --- a/cognite/client/data_classes/capabilities.py +++ b/cognite/client/data_classes/capabilities.py @@ -1104,6 +1104,22 @@ class Scope: DataSet = DataSetScope +@dataclass +class TransformationsExternalDataSourcesAcl(Capability): + _capability_name = "transformationsExternalDataSourcesAcl" + actions: Sequence[Action] + scope: AllScope | DataSetScope + + class Action(Capability.Action): # type: ignore [misc] + Read = "READ" + Write = "WRITE" + Use = "USE" + + class Scope: + All = AllScope + DataSet = DataSetScope + + @dataclass class TypesAcl(Capability): _capability_name = "typesAcl" diff --git a/cognite/client/data_classes/transformations/__init__.py b/cognite/client/data_classes/transformations/__init__.py index a55f9483b0..c19c6a8a62 100644 --- a/cognite/client/data_classes/transformations/__init__.py +++ b/cognite/client/data_classes/transformations/__init__.py @@ -26,6 +26,21 @@ TransformationBlockedInfo, TransformationDestination, ) +from cognite.client.data_classes.transformations.external_data import ( # noqa: F401 + ExternalDataSource, + ExternalDataSourceList, + ExternalDataSourceUsability, + ExternalDataSourceWrite, + ExternalDataSourceWriteList, + OneLakeCredentials, + OneLakeCredentialsWrite, + OneLakeDataSourceSettings, + OneLakeDataSourceSettingsWrite, + OneLakeExternalDataSource, + OneLakeExternalDataSourceWrite, + OneLakeLocationDescription, + UnknownExternalDataSource, +) from cognite.client.data_classes.transformations.jobs import TransformationJob, TransformationJobList from cognite.client.data_classes.transformations.schedules import TransformationSchedule from cognite.client.data_classes.transformations.schema import TransformationSchemaColumnList diff --git a/cognite/client/data_classes/transformations/external_data.py b/cognite/client/data_classes/transformations/external_data.py new file mode 100644 index 0000000000..4e32b6b3ef --- /dev/null +++ b/cognite/client/data_classes/transformations/external_data.py @@ -0,0 +1,506 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, ClassVar, NoReturn, cast + +from typing_extensions import Self + +from cognite.client.data_classes._base import ( + CogniteResource, + CogniteResourceList, + ExternalIDTransformerMixin, + WriteableCogniteResource, + WriteableCogniteResourceList, +) +from cognite.client.utils._text import convert_all_keys_recursive + +ONE_LAKE_FORMAT = "one_lake" + + +class OneLakeLocationDescription(CogniteResource): + """Location of a Fabric OneLake lakehouse. + + Args: + workspace_name (str): Fabric workspace GUID or name. + container_name (str): Fabric lakehouse GUID or name. + """ + + def __init__(self, workspace_name: str, container_name: str) -> None: + self.workspace_name = workspace_name + self.container_name = container_name + + @classmethod + def _load(cls, resource: dict[str, Any]) -> Self: + return cls( + workspace_name=resource["workspaceName"], + container_name=resource["containerName"], + ) + + +class OneLakeCredentials(CogniteResource): + """Response model for Azure credentials returned by list/get (``clientSecret`` is never included). + + Args: + client_id (str): Azure application (client) ID. + tenant_id (str): Azure tenant (directory) ID. + """ + + def __init__(self, client_id: str, tenant_id: str) -> None: + self.client_id = client_id + self.tenant_id = tenant_id + + @classmethod + def _load(cls, resource: dict[str, Any]) -> Self: + return cls( + client_id=resource["clientId"], + tenant_id=resource["tenantId"], + ) + + +class OneLakeCredentialsWrite(CogniteResource): + """Upsert model for Azure credentials when registering a OneLake external data source in CDF. + + Note: + ``Write`` does **not** mean writing data into OneLake, it merely follows the naming convention + in the SDK for create/update-style classes. + + Args: + client_id (str): Azure application (client) ID. + tenant_id (str): Azure tenant (directory) ID. + client_secret (str): Azure client secret + """ + + def __init__(self, client_id: str, tenant_id: str, client_secret: str) -> None: + self.client_id = client_id + self.tenant_id = tenant_id + self.client_secret = client_secret + + def __repr__(self) -> str: + return ( + f"OneLakeCredentialsWrite(client_id={self.client_id!r}, tenant_id={self.tenant_id!r}," + f" client_secret=)" + ) + + def __str__(self) -> str: + return repr(self) + + @classmethod + def _load(cls, resource: dict[str, Any]) -> Self: + return cls( + client_id=resource["clientId"], + tenant_id=resource["tenantId"], + client_secret=resource["clientSecret"], + ) + + +class OneLakeDataSourceSettings(CogniteResource): + """Response model for OneLake connection settings. + + Args: + credentials (OneLakeCredentials | None): Azure credentials (client ID and tenant ID only). + location_description (OneLakeLocationDescription | None): Fabric workspace and lakehouse identifiers. + """ + + def __init__( + self, + credentials: OneLakeCredentials | None = None, + location_description: OneLakeLocationDescription | None = None, + ) -> None: + self.credentials = credentials + self.location_description = location_description + + @classmethod + def _load(cls, resource: dict[str, Any]) -> Self: + return cls( + credentials=OneLakeCredentials._load_if(resource.get("credentials")), + location_description=OneLakeLocationDescription._load_if(resource.get("locationDescription")), + ) + + def dump(self, camel_case: bool = True) -> dict[str, Any]: + result = super().dump(camel_case=camel_case) + if self.credentials is not None: + result["credentials"] = self.credentials.dump(camel_case=camel_case) + if self.location_description is not None: + key = "locationDescription" if camel_case else "location_description" + result[key] = self.location_description.dump(camel_case=camel_case) + return result + + +class OneLakeDataSourceSettingsWrite(CogniteResource): + """Upsert model for OneLake connection settings registered in CDF. + + Args: + credentials (OneLakeCredentialsWrite | None): Azure credentials for ``upsert()``. + location_description (OneLakeLocationDescription | None): Fabric workspace and lakehouse identifiers. + """ + + def __init__( + self, + credentials: OneLakeCredentialsWrite | None = None, + location_description: OneLakeLocationDescription | None = None, + ) -> None: + self.credentials = credentials + self.location_description = location_description + + @classmethod + def _load(cls, resource: dict[str, Any]) -> Self: + return cls( + credentials=OneLakeCredentialsWrite._load_if(resource.get("credentials")), + location_description=OneLakeLocationDescription._load_if(resource.get("locationDescription")), + ) + + def dump(self, camel_case: bool = True) -> dict[str, Any]: + result = super().dump(camel_case=camel_case) + if self.credentials is not None: + result["credentials"] = self.credentials.dump(camel_case=camel_case) + if self.location_description is not None: + key = "locationDescription" if camel_case else "location_description" + result[key] = self.location_description.dump(camel_case=camel_case) + return result + + +class ExternalDataSource(WriteableCogniteResource["ExternalDataSourceWrite"], ABC): + """An external data source (API read model — returned by list/get). + + Format-specific subclasses (e.g. :class:`OneLakeExternalDataSource`) hold typed settings. + + Args: + external_id (str): External ID of the data source. + format (str): Backend format identifier (``"one_lake"`` for OneLake sources). + name (str | None): Human-readable name. + data_set_id (int | None): Data set ID for ACL scoping. + created_time (int | None): Time the resource was created (milliseconds since epoch). + last_updated_time (int | None): Time the resource was last updated (milliseconds since epoch). + """ + + def __init__( + self, + external_id: str, + format: str, + name: str | None = None, + data_set_id: int | None = None, + created_time: int | None = None, + last_updated_time: int | None = None, + ) -> None: + self.external_id = external_id + self.name = name + self.data_set_id = data_set_id + self.format = format + self.created_time = created_time + self.last_updated_time = last_updated_time + + @classmethod + def _load(cls, resource: dict[str, Any]) -> ExternalDataSource: + fmt = resource["format"] + if fmt == ONE_LAKE_FORMAT: + return OneLakeExternalDataSource._load(resource) + return UnknownExternalDataSource._load(resource) + + @abstractmethod + def as_write(self, client_secret: str | None = None) -> ExternalDataSourceWrite: + """Return an upsert model for updating this source in CDF. + + Args: + client_secret (str | None): Passed through on format-specific subclasses. + Returns: + ExternalDataSourceWrite: Upsert model for this source. + """ + + +class OneLakeExternalDataSource(ExternalDataSource): + """A Fabric OneLake external data source (API read model). + + OneLake external data sources are **read-only from a transform perspective** — transforms can + read data from OneLake tables via ``ext_onelake()`` SQL, but writing transform output to OneLake + is not supported. + + Args: + external_id (str): External ID of the data source. + format (str): Backend format identifier (always ``"one_lake"``). + name (str | None): Human-readable name. + data_set_id (int | None): Data set ID for ACL scoping. + settings (OneLakeDataSourceSettings | None): Connection settings. + created_time (int | None): Time the resource was created (milliseconds since epoch). + last_updated_time (int | None): Time the resource was last updated (milliseconds since epoch). + """ + + def __init__( + self, + external_id: str, + format: str, + name: str | None = None, + data_set_id: int | None = None, + settings: OneLakeDataSourceSettings | None = None, + created_time: int | None = None, + last_updated_time: int | None = None, + ) -> None: + super().__init__( + external_id=external_id, + format=format, + name=name, + data_set_id=data_set_id, + created_time=created_time, + last_updated_time=last_updated_time, + ) + self.settings = settings + + @classmethod + def _load(cls, resource: dict[str, Any]) -> Self: + settings = OneLakeDataSourceSettings._load_if(resource.get("settings")) + return cls( + external_id=resource["externalId"], + format=resource["format"], + name=resource.get("name"), + data_set_id=resource.get("dataSetId"), + settings=settings, + created_time=resource.get("createdTime"), + last_updated_time=resource.get("lastUpdatedTime"), + ) + + def as_write(self, client_secret: str | None = None) -> OneLakeExternalDataSourceWrite: + """Return an upsert model for updating this source in CDF. + + Args: + client_secret (str | None): Required when the read model includes credentials, because the API does not return ``client_secret``. Omit when only metadata (name, data set, location) changes. + Returns: + OneLakeExternalDataSourceWrite: Upsert model for this source. + """ + settings_write: OneLakeDataSourceSettingsWrite | None = None + if self.settings is not None: + creds_write: OneLakeCredentialsWrite | None = None + if self.settings.credentials is not None: + if client_secret is None: + raise ValueError( + "client_secret is required to convert credentials to a write model because the API " + "does not return it. Pass client_secret to as_write(), or construct " + "OneLakeExternalDataSourceWrite with settings containing credentials." + ) + creds_write = OneLakeCredentialsWrite( + client_id=self.settings.credentials.client_id, + tenant_id=self.settings.credentials.tenant_id, + client_secret=client_secret, + ) + settings_write = OneLakeDataSourceSettingsWrite( + credentials=creds_write, + location_description=self.settings.location_description, + ) + return OneLakeExternalDataSourceWrite( + external_id=self.external_id, + name=self.name, + data_set_id=self.data_set_id, + settings=settings_write, + ) + + def dump(self, camel_case: bool = True) -> dict[str, Any]: + result = super().dump(camel_case=camel_case) + if self.settings is not None: + result["settings"] = self.settings.dump(camel_case=camel_case) + return result + + +class UnknownExternalDataSource(ExternalDataSource): + """An external data source with a format not supported by this SDK version. + + Args: + external_id (str): External ID of the data source. + format (str): Backend format identifier returned by the API. + name (str | None): Human-readable name. + data_set_id (int | None): Data set ID for ACL scoping. + created_time (int | None): Time the resource was created (milliseconds since epoch). + last_updated_time (int | None): Time the resource was last updated (milliseconds since epoch). + """ + + def __init__( + self, + external_id: str, + format: str, + name: str | None = None, + data_set_id: int | None = None, + created_time: int | None = None, + last_updated_time: int | None = None, + ) -> None: + super().__init__( + external_id=external_id, + format=format, + name=name, + data_set_id=data_set_id, + created_time=created_time, + last_updated_time=last_updated_time, + ) + self._raw: dict[str, Any] | None = None + + @classmethod + def _load(cls, resource: dict[str, Any]) -> Self: + instance = cls( + external_id=resource["externalId"], + format=resource["format"], + name=resource.get("name"), + data_set_id=resource.get("dataSetId"), + created_time=resource.get("createdTime"), + last_updated_time=resource.get("lastUpdatedTime"), + ) + instance._raw = resource + return instance + + def dump(self, camel_case: bool = True) -> dict[str, Any]: + if self._raw is not None: + return convert_all_keys_recursive(self._raw, camel_case=camel_case) + return super().dump(camel_case=camel_case) + + def as_write(self, client_secret: str | None = None) -> NoReturn: + raise ValueError( + f"Cannot convert unknown external data source format {self.format!r} to a write model in this SDK version." + ) + + +class ExternalDataSourceWrite(WriteableCogniteResource["ExternalDataSourceWrite"]): + """Upsert model for an external data source (``external_data_sources.upsert()``). + + Use :class:`OneLakeExternalDataSourceWrite` to register a Fabric OneLake source. + + Args: + external_id (str): External ID of the data source. + name (str | None): Human-readable name. + data_set_id (int | None): Data set ID for ACL scoping. + """ + + def __init__( + self, + external_id: str, + name: str | None = None, + data_set_id: int | None = None, + ) -> None: + self.external_id = external_id + self.name = name + self.data_set_id = data_set_id + + @classmethod + def _load(cls, resource: dict[str, Any]) -> Self: + if resource.get("format") == ONE_LAKE_FORMAT: + return cast(Self, OneLakeExternalDataSourceWrite._load(resource)) + return cls( + external_id=resource["externalId"], + name=resource.get("name"), + data_set_id=resource.get("dataSetId"), + ) + + def as_write(self) -> ExternalDataSourceWrite: + return self + + +class OneLakeExternalDataSourceWrite(ExternalDataSourceWrite): + """Upsert model for registering a Fabric OneLake external data source in CDF. + + Registers Azure credentials and lakehouse location so transforms can **read** via + ``ext_onelake()``. Does not write data into OneLake. + + Args: + external_id (str): External ID for the data source. Must be unique. + settings (OneLakeDataSourceSettingsWrite | None): OneLake credentials and location. + name (str | None): Human-readable name. + data_set_id (int | None): Data set ID for ACL scoping. + + Examples: + + Register a Fabric OneLake source: + + >>> from cognite.client import CogniteClient + >>> from cognite.client.data_classes.transformations import ( + ... OneLakeCredentialsWrite, + ... OneLakeDataSourceSettingsWrite, + ... OneLakeExternalDataSourceWrite, + ... OneLakeLocationDescription, + ... ) + >>> client = CogniteClient() + >>> source = OneLakeExternalDataSourceWrite( + ... external_id="fabric-lakehouse-prod", + ... name="Production lakehouse", + ... data_set_id=123456, + ... settings=OneLakeDataSourceSettingsWrite( + ... credentials=OneLakeCredentialsWrite( + ... client_id="", + ... tenant_id="", + ... client_secret="", + ... ), + ... location_description=OneLakeLocationDescription( + ... workspace_name="", + ... container_name="", + ... ), + ... ), + ... ) + >>> client.transformations.external_data_sources.upsert(source) + """ + + _FORMAT: ClassVar[str] = ONE_LAKE_FORMAT + settings: OneLakeDataSourceSettingsWrite | None + + def __init__( + self, + external_id: str, + settings: OneLakeDataSourceSettingsWrite | None = None, + name: str | None = None, + data_set_id: int | None = None, + ) -> None: + super().__init__(external_id=external_id, name=name, data_set_id=data_set_id) + self.settings = settings + + @classmethod + def _load(cls, resource: dict[str, Any]) -> Self: + return cls( + external_id=resource["externalId"], + name=resource.get("name"), + data_set_id=resource.get("dataSetId"), + settings=OneLakeDataSourceSettingsWrite._load_if(resource.get("settings")), + ) + + def dump(self, camel_case: bool = True) -> dict[str, Any]: + result = super().dump(camel_case=camel_case) + result["format"] = self._FORMAT + if self.settings is not None: + result["settings"] = self.settings.dump(camel_case=camel_case) + return result + + +class ExternalDataSourceList( + WriteableCogniteResourceList[ExternalDataSourceWrite, ExternalDataSource], ExternalIDTransformerMixin +): + """A list of ExternalDataSource (read model) objects.""" + + _RESOURCE = ExternalDataSource + + def as_write(self, client_secret: str | None = None) -> ExternalDataSourceWriteList: + """Return upsert models for each source. + + Args: + client_secret (str | None): Passed through to :meth:`OneLakeExternalDataSource.as_write` for each item. + Returns: + ExternalDataSourceWriteList: Upsert models for each source in the list. + """ + return ExternalDataSourceWriteList([item.as_write(client_secret=client_secret) for item in self.data]) + + +class ExternalDataSourceWriteList(CogniteResourceList[ExternalDataSourceWrite], ExternalIDTransformerMixin): + """A list of ExternalDataSourceWrite objects.""" + + _RESOURCE = ExternalDataSourceWrite + + +class ExternalDataSourceUsability(CogniteResource): + """Result of verifying a Fabric OneLake external data source's usability. + + Args: + external_id (str | None): External ID of the verified data source. + usable_version (str | None): UUID indicating the data source is accessible and credentials are valid. + ``None`` if the source cannot be accessed. + """ + + def __init__(self, external_id: str | None = None, usable_version: str | None = None) -> None: + self.external_id = external_id + self.usable_version = usable_version + + @classmethod + def _load(cls, resource: dict[str, Any]) -> Self: + return cls( + external_id=resource.get("externalId"), + usable_version=resource.get("usableVersion"), + ) diff --git a/tests/tests_unit/test_base.py b/tests/tests_unit/test_base.py index e9d94a8384..5c55894e84 100644 --- a/tests/tests_unit/test_base.py +++ b/tests/tests_unit/test_base.py @@ -46,6 +46,11 @@ from cognite.client.data_classes.postgres_gateway import TableList, User, UserCreated, UserCreatedList, UserList from cognite.client.data_classes.sequences import SequenceUpdate from cognite.client.data_classes.time_series import TimeSeries, TimeSeriesList +from cognite.client.data_classes.transformations.external_data import ( + ExternalDataSourceList, + OneLakeExternalDataSource, + UnknownExternalDataSource, +) from cognite.client.data_classes.workflows import UnknownWorkflowTaskParameters, WorkflowTaskOutput from cognite.client.exceptions import CogniteMissingClientError from cognite.client.testing import CogniteClientMock @@ -264,6 +269,15 @@ def test_writable_as_write( instance.instance_id = None assert instance.external_id is not None + if isinstance(instance, UnknownExternalDataSource): + with pytest.raises(ValueError, match="unknown external data source format"): + instance.as_write() + return + + if isinstance(instance, OneLakeExternalDataSource): + if instance.settings is not None and instance.settings.credentials is not None: + instance.settings.credentials = None + write_format = instance.as_write() assert isinstance(write_format, CogniteResource) @@ -293,7 +307,11 @@ def test_writable_list_as_write( resource_cls = writable_list_cls._RESOURCE instance_generator = FakeCogniteResourceGenerator(seed=52, async_client=cognite_async_mock_client_placeholder) # TODO(doctrino): Why not have gen. create the list directly? - resource_list = writable_list_cls([instance_generator.create_instance(resource_cls)]) + resource_list: WriteableCogniteResourceList[Any, Any] + if writable_list_cls is ExternalDataSourceList: + resource_list = ExternalDataSourceList([instance_generator.create_instance(OneLakeExternalDataSource)]) + else: + resource_list = writable_list_cls([instance_generator.create_instance(resource_cls)]) if resource_cls is FunctionSchedule: # FunctionSchedulesList.as_write() calls FunctionSchedule.as_write() on each item, @@ -305,10 +323,16 @@ def test_writable_list_as_write( # Files and time series with instance ID can not be created through "old APIs". Doing as_write with # instance_id set raises an error, so we clear it here: for item in resource_list: - item.instance_id = None + item.instance_id = None # type: ignore[union-attr] assert item.external_id is not None - write_format = resource_list.as_write() + if isinstance(resource_list, ExternalDataSourceList): + for item in resource_list: + if isinstance(item, OneLakeExternalDataSource) and item.settings is not None: + if item.settings.credentials is not None: + item.settings.credentials = None + + write_format: CogniteResourceList = resource_list.as_write() assert isinstance(write_format, CogniteResourceList) @pytest.mark.dsl diff --git a/tests/tests_unit/test_data_classes/test_capabilities.py b/tests/tests_unit/test_data_classes/test_capabilities.py index f004308b9c..3eb42b9416 100644 --- a/tests/tests_unit/test_data_classes/test_capabilities.py +++ b/tests/tests_unit/test_data_classes/test_capabilities.py @@ -164,6 +164,13 @@ def all_acls() -> Iterator[dict[str, Any]]: {"timeSeriesAcl": {"actions": ["WRITE", "READ"], "scope": {"assetRootIdScope": {"rootIds": ["58"]}}}}, {"transformationsAcl": {"actions": ["READ", "WRITE"], "scope": {"all": {}}}}, {"transformationsAcl": {"actions": ["READ", "WRITE"], "scope": {"datasetScope": {"ids": ["94"]}}}}, + {"transformationsExternalDataSourcesAcl": {"actions": ["READ", "WRITE", "USE"], "scope": {"all": {}}}}, + { + "transformationsExternalDataSourcesAcl": { + "actions": ["READ", "WRITE", "USE"], + "scope": {"datasetScope": {"ids": ["94"]}}, + } + }, {"visionModelAcl": {"actions": ["READ", "WRITE"], "scope": {"all": {}}}}, {"wellsAcl": {"actions": ["READ", "WRITE"], "scope": {"all": {}}}}, {"workflowOrchestrationAcl": {"actions": ["READ", "WRITE"], "scope": {"all": {}}}}, diff --git a/tests/tests_unit/test_data_classes/test_transformations/__init__.py b/tests/tests_unit/test_data_classes/test_transformations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/tests_unit/test_data_classes/test_transformations/test_external_data.py b/tests/tests_unit/test_data_classes/test_transformations/test_external_data.py new file mode 100644 index 0000000000..dcfef3ec3e --- /dev/null +++ b/tests/tests_unit/test_data_classes/test_transformations/test_external_data.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import pytest + +from cognite.client.data_classes.transformations.external_data import ( + ExternalDataSource, + OneLakeCredentialsWrite, + OneLakeDataSourceSettingsWrite, + OneLakeExternalDataSource, + OneLakeExternalDataSourceWrite, + OneLakeLocationDescription, + UnknownExternalDataSource, +) + + +def _onelake_write_settings() -> OneLakeDataSourceSettingsWrite: + return OneLakeDataSourceSettingsWrite( + credentials=OneLakeCredentialsWrite("cid", "tid", "sec"), + location_description=OneLakeLocationDescription("ws", "cn"), + ) + + +def test_onelake_write_init_structure() -> None: + source = OneLakeExternalDataSourceWrite(external_id="x", settings=_onelake_write_settings()) + + assert source.external_id == "x" + assert source.settings is not None + assert source.settings.credentials is not None + assert source.settings.credentials.client_id == "cid" + assert source.settings.credentials.client_secret == "sec" + assert source.settings.location_description is not None + assert source.settings.location_description.workspace_name == "ws" + + +def test_write_dump_always_includes_format() -> None: + source = OneLakeExternalDataSourceWrite(external_id="x", settings=_onelake_write_settings()) + dumped = source.dump(camel_case=True) + + assert "format" in dumped + assert dumped["format"] == "one_lake" + assert dumped["settings"]["credentials"]["clientId"] == "cid" + + +def test_base_write_has_no_settings_field() -> None: + source = OneLakeExternalDataSourceWrite(external_id="x", settings=None) + dumped = source.dump(camel_case=True) + + assert dumped["externalId"] == "x" + assert "settings" not in dumped or dumped.get("settings") is None + + +def test_one_lake_load_returns_subclass() -> None: + raw = {"externalId": "x", "format": "one_lake", "settings": {}} + source = ExternalDataSource._load(raw) + + assert isinstance(source, OneLakeExternalDataSource) + assert source.format == "one_lake" + + +def test_read_load_unknown_format_returns_unknown_subclass() -> None: + raw = {"externalId": "x", "format": "delta_sharing", "settings": {}} + source = ExternalDataSource._load(raw) + + assert isinstance(source, UnknownExternalDataSource) + assert source.external_id == "x" + assert source.format == "delta_sharing" + assert not hasattr(source, "settings") or getattr(source, "settings", None) is None + assert source.dump(camel_case=True) == raw + + +def test_read_load_requires_format() -> None: + raw = {"externalId": "x", "settings": {}} + + with pytest.raises(KeyError, match="format"): + ExternalDataSource._load(raw) + + +def test_unknown_as_write_raises() -> None: + source = UnknownExternalDataSource(external_id="x", format="delta_sharing") + + with pytest.raises(ValueError, match="unknown external data source format"): + source.as_write() + + +def test_as_write_raises_without_client_secret() -> None: + raw = { + "externalId": "x", + "format": "one_lake", + "settings": { + "credentials": {"clientId": "cid", "tenantId": "tid"}, + "locationDescription": {"workspaceName": "ws", "containerName": "cn"}, + }, + } + read_source = ExternalDataSource._load(raw) + assert isinstance(read_source, OneLakeExternalDataSource) + + with pytest.raises(ValueError, match="client_secret is required"): + read_source.as_write() + + +def test_as_write_with_client_secret() -> None: + raw = { + "externalId": "x", + "format": "one_lake", + "settings": { + "credentials": {"clientId": "cid", "tenantId": "tid"}, + "locationDescription": {"workspaceName": "ws", "containerName": "cn"}, + }, + } + read_source = ExternalDataSource._load(raw) + assert isinstance(read_source, OneLakeExternalDataSource) + write_source = read_source.as_write(client_secret="new-secret") + + assert isinstance(write_source, OneLakeExternalDataSourceWrite) + assert write_source.settings is not None + assert write_source.settings.credentials is not None + assert write_source.settings.credentials.client_secret == "new-secret" + + +def test_credentials_write_repr_masks_secret() -> None: + creds = OneLakeCredentialsWrite("cid", "tid", "actual-secret") + result = repr(creds) + + assert "actual-secret" not in result + assert "" in result + + +def test_credentials_write_str_masks_secret() -> None: + creds = OneLakeCredentialsWrite("cid", "tid", "actual-secret") + result = str(creds) + + assert "actual-secret" not in result + assert "" in result