Skip to content

Commit

Permalink
Merge pull request #491 from atlanhq/APP-5019
Browse files Browse the repository at this point in the history
APP-5019: Add `CrendentialClient.creator()` (with the optional "host" and "port" params)
  • Loading branch information
Aryamanz29 authored Feb 4, 2025
2 parents e39cac0 + 099b973 commit 58ff4cd
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 32 deletions.
8 changes: 7 additions & 1 deletion pyatlan/client/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,12 +512,18 @@
HTTPStatus.OK,
endpoint=EndPoint.HERACLES,
)
CREATE_OL_CREDENTIALS = API(
CREATE_CREDENTIALS = API(
CREDENTIALS_API,
HTTPMethod.POST,
HTTPStatus.OK,
EndPoint.HERACLES,
)
DELETE_CREDENTIALS_BY_GUID = API(
CREDENTIALS_API + "/{credential_guid}/archive",
HTTPMethod.POST,
HTTPStatus.OK,
endpoint=EndPoint.HERACLES,
)
AUDIT_API = "entity/auditSearch"
AUDIT_SEARCH = API(AUDIT_API, HTTPMethod.POST, HTTPStatus.OK, endpoint=EndPoint.ATLAS)
SEARCH_LOG_API = "search/searchlog"
Expand Down
34 changes: 34 additions & 0 deletions pyatlan/client/credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
GET_CREDENTIAL_BY_GUID,
TEST_CREDENTIAL,
UPDATE_CREDENTIAL_BY_GUID,
CREATE_CREDENTIALS,
DELETE_CREDENTIALS_BY_GUID,
)
from pyatlan.errors import ErrorCode
from pyatlan.model.credential import (
Expand All @@ -34,6 +36,22 @@ def __init__(self, client: ApiCaller):
)
self._client = client

@validate_arguments
def creator(self, credential: Credential) -> CredentialResponse:
"""
Create a new credential.
:param credential: provide full details of the credential's to be created.
:returns: A CredentialResponse instance.
:raises ValidationError: If the provided `credential` is invalid.
"""
raw_json = self._client._call_api(
api=CREATE_CREDENTIALS.format_path_with_params(),
query_params={"testCredential": "true"},
request_obj=credential,
)
return CredentialResponse(**raw_json)

@validate_arguments
def get(self, guid: str) -> CredentialResponse:
"""
Expand Down Expand Up @@ -88,6 +106,22 @@ def get_all(
)
return CredentialListResponse(records=raw_json.get("records") or [])

@validate_arguments
def purge_by_guid(self, guid: str) -> CredentialResponse:
"""
Hard-deletes (purges) credential by their unique identifier (GUID).
This operation is irreversible.
:param guid: unique identifier(s) (GUIDs) of credential to hard-delete
:returns: details of the hard-deleted asset(s)
:raises AtlanError: on any API communication issue
"""
raw_json = self._client._call_api(
DELETE_CREDENTIALS_BY_GUID.format_path({"credential_guid": guid})
)

return raw_json

@validate_arguments
def test(self, credential: Credential) -> CredentialTestResponse:
"""
Expand Down
49 changes: 19 additions & 30 deletions pyatlan/client/open_lineage.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

from pyatlan import utils
from pyatlan.client.common import ApiCaller
from pyatlan.client.constants import CREATE_OL_CREDENTIALS, OPEN_LINEAGE_SEND_EVENT_API
from pyatlan.client.constants import OPEN_LINEAGE_SEND_EVENT_API
from pyatlan.errors import AtlanError, ErrorCode
from pyatlan.model.assets import Connection
from pyatlan.model.credential import CredentialResponse
from pyatlan.model.credential import Credential
from pyatlan.model.enums import AtlanConnectorType
from pyatlan.model.open_lineage.event import OpenLineageEvent
from pyatlan.model.response import AssetMutationResponse
Expand All @@ -26,33 +26,6 @@ def __init__(self, client: ApiCaller):
)
self._client = client

def _create_credential(self, connector_name: str) -> CredentialResponse:
"""
Creates an OpenLineage credential for the specified connector.
:param connector_name: of the connection that should be OpenLineage event
:return: details of the created credential
"""
body = {
"authType": "atlan_api_key",
"name": f"default-{connector_name}-{int(utils.get_epoch_timestamp())}-0",
"connectorConfigName": f"atlan-connectors-{connector_name}",
"connector": f"{connector_name}",
"connectorType": "event",
"extra": {
"events.enable-partial-assets": True,
"events.enabled": True,
"events.topic": f"openlineage_{connector_name}",
"events.urlPath": f"/events/openlineage/{connector_name}/api/v1/lineage",
},
}
raw_json = self._client._call_api(
api=CREATE_OL_CREDENTIALS.format_path_with_params(),
query_params={"testCredential": "true"},
request_obj=body,
)
return CredentialResponse(**raw_json)

