diff --git a/pyatlan/errors.py b/pyatlan/errors.py
index efc42d3a3..2a51e279d 100644
--- a/pyatlan/errors.py
+++ b/pyatlan/errors.py
@@ -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",
diff --git a/pyatlan/model/assets/connection.py b/pyatlan/model/assets/connection.py
index c4969a01d..334b1e33e 100644
--- a/pyatlan/model/assets/connection.py
+++ b/pyatlan/model/assets/connection.py
@@ -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,
@@ -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"""
@@ -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"
@@ -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"
diff --git a/pyatlan/test_utils/__init__.py b/pyatlan/test_utils/__init__.py
index 01f485090..bc69c7814 100644
--- a/pyatlan/test_utils/__init__.py
+++ b/pyatlan/test_utils/__init__.py
@@ -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--`` 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():
@@ -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
diff --git a/pyatlan_v9/model/assets/_overlays/connection.py b/pyatlan_v9/model/assets/_overlays/connection.py
index 95f815543..bbf47d956 100644
--- a/pyatlan_v9/model/assets/_overlays/connection.py
+++ b/pyatlan_v9/model/assets/_overlays/connection.py
@@ -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
@@ -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"
@@ -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"
diff --git a/pyatlan_v9/model/assets/connection.py b/pyatlan_v9/model/assets/connection.py
index 4976accd2..07b312028 100644
--- a/pyatlan_v9/model/assets/connection.py
+++ b/pyatlan_v9/model/assets/connection.py
@@ -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,
@@ -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"
@@ -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"
diff --git a/tests/integration/aio/test_connection.py b/tests/integration/aio/test_connection.py
index 2cf409e3b..ab661a025 100644
--- a/tests/integration/aio/test_connection.py
+++ b/tests/integration/aio/test_connection.py
@@ -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(
@@ -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
@@ -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"
diff --git a/tests/integration/aio/test_index_search.py b/tests/integration/aio/test_index_search.py
index 9cb26e964..d7fd19115 100644
--- a/tests/integration/aio/test_index_search.py
+++ b/tests/integration/aio/test_index_search.py
@@ -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_*"),
]
diff --git a/tests/integration/aio/utils.py b/tests/integration/aio/utils.py
index 095ed1639..dbabd3998 100644
--- a/tests/integration/aio/utils.py
+++ b/tests/integration/aio/utils.py
@@ -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
diff --git a/tests/integration/client.py b/tests/integration/client.py
index 4762863b7..6623a576e 100644
--- a/tests/integration/client.py
+++ b/tests/integration/client.py
@@ -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")
diff --git a/tests/integration/connection_test.py b/tests/integration/connection_test.py
index 8fc2329dd..92aab7939 100644
--- a/tests/integration/connection_test.py
+++ b/tests/integration/connection_test.py
@@ -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(
@@ -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"
)
diff --git a/tests/integration/requests_test.py b/tests/integration/requests_test.py
index f4a059b10..ea573fd44 100644
--- a/tests/integration/requests_test.py
+++ b/tests/integration/requests_test.py
@@ -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
diff --git a/tests/integration/test_index_search.py b/tests/integration/test_index_search.py
index 0b9c02ccd..59cda5a97 100644
--- a/tests/integration/test_index_search.py
+++ b/tests/integration/test_index_search.py
@@ -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_*"),
]
diff --git a/tests/unit/model/connection_test.py b/tests/unit/model/connection_test.py
index 495c460e9..ea19bbac9 100644
--- a/tests/unit/model/connection_test.py
+++ b/tests/unit/model/connection_test.py
@@ -5,6 +5,7 @@
from pyatlan.client.atlan import AtlanClient
from pyatlan.client.token import TokenClient
+from pyatlan.errors import InvalidRequestError
from pyatlan.model.assets import Connection
from pyatlan.model.enums import AtlanConnectionCategory, AtlanConnectorType
from tests.unit.model.constants import CONNECTION_NAME, CONNECTION_QUALIFIED_NAME
@@ -321,3 +322,127 @@ def test_validation_of_admin_not_done_when_constructed_from_json(
mock_role_cache.validate_idstrs.assert_not_called()
mock_group_cache.validate_aliases.assert_not_called()
mock_user_cache.validate_names.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# BLDX-1294 — connector-type value regex validation in Connection.creator
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "bad_value",
+ [
+ "dev_cmdr", # underscore — the original reported case
+ "dev.cmdr", # dot
+ "dev cmdr", # whitespace
+ "dev/cmdr", # slash
+ "dev@cmdr", # special char
+ "dev_", # trailing underscore
+ ],
+)
+def test_creator_rejects_invalid_connector_type_value(
+ client: AtlanClient,
+ bad_value: str,
+ mock_role_cache,
+ mock_user_cache,
+ mock_group_cache,
+):
+ """BLDX-1294: Connection.creator() must reject custom connector_type values
+ whose slug doesn't match the platform's [a-z0-9-]+ pattern. Without this,
+ callers create Connections via pyatlan that the server-side asset-import
+ path later rejects, leaving phantom Connection rows in Atlas."""
+ custom = AtlanConnectorType.CREATE_CUSTOM(
+ name=bad_value.upper().replace("-", "_") or "EMPTY",
+ value=bad_value,
+ category=AtlanConnectionCategory.CUSTOM,
+ )
+
+ with pytest.raises(InvalidRequestError) as exc_info:
+ Connection.creator(
+ client=client,
+ name=CONNECTION_NAME,
+ connector_type=custom,
+ admin_users=["ernest"],
+ )
+ # Match the Java SDK convention — typed error with code
+ # ATLAN-PYTHON-400-079 (INVALID_CONNECTION_QN) and the bad slug
+ # surfaced in the message.
+ assert "ATLAN-PYTHON-400-079" in str(exc_info.value)
+ assert bad_value in str(exc_info.value)
+
+
+@pytest.mark.parametrize(
+ "good_value",
+ [
+ "dev-cmdr", # hyphen — the recommended replacement
+ "snowflake", # built-in-like slug
+ "amazon-msk", # hyphenated multi-word
+ "a", # single-char
+ "abc123", # alphanumerics
+ "123-abc-456", # mixed digit + alpha + hyphen
+ ],
+)
+def test_creator_accepts_valid_connector_type_value(
+ client: AtlanClient,
+ good_value: str,
+ mock_role_cache,
+ mock_user_cache,
+ mock_group_cache,
+):
+ """Valid lowercase-alphanumeric-and-hyphen slugs continue to work."""
+ custom = AtlanConnectorType.CREATE_CUSTOM(
+ name="CUSTOM",
+ value=good_value,
+ category=AtlanConnectionCategory.CUSTOM,
+ )
+
+ sut = Connection.creator(
+ client=client,
+ name=CONNECTION_NAME,
+ connector_type=custom,
+ admin_users=["ernest"],
+ )
+ assert sut.name == CONNECTION_NAME
+ assert sut.qualified_name.startswith(f"default/{good_value}/")
+
+
+def test_creator_accepts_builtin_connector_types(
+ client: AtlanClient,
+ mock_role_cache,
+ mock_user_cache,
+ mock_group_cache,
+):
+ """Built-in AtlanConnectorType members have always-valid slugs.
+ Regression pin — the new BLDX-1294 validator must not break them."""
+ sut = Connection.creator(
+ client=client,
+ name=CONNECTION_NAME,
+ connector_type=AtlanConnectorType.SNOWFLAKE,
+ admin_users=["ernest"],
+ )
+ assert sut.qualified_name.startswith("default/snowflake/")
+
+
+@pytest.mark.asyncio
+async def test_creator_async_rejects_invalid_connector_type_value(
+ client: AtlanClient,
+ mock_role_cache,
+ mock_user_cache,
+ mock_group_cache,
+):
+ """Same validation must apply to the async creator path."""
+ bad = AtlanConnectorType.CREATE_CUSTOM(
+ name="DEV_CMDR",
+ value="dev_cmdr",
+ category=AtlanConnectionCategory.CUSTOM,
+ )
+
+ with pytest.raises(InvalidRequestError) as exc_info:
+ await Connection.creator_async(
+ client=client,
+ name=CONNECTION_NAME,
+ connector_type=bad,
+ admin_users=["ernest"],
+ )
+ assert "ATLAN-PYTHON-400-079" in str(exc_info.value)
+ assert "dev_cmdr" in str(exc_info.value)
diff --git a/tests_v9/integration/aio/test_connection.py b/tests_v9/integration/aio/test_connection.py
index c36271b5e..131aa8197 100644
--- a/tests_v9/integration/aio/test_connection.py
+++ b/tests_v9/integration/aio/test_connection.py
@@ -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(
@@ -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
@@ -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"
diff --git a/tests_v9/integration/aio/test_index_search.py b/tests_v9/integration/aio/test_index_search.py
index 2cf094ea2..cb9e52919 100644
--- a/tests_v9/integration/aio/test_index_search.py
+++ b/tests_v9/integration/aio/test_index_search.py
@@ -405,6 +405,7 @@ async def test_search_pagination(mock_logger, client: AsyncAtlanClient):
# Avoid testing on integration tests objects
exclude_sdk_terms = [
Asset.NAME.wildcard("psdkv9_*"),
+ Asset.NAME.wildcard("psdkv9-*"),
Asset.NAME.wildcard("jsdk_*"),
Asset.NAME.wildcard("gsdk_*"),
]
diff --git a/tests_v9/integration/aio/utils.py b/tests_v9/integration/aio/utils.py
index cf4231264..d78edab98 100644
--- a/tests_v9/integration/aio/utils.py
+++ b/tests_v9/integration/aio/utils.py
@@ -44,7 +44,14 @@ async def delete_token_async(
delete_tokens = [
token
for token in tokens
- if token.display_name and "psdkv9_Async" in token.display_name
+ # Match both pre-BLDX-1294 (``psdkv9_Async``) and current
+ # (``psdkv9-async``) display-name shapes so cleanup works
+ # across runs.
+ if token.display_name
+ and (
+ "psdkv9_Async" in token.display_name
+ or "psdkv9-async" in token.display_name
+ )
]
for token in delete_tokens:
assert token and token.guid
diff --git a/tests_v9/integration/client.py b/tests_v9/integration/client.py
index e8c4864cb..198e05d16 100644
--- a/tests_v9/integration/client.py
+++ b/tests_v9/integration/client.py
@@ -15,16 +15,22 @@
class TestId:
+ # Mirrors :class:`tests.integration.client.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"psdkv9_{input}_{cls.session_id}"
+ slug = input.lower().replace("_", "-")
+ return f"psdkv9-{slug}-{cls.session_id}"
@pytest.fixture(scope="module")
diff --git a/tests_v9/integration/connection_test.py b/tests_v9/integration/connection_test.py
index e80d706d3..2a22a9a06 100644
--- a/tests_v9/integration/connection_test.py
+++ b/tests_v9/integration/connection_test.py
@@ -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(
@@ -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"
)
diff --git a/tests_v9/integration/requests_test.py b/tests_v9/integration/requests_test.py
index 1dc77468b..19389643e 100644
--- a/tests_v9/integration/requests_test.py
+++ b/tests_v9/integration/requests_test.py
@@ -26,7 +26,14 @@ def delete_token(token_client: AtlanClient, token: Optional[ApiToken] = None):
delete_tokens = [
token
for token in tokens
- if token.display_name and "psdkv9_Requests" in token.display_name
+ # Match both pre-BLDX-1294 (``psdkv9_Requests``) and current
+ # (``psdkv9-requests``) display-name shapes so cleanup works
+ # across runs.
+ if token.display_name
+ and (
+ "psdkv9_Requests" in token.display_name
+ or "psdkv9-requests" in token.display_name
+ )
]
for token in delete_tokens:
assert token and token.guid
diff --git a/tests_v9/integration/test_index_search.py b/tests_v9/integration/test_index_search.py
index 9f4be2eea..8ee870f57 100644
--- a/tests_v9/integration/test_index_search.py
+++ b/tests_v9/integration/test_index_search.py
@@ -386,6 +386,7 @@ def test_search_pagination(mock_logger, client: AtlanClient):
# Avoid testing on integration tests objects
exclude_sdk_terms = [
Asset.NAME.wildcard("psdkv9_*"),
+ Asset.NAME.wildcard("psdkv9-*"),
Asset.NAME.wildcard("jsdk_*"),
Asset.NAME.wildcard("gsdk_*"),
]
diff --git a/tests_v9/unit/model/connection_test.py b/tests_v9/unit/model/connection_test.py
index ff7639051..aa208e9c2 100644
--- a/tests_v9/unit/model/connection_test.py
+++ b/tests_v9/unit/model/connection_test.py
@@ -9,6 +9,7 @@
import pytest
from msgspec import UNSET
+from pyatlan.errors import InvalidRequestError
from pyatlan_v9.client.atlan import AtlanClient
from pyatlan_v9.model import Connection
from pyatlan_v9.model.enums import AtlanConnectionCategory, AtlanConnectorType
@@ -390,3 +391,68 @@ def test_type_name_defaults():
"""Test that type_name defaults to 'Connection'."""
conn = Connection(name=CONNECTION_NAME, qualified_name=CONNECTION_QUALIFIED_NAME)
assert conn.type_name == "Connection"
+
+
+# ---------------------------------------------------------------------------
+# BLDX-1294 — connector-type value regex validation (mirrors pyatlan)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "bad_value",
+ [
+ "dev_cmdr", # underscore — the original reported case
+ "dev.cmdr", # dot
+ "dev cmdr", # whitespace
+ "dev/cmdr", # slash
+ "dev@cmdr", # special char
+ "dev_", # trailing underscore
+ ],
+)
+def test_creator_rejects_invalid_connector_type_value_v9(
+ client: AtlanClient,
+ bad_value: str,
+ mock_role_cache,
+ mock_user_cache,
+ mock_group_cache,
+):
+ """BLDX-1294: pyatlan_v9 Connection.creator() must enforce the same
+ [a-z0-9-]+ slug rule as pyatlan to keep parity with the platform's
+ asset-import / RAB validation."""
+ custom = AtlanConnectorType.CREATE_CUSTOM(
+ name=bad_value.upper().replace("-", "_") or "BADNAME",
+ value=bad_value,
+ category=AtlanConnectionCategory.CUSTOM,
+ )
+
+ with pytest.raises(InvalidRequestError) as exc_info:
+ Connection.creator(
+ client=client,
+ name=CONNECTION_NAME,
+ connector_type=custom,
+ admin_users=["ernest"],
+ )
+ assert "ATLAN-PYTHON-400-079" in str(exc_info.value)
+ assert bad_value in str(exc_info.value)
+
+
+def test_creator_accepts_valid_connector_type_value_v9(
+ client: AtlanClient,
+ mock_role_cache,
+ mock_user_cache,
+ mock_group_cache,
+):
+ """Valid slugs (hyphen-only) continue to work in v9."""
+ custom = AtlanConnectorType.CREATE_CUSTOM(
+ name="DEV_CMDR",
+ value="dev-cmdr",
+ category=AtlanConnectionCategory.CUSTOM,
+ )
+
+ sut = Connection.creator(
+ client=client,
+ name=CONNECTION_NAME,
+ connector_type=custom,
+ admin_users=["ernest"],
+ )
+ assert sut.qualified_name.startswith("default/dev-cmdr/")