Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions pyatlan/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,13 @@ class ErrorCode(Enum):
"Ensure the file path does not point to a blocked location (system files, credential directories, or paths defined in PYATLAN_UPLOAD_FILE_BLOCKED_PATHS).",
InvalidRequestError,
)
INVALID_CONNECTION_QN = (
400,
"ATLAN-PYTHON-400-079",
"Invalid connectorType slug '{0}' for connection qualifiedName: must match pattern '^[a-z0-9-]+$' (lower-case alphanumerics and hyphens only).",
"Replace any underscores with hyphens (e.g. 'dev_cmdr' -> 'dev-cmdr'). Underscores, dots, uppercase letters, whitespace, and other characters are not permitted because the Atlan platform's asset-import path rejects them at ingestion time, leaving phantom Connection rows in Atlas. Mirrors the Java SDK constraint (atlan-java ErrorCode.INVALID_CONNECTION_QN).",
InvalidRequestError,
)
AUTHENTICATION_PASSTHROUGH = (
401,
"ATLAN-PYTHON-401-000",
Expand Down
39 changes: 39 additions & 0 deletions pyatlan/model/assets/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

from __future__ import annotations

import re
from datetime import datetime
from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional, Set
from warnings import warn

from pydantic.v1 import Field, validator

from pyatlan.errors import ErrorCode
from pyatlan.model.enums import (
AtlanConnectorType,
ConnectionDQEnvironmentSetupStatus,
Expand All @@ -31,6 +33,41 @@
from pyatlan.client.atlan import AtlanClient


#: Allowed characters for the connector-type slug embedded in
#: ``connectionQualifiedName`` (the ``{slug}`` in
#: ``default/{slug}/{epoch}``). Lower-case alphanumerics and hyphens
#: only — mirrors the Java SDK constraint and the Atlan platform's
#: server-side ``RAB`` (Reading Asset Bulk) / asset-import validation.
#: Tightening pyatlan to this pattern at creation time (BLDX-1294)
#: closes a gap where users could create connections with underscores
#: (or other characters) via pyatlan only to discover them rejected
#: by RAB at import time — leaving phantom Connection rows in Atlas.
_CONNECTOR_TYPE_VALUE_PATTERN: re.Pattern = re.compile(r"^[a-z0-9-]+$")


def _validate_connector_type_value(connector_type: AtlanConnectorType) -> None:
"""Reject ``connector_type`` values that wouldn't survive RAB / asset-import
validation server-side. See ``_CONNECTOR_TYPE_VALUE_PATTERN``.

Built-in :class:`AtlanConnectorType` members are all kebab-case and
therefore always pass; this guard exists for custom types created
via :meth:`AtlanConnectorType.CREATE_CUSTOM` where the caller-
supplied ``value`` could otherwise contain underscores, dots,
uppercase letters, or other characters that the platform rejects
later in the pipeline.

Raises:
InvalidRequestError: With error code
``ATLAN-PYTHON-400-079`` (``INVALID_CONNECTION_QN``) when the
slug fails the pattern check. Mirrors the Java SDK's
``ErrorCode.INVALID_CONNECTION_QN`` so cross-SDK error
reporting stays consistent.
"""
value = connector_type.value
if not _CONNECTOR_TYPE_VALUE_PATTERN.match(value):
raise ErrorCode.INVALID_CONNECTION_QN.exception_with_parameters(value)


class Connection(Asset, type_name="Connection"):
"""Description"""

Expand All @@ -51,6 +88,7 @@ def creator(
validate_required_fields(
["client", "name", "connector_type"], [client, name, connector_type]
)
_validate_connector_type_value(connector_type)
if not admin_users and not admin_groups and not admin_roles:
raise ValueError(
"One of admin_user, admin_groups or admin_roles is required"
Expand Down Expand Up @@ -102,6 +140,7 @@ async def creator_async(
validate_required_fields(
["client", "name", "connector_type"], [client, name, connector_type]
)
_validate_connector_type_value(connector_type)
if not admin_users and not admin_groups and not admin_roles:
raise ValueError(
"One of admin_user, admin_groups or admin_roles is required"
Expand Down
35 changes: 32 additions & 3 deletions pyatlan/test_utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,36 @@


class TestId:
# Slug-safe alphabet (lower-case alphanumerics only). Together with
# the hyphen separators in ``make_unique``, this guarantees every
# generated id matches ``^[a-z0-9-]+$`` — the same pattern the
# platform's asset-import (RAB) validator enforces for the
# connectorType segment of ``connectionQualifiedName``. Tests can
# therefore feed ``TestId.make_unique(...)`` directly into
# ``AtlanConnectorType.CREATE_CUSTOM(value=...)`` without tripping
# ``ErrorCode.INVALID_CONNECTION_QN`` (ATLAN-PYTHON-400-079, see
# BLDX-1294). The previous mixed-case + underscore-separated form
# caused every integration test that built a custom connector slug
# from ``MODULE_NAME`` to fail at fixture setup.
session_id = generate_nanoid(
alphabet="1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
alphabet="1234567890abcdefghijklmnopqrstuvwxyz",
size=5,
)

@classmethod
def make_unique(cls, input: str):
return f"psdk_{input}_{cls.session_id}"
"""Return a slug-safe, run-unique id of the form
``psdk-<input>-<session>`` where every segment matches
``^[a-z0-9-]+$``.

Input is lower-cased and any underscores are converted to
hyphens, so callers don't need to remember to slug-format the
input themselves. Backward-compat note for searches keyed on
the old ``psdk_`` prefix: switch to ``psdk-`` or strip the
separator entirely.
"""
slug = input.lower().replace("_", "-")
return f"psdk-{slug}-{cls.session_id}"


def get_random_connector():
Expand All @@ -57,7 +79,14 @@ def delete_token(client: AtlanClient, token: Optional[ApiToken] = None):
delete_tokens = [
token
for token in tokens
if token.display_name and "psdk_Requests" in token.display_name
# Match both pre-BLDX-1294 (``psdk_Requests``) and current
# (``psdk-requests``) display-name shapes so cleanup works
# across runs.
if token.display_name
and (
"psdk_Requests" in token.display_name
or "psdk-requests" in token.display_name
)
]
for token in delete_tokens:
assert token and token.guid # noqa: S101
Expand Down
3 changes: 3 additions & 0 deletions pyatlan_v9/model/assets/_overlays/connection.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# STDLIB_IMPORT: from typing import TYPE_CHECKING, List, Optional
# IMPORT: from pyatlan.model.enums import AtlanConnectorType
# INTERNAL_IMPORT: from pyatlan.model.assets.connection import _validate_connector_type_value
# INTERNAL_IMPORT: from pyatlan.utils import init_guid, validate_required_fields

@classmethod
Expand Down Expand Up @@ -38,6 +39,7 @@ def creator(
validate_required_fields(
["client", "name", "connector_type"], [client, name, connector_type]
)
_validate_connector_type_value(connector_type)
if not admin_users and not admin_groups and not admin_roles:
raise ValueError(
"One of admin_user, admin_groups or admin_roles is required"
Expand Down Expand Up @@ -92,6 +94,7 @@ async def creator_async(
validate_required_fields(
["client", "name", "connector_type"], [client, name, connector_type]
)
_validate_connector_type_value(connector_type)
if not admin_users and not admin_groups and not admin_roles:
raise ValueError(
"One of admin_user, admin_groups or admin_roles is required"
Expand Down
3 changes: 3 additions & 0 deletions pyatlan_v9/model/assets/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import msgspec
from msgspec import UNSET, UnsetType

from pyatlan.model.assets.connection import _validate_connector_type_value
from pyatlan.model.enums import AtlanConnectorType
from pyatlan_v9.model.conversion_utils import (
categorize_relationships,
Expand Down Expand Up @@ -439,6 +440,7 @@ def creator(
validate_required_fields(
["client", "name", "connector_type"], [client, name, connector_type]
)
_validate_connector_type_value(connector_type)
if not admin_users and not admin_groups and not admin_roles:
raise ValueError(
"One of admin_user, admin_groups or admin_roles is required"
Expand Down Expand Up @@ -493,6 +495,7 @@ async def creator_async(
validate_required_fields(
["client", "name", "connector_type"], [client, name, connector_type]
)
_validate_connector_type_value(connector_type)
if not admin_users and not admin_groups and not admin_roles:
raise ValueError(
"One of admin_user, admin_groups or admin_roles is required"
Expand Down
6 changes: 3 additions & 3 deletions tests/integration/aio/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def custom_connection(
) -> AsyncGenerator[Connection, None]:
CUSTOM_CONNECTOR_TYPE = AtlanConnectorType.CREATE_CUSTOM(
name=f"{MODULE_NAME}_NAME",
value=f"{MODULE_NAME}_type",
value=f"{MODULE_NAME}-type",
category=AtlanConnectionCategory.API,
)
result = await create_connection_async(
Expand All @@ -52,7 +52,7 @@ async def custom_connection(

async def test_custom_connection(custom_connection: Connection):
assert custom_connection.name == MODULE_NAME
assert custom_connection.connector_name == f"{MODULE_NAME.lower()}_type"
assert custom_connection.connector_name == f"{MODULE_NAME.lower()}-type"
assert custom_connection.qualified_name
assert custom_connection.category == AtlanConnectionCategory.API

Expand All @@ -68,4 +68,4 @@ async def test_custom_connection_qualified_name(
)
assert found
assert found.name == MODULE_NAME
assert found.connector_name == f"{MODULE_NAME.lower()}_type"
assert found.connector_name == f"{MODULE_NAME.lower()}-type"
4 changes: 4 additions & 0 deletions tests/integration/aio/test_index_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,8 +404,12 @@ async def _assert_search_results(
@patch.object(LOGGER, "debug")
async def test_search_pagination(mock_logger, client: AsyncAtlanClient):
# Avoid testing on integration tests objects
# Match both legacy ``psdk_*`` (pre-BLDX-1294, underscore separator)
# and current ``psdk-*`` (slug-safe hyphen separator) test-asset
# names so older runs' artifacts aren't accidentally pulled in.
exclude_sdk_terms = [
Asset.NAME.wildcard("psdk_*"),
Asset.NAME.wildcard("psdk-*"),
Asset.NAME.wildcard("jsdk_*"),
Asset.NAME.wildcard("gsdk_*"),
]
Expand Down
8 changes: 7 additions & 1 deletion tests/integration/aio/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ async def delete_token_async(
delete_tokens = [
token
for token in tokens
if token.display_name and "psdk_async" in token.display_name
# Match both pre-BLDX-1294 (``psdk_async``) and current
# (``psdk-async``) display-name shapes so cleanup works
# across runs.
if token.display_name
and (
"psdk_async" in token.display_name or "psdk-async" in token.display_name
)
]
for token in delete_tokens:
assert token and token.guid
Expand Down
11 changes: 9 additions & 2 deletions tests/integration/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,23 @@


class TestId:
# Mirrors :class:`pyatlan.test_utils.TestId` — kept in sync so a
# value passed to ``AtlanConnectorType.CREATE_CUSTOM`` matches the
# platform's ``^[a-z0-9-]+$`` connectorType slug rule (BLDX-1294 /
# ATLAN-PYTHON-400-079). Lowercase alphanumeric session_id + hyphen
# separators + lowercased+hyphenated input.
from nanoid import generate as generate_nanoid # type: ignore

session_id = generate_nanoid(
alphabet="1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
alphabet="1234567890abcdefghijklmnopqrstuvwxyz",
size=5,
)

@classmethod
def make_unique(cls, input: str):
return f"psdk_{input}_{cls.session_id}"
"""See :meth:`pyatlan.test_utils.TestId.make_unique`."""
slug = input.lower().replace("_", "-")
return f"psdk-{slug}-{cls.session_id}"


@pytest.fixture(scope="module")
Expand Down
8 changes: 4 additions & 4 deletions tests/integration/connection_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def create_connection(
def custom_connection(client: AtlanClient) -> Generator[Connection, None, None]:
CUSTOM_CONNECTOR_TYPE = AtlanConnectorType.CREATE_CUSTOM(
name=f"{MODULE_NAME}_NAME",
value=f"{MODULE_NAME}_type",
value=f"{MODULE_NAME}-type",
category=AtlanConnectionCategory.API,
)
result = create_connection(
Expand All @@ -46,11 +46,11 @@ def custom_connection(client: AtlanClient) -> Generator[Connection, None, None]:

def test_custom_connection(custom_connection: Connection):
assert custom_connection.name == MODULE_NAME
assert custom_connection.connector_name == f"{MODULE_NAME.lower()}_type"
assert custom_connection.connector_name == f"{MODULE_NAME.lower()}-type"
assert custom_connection.qualified_name
assert f"default/{MODULE_NAME.lower()}_type" in custom_connection.qualified_name
assert f"default/{MODULE_NAME.lower()}-type" in custom_connection.qualified_name
assert (
AtlanConnectorType[f"{MODULE_NAME}_NAME"].value == f"{MODULE_NAME.lower()}_type"
AtlanConnectorType[f"{MODULE_NAME}_NAME"].value == f"{MODULE_NAME.lower()}-type"
)


Expand Down
11 changes: 10 additions & 1 deletion tests/integration/requests_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,16 @@ def delete_token(token_client: AtlanClient, token: Optional[ApiToken] = None):
delete_tokens = [
token
for token in tokens
if token.display_name and "psdk_Requests" in token.display_name
# Match both pre-BLDX-1294 (``psdk_Requests``, mixed-case
# underscore-separated) and current (``psdk-requests``,
# lower-case hyphen-separated) display-name shapes — so a
# cleanup that catches partial failures from older sessions
# still works.
if token.display_name
and (
"psdk_Requests" in token.display_name
or "psdk-requests" in token.display_name
)
]
for token in delete_tokens:
assert token and token.guid
Expand Down
4 changes: 4 additions & 0 deletions tests/integration/test_index_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,8 +385,12 @@ def _assert_search_results(results, expected_sorts, size, TOTAL_ASSETS, bulk=Fal
@patch.object(LOGGER, "debug")
def test_search_pagination(mock_logger, client: AtlanClient):
# Avoid testing on integration tests objects
# Match both legacy ``psdk_*`` (pre-BLDX-1294, underscore separator)
# and current ``psdk-*`` (slug-safe hyphen separator) test-asset
# names so older runs' artifacts aren't accidentally pulled in.
exclude_sdk_terms = [
Asset.NAME.wildcard("psdk_*"),
Asset.NAME.wildcard("psdk-*"),
Asset.NAME.wildcard("jsdk_*"),
Asset.NAME.wildcard("gsdk_*"),
]
Expand Down
Loading
Loading