@validate_arguments
def create_connection(
self,
Expand All @@ -76,7 +49,23 @@ def create_connection(

client = AtlanClient.get_default_client()

response = self._create_credential(connector_name=connector_type.value)
create_credential = Credential()
create_credential.auth_type = "atlan_api_key"
create_credential.name = (
f"default-{connector_type.value}-{int(utils.get_epoch_timestamp())}-0"
)
create_credential.connector = str(connector_type.value)
create_credential.connector_config_name = (
f"atlan-connectors-{connector_type.value}"
)
create_credential.connector_type = "event"
create_credential.extras = {
"events.enable-partial-assets": True,
"events.enabled": True,
"events.topic": f"openlineage_{connector_type.value}",
"events.urlPath": f"/events/openlineage/{connector_type.value}/api/v1/lineage",
}
response = client.credentials.creator(credential=create_credential)
connection = Connection.creator(
name=name,
connector_type=connector_type,
Expand Down
4 changes: 4 additions & 0 deletions pyatlan/model/assets/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ def creator(
admin_users: Optional[List[str]] = None,
admin_groups: Optional[List[str]] = None,
admin_roles: Optional[List[str]] = None,
host: Optional[str] = None,
port: Optional[int] = None,
) -> Connection:
validate_required_fields(["name", "connector_type"], [name, connector_type])
if not admin_users and not admin_groups and not admin_roles:
Expand All @@ -50,6 +52,8 @@ def creator(
attr.admin_users = set() if admin_users is None else set(admin_users)
attr.admin_groups = set() if admin_groups is None else set(admin_groups)
attr.admin_roles = set() if admin_roles is None else set(admin_roles)
attr.host = host
attr.port = port
return cls(attributes=attr)

@classmethod
Expand Down
12 changes: 11 additions & 1 deletion pyatlan/model/credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ class Credential(AtlanObject):
default=None, description="Unique identifier (GUID) of the credential."
)
name: Optional[str] = Field(default=None, description="Name of the credential.")
description: Optional[str] = Field(
default=None, description="Description of the credential."
)
host: Optional[str] = Field(
default=None,
description="Hostname for which connectivity is defined by the credential.",
Expand All @@ -22,7 +25,8 @@ class Credential(AtlanObject):
description="Authentication mechanism represented by the credential.",
)
connector_type: Optional[str] = Field(
default=None, description="Type of connector used by the credential."
default=None,
description="Type of connector used by the credential.",
)
username: Optional[str] = Field(
default=None,
Expand Down Expand Up @@ -51,6 +55,12 @@ class Credential(AtlanObject):
default=None,
description="Name of the connector configuration responsible for managing the credential.",
)
metadata: Optional[Dict[str, Any]] = Field(default=None, description="TBD")
level: Optional[Union[Dict[str, Any], str]] = Field(default=None, description="TBD")
connector: Optional[str] = Field(
default=None,
description="Name of the connector used by the credential",
)


class CredentialResponse(AtlanObject):
Expand Down
54 changes: 54 additions & 0 deletions tests/integration/test_workflow_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@

import pytest

from pyatlan import utils
from pyatlan.client.atlan import AtlanClient
from pyatlan.client.workflow import WorkflowClient
from pyatlan.model.assets import Connection
from pyatlan.model.credential import Credential, CredentialResponse
from pyatlan.model.enums import AtlanConnectorType, AtlanWorkflowPhase, WorkflowPackage
from pyatlan.model.packages.snowflake_miner import SnowflakeMiner
from pyatlan.model.workflow import WorkflowResponse, WorkflowSchedule
Expand All @@ -26,6 +28,41 @@
WORKFLOW_SCHEDULE_TIMEZONE_UPDATED_3 = "Europe/Dublin"


@pytest.fixture(scope="module")
def create_credentials(
client: AtlanClient,
) -> Generator[CredentialResponse, None, None]:
"""Creates a new credential using the Atlan API."""
credentials_name = f"default-spark-{int(utils.get_epoch_timestamp())}-0"

credentials = Credential(
name=credentials_name,
auth_type="atlan_api_key",
connector_config_name="atlan-connectors-spark",
connector="spark",
username="test-username",
password="12345",
connector_type="event",
host="test-host",
port=123,
)

create_credentials = client.credentials.creator(credentials)
guid = create_credentials.id
if guid is None:
raise ValueError("Failed to retrieve GUID from created credentials.")

yield create_credentials

response = delete_credentials(client, guid=guid)
assert response is None


def delete_credentials(client: AtlanClient, guid: str):
response = client.credentials.purge_by_guid(guid=guid)
return response


@pytest.fixture(scope="module")
def connection(client: AtlanClient) -> Generator[Connection, None, None]:
connection = create_connection(
Expand Down Expand Up @@ -271,6 +308,23 @@ def test_workflow_add_remove_schedule(client: AtlanClient, workflow: WorkflowRes
_assert_remove_schedule(response, workflow)


def test_credentials(client: AtlanClient, create_credentials: Credential):
credentials = create_credentials
assert credentials
assert credentials.id
reterieved_creds = client.credentials.get(guid=credentials.id)
assert reterieved_creds.auth_type == "atlan_api_key"
assert reterieved_creds.connector_config_name == "atlan-connectors-spark"
assert reterieved_creds.connector == "spark"
assert reterieved_creds.username == "test-username"
assert create_credentials.connector_type == "event"
assert create_credentials.host == "test-host"
assert create_credentials.port == 123
assert create_credentials.extras is None
assert create_credentials.level is None
assert create_credentials.metadata is None


def test_get_all_credentials(client: AtlanClient):
credentials = client.credentials.get_all()
assert credentials, "Expected credentials but found None"
Expand Down
77 changes: 77 additions & 0 deletions tests/unit/test_credential_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
TEST_INVALID_GUID_GET_VALIDATION_ERR = (
"1 validation error for Get\nguid\n str type expected (type=type_error.str)"
)
TEST_INVALID_GUID_PURGE_BY_GUID_VALIDATION_ERR = (
"1 validation error for PurgeByGuid\n"
"guid\n str type expected (type=type_error.str)"
)
TEST_INVALID_CRED_TEST_VALIDATION_ERR = (
"1 validation error for Test\ncredential\n "
"value is not a valid dict (type=type_error.dict)"
Expand All @@ -32,6 +36,10 @@
"1 validation error for TestAndUpdate\ncredential\n "
"value is not a valid dict (type=type_error.dict)"
)
TEST_INVALID_CRED_CREATOR_VALIDATION_ERR = (
"1 validation error for Creator\ncredential\n "
"value is not a valid dict (type=type_error.dict)"
)
TEST_INVALID_API_CALLER_PARAMETER_TYPE = (
"ATLAN-PYTHON-400-048 Invalid parameter type for client should be ApiCaller"
)
Expand Down Expand Up @@ -294,3 +302,72 @@ def test_cred_get_all_no_results(mock_api_caller):
assert isinstance(result, CredentialListResponse)
assert result.records == []
assert len(result.records) == 0


@pytest.mark.parametrize("create_credentials", ["invalid_cred", 123])
def test_cred_creator_wrong_params_raises_validation_error(
create_credentials, client: CredentialClient
):
with pytest.raises(ValidationError) as err:
client.creator(credential=create_credentials)
assert TEST_INVALID_CRED_CREATOR_VALIDATION_ERR == str(err.value)


@pytest.mark.parametrize(
"credential_data",
[
(
Credential(
name="test-name",
description="test-desc",
connector_config_name="test-ccn",
connector="test-conn",
connector_type="test-ct",
auth_type="test-at",
host="test-host",
port=123,
username="test-username",
extra={"some": "value"},
)
),
],
)
def test_creator_success(
credential_data,
credential_response: CredentialResponse,
mock_api_caller,
client: CredentialClient,
):

mock_api_caller._call_api.return_value = credential_response.dict()
client = CredentialClient(mock_api_caller)

response = client.creator(credential=credential_data)

assert isinstance(response, CredentialResponse)
assert credential_data.name == response.name
assert credential_data.description == response.description
assert credential_data.port == response.port
assert credential_data.auth_type == response.auth_type
assert credential_data.connector_type == response.connector_type
assert credential_data.connector_config_name == response.connector_config_name
assert credential_data.username == response.username
assert credential_data.extras == response.extras
assert response.level is None


@pytest.mark.parametrize("test_guid", [[123], set(), dict()])
def test_cred_purge_by_guid_wrong_params_raises_validation_error(
test_guid, client: CredentialClient
):
with pytest.raises(ValidationError) as err:
client.purge_by_guid(guid=test_guid)
assert TEST_INVALID_GUID_PURGE_BY_GUID_VALIDATION_ERR == str(err.value)


def test_cred_purge_by_guid_when_given_guid(
client: CredentialClient,
mock_api_caller,
):
mock_api_caller._call_api.return_value = None
assert client.purge_by_guid(guid="test-id") is None

0 comments on commit 58ff4cd

Please sign in to comment.