atlan-application-sdk-api 3.51.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- application_sdk/__init__.py +7 -0
- application_sdk/_build_info.py +62 -0
- application_sdk/_context_errors.py +65 -0
- application_sdk/_install.py +130 -0
- application_sdk/_logging.py +171 -0
- application_sdk/_runtime/__init__.py +29 -0
- application_sdk/_runtime/offload.py +1020 -0
- application_sdk/_runtime/progress.py +757 -0
- application_sdk/clients/__init__.py +63 -0
- application_sdk/clients/_interface.py +58 -0
- application_sdk/clients/_sql_failure_classifier.py +112 -0
- application_sdk/clients/base.py +565 -0
- application_sdk/clients/http_errors.py +28 -0
- application_sdk/clients/models.py +49 -0
- application_sdk/clients/sql.py +1218 -0
- application_sdk/clients/sql_errors.py +192 -0
- application_sdk/clients/sql_typecasters.py +431 -0
- application_sdk/clients/ssl_utils.py +247 -0
- application_sdk/common/__init__.py +53 -0
- application_sdk/common/_generated_tree.py +321 -0
- application_sdk/common/_listing.py +192 -0
- application_sdk/common/aws_utils.py +522 -0
- application_sdk/common/aws_utils_errors.py +88 -0
- application_sdk/common/build_identity.py +120 -0
- application_sdk/common/concurrency.py +45 -0
- application_sdk/common/errors.py +99 -0
- application_sdk/common/filter_matching.py +261 -0
- application_sdk/common/logger.py +11 -0
- application_sdk/common/models.py +31 -0
- application_sdk/common/sql_filters.py +719 -0
- application_sdk/common/sql_filters_errors.py +17 -0
- application_sdk/common/task_queue.py +431 -0
- application_sdk/common/types.py +51 -0
- application_sdk/common/utils.py +87 -0
- application_sdk/constants.py +1120 -0
- application_sdk/contracts/__init__.py +158 -0
- application_sdk/contracts/base.py +1178 -0
- application_sdk/contracts/events.py +251 -0
- application_sdk/contracts/types.py +801 -0
- application_sdk/contracts/types_errors.py +17 -0
- application_sdk/credentials/__init__.py +326 -0
- application_sdk/credentials/errors.py +226 -0
- application_sdk/credentials/extra.py +114 -0
- application_sdk/credentials/ingress.py +283 -0
- application_sdk/credentials/ref.py +376 -0
- application_sdk/credentials/spec.py +182 -0
- application_sdk/credentials/types.py +339 -0
- application_sdk/credentials/utils.py +257 -0
- application_sdk/errors/__init__.py +224 -0
- application_sdk/errors/base.py +504 -0
- application_sdk/errors/categories.py +118 -0
- application_sdk/errors/http.py +81 -0
- application_sdk/errors/leaves.py +526 -0
- application_sdk/errors/wire.py +124 -0
- application_sdk/handler/__init__.py +97 -0
- application_sdk/handler/_warmup.py +198 -0
- application_sdk/handler/asgi.py +252 -0
- application_sdk/handler/base.py +281 -0
- application_sdk/handler/context.py +212 -0
- application_sdk/handler/contracts.py +1166 -0
- application_sdk/handler/manifest.py +81 -0
- application_sdk/handler/metadata_routes.py +699 -0
- application_sdk/handler/object_routes.py +418 -0
- application_sdk/handler/request_contract.py +91 -0
- application_sdk/handler/routes.py +1118 -0
- application_sdk/handler/service_errors.py +37 -0
- application_sdk/server/__init__.py +15 -0
- application_sdk/server/fastapi/models.py +338 -0
- application_sdk/templates/__init__.py +52 -0
- application_sdk/templates/contracts/__init__.py +111 -0
- application_sdk/templates/contracts/base_metadata_extraction.py +32 -0
- application_sdk/templates/contracts/incremental_sql.py +563 -0
- application_sdk/templates/contracts/sql_metadata.py +824 -0
- application_sdk/templates/contracts/sql_query.py +105 -0
- application_sdk/version.py +40 -0
- atlan_application_sdk_api-3.51.0.dist-info/METADATA +230 -0
- atlan_application_sdk_api-3.51.0.dist-info/RECORD +78 -0
- atlan_application_sdk_api-3.51.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""SSL utilities for HTTP clients."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import os
|
|
5
|
+
import ssl
|
|
6
|
+
|
|
7
|
+
from application_sdk._logging import get_logger
|
|
8
|
+
from application_sdk.constants import SSL_CERT_DIR
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
# Supported certificate file extensions
|
|
13
|
+
CERT_FILE_EXTENSIONS = (".pem", ".crt", ".cer", ".ca-bundle")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_ssl_cert_dir() -> str | None:
|
|
17
|
+
"""
|
|
18
|
+
Get the SSL certificate directory from configuration.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
The SSL certificate directory path if SSL_CERT_DIR is set and the directory exists,
|
|
22
|
+
None otherwise.
|
|
23
|
+
"""
|
|
24
|
+
if SSL_CERT_DIR and os.path.isdir(SSL_CERT_DIR):
|
|
25
|
+
logger.debug("Using SSL certificates from directory: %s", SSL_CERT_DIR)
|
|
26
|
+
return SSL_CERT_DIR
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_certificate_files(cert_dir: str) -> list[str]:
|
|
31
|
+
"""
|
|
32
|
+
Get all certificate files from a directory.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
cert_dir: Directory to search for certificate files
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
List of full paths to certificate files
|
|
39
|
+
"""
|
|
40
|
+
cert_files: list[str] = []
|
|
41
|
+
for filename in os.listdir(cert_dir):
|
|
42
|
+
if filename.lower().endswith(CERT_FILE_EXTENSIONS):
|
|
43
|
+
cert_files.append(os.path.join(cert_dir, filename))
|
|
44
|
+
return cert_files
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_ssl_context_with_custom_certs(cert_dir: str) -> ssl.SSLContext:
|
|
48
|
+
"""
|
|
49
|
+
Create an SSL context that includes both default system certificates and custom certificates.
|
|
50
|
+
|
|
51
|
+
This ensures that connections to public services (using well-known CAs) continue to work,
|
|
52
|
+
while also allowing connections to private services using custom/internal certificates.
|
|
53
|
+
|
|
54
|
+
The function loads all .pem, .crt, .cer, and .ca-bundle files from the specified directory.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
cert_dir: Directory containing additional certificate files (.pem, .crt, .cer, .ca-bundle)
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
SSL context with both default and custom certificates loaded
|
|
61
|
+
"""
|
|
62
|
+
ssl_context = ssl.create_default_context()
|
|
63
|
+
|
|
64
|
+
cert_files = get_certificate_files(cert_dir)
|
|
65
|
+
|
|
66
|
+
for cert_file in cert_files:
|
|
67
|
+
try:
|
|
68
|
+
ssl_context.load_verify_locations(cafile=cert_file)
|
|
69
|
+
logger.debug("Loaded certificate from: %s", cert_file)
|
|
70
|
+
except ssl.SSLError:
|
|
71
|
+
logger.warning(
|
|
72
|
+
"Failed to load certificate from %s", cert_file, exc_info=True
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if cert_files:
|
|
76
|
+
logger.debug(
|
|
77
|
+
"Created SSL context with default certificates and %d custom certificate(s) from: %s",
|
|
78
|
+
len(cert_files),
|
|
79
|
+
cert_dir,
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
logger.debug(
|
|
83
|
+
"Created SSL context with default certificates (no custom certificates found in %s)",
|
|
84
|
+
cert_dir,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return ssl_context
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@functools.lru_cache(maxsize=1)
|
|
91
|
+
def get_ssl_context() -> bool | ssl.SSLContext:
|
|
92
|
+
"""
|
|
93
|
+
Get the SSL verification context for HTTP clients (httpx, aiohttp, etc.).
|
|
94
|
+
|
|
95
|
+
If SSL_CERT_DIR is set and points to a valid directory, returns an SSLContext
|
|
96
|
+
that includes both default system certificates AND custom certificates from that directory.
|
|
97
|
+
This allows connections to both public services and private services with custom CAs.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
An SSLContext with combined certificates if SSL_CERT_DIR is set,
|
|
101
|
+
True otherwise for default SSL verification.
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
>>> import httpx
|
|
105
|
+
>>> ssl_context = get_ssl_context()
|
|
106
|
+
>>> async with httpx.AsyncClient(verify=ssl_context) as client:
|
|
107
|
+
... response = await client.get("https://example.com")
|
|
108
|
+
|
|
109
|
+
>>> import aiohttp
|
|
110
|
+
>>> ssl_context = get_ssl_context()
|
|
111
|
+
>>> async with aiohttp.ClientSession() as session:
|
|
112
|
+
... async with session.get("https://example.com", ssl=ssl_context) as response:
|
|
113
|
+
... pass
|
|
114
|
+
"""
|
|
115
|
+
ssl_cert_dir = get_ssl_cert_dir()
|
|
116
|
+
if ssl_cert_dir:
|
|
117
|
+
return create_ssl_context_with_custom_certs(ssl_cert_dir)
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _get_default_ca_bundle_path() -> str | None:
|
|
122
|
+
"""
|
|
123
|
+
Get the path to the default CA certificate bundle.
|
|
124
|
+
|
|
125
|
+
Tries multiple sources in order:
|
|
126
|
+
1. certifi package (if available) - provides Mozilla CA bundle
|
|
127
|
+
2. SSL default verify paths from the system
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Path to the default CA bundle file, or None if not found.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
import certifi # noqa: PLC0415 — optional dep: certifi
|
|
134
|
+
|
|
135
|
+
certifi_path = certifi.where()
|
|
136
|
+
if certifi_path and os.path.isfile(certifi_path):
|
|
137
|
+
logger.debug("Using certifi CA bundle: %s", certifi_path)
|
|
138
|
+
return certifi_path
|
|
139
|
+
except ImportError:
|
|
140
|
+
logger.debug("certifi not available, falling back to system CA paths")
|
|
141
|
+
|
|
142
|
+
default_paths = ssl.get_default_verify_paths()
|
|
143
|
+
|
|
144
|
+
if default_paths.cafile and os.path.isfile(default_paths.cafile):
|
|
145
|
+
logger.debug("Using system CA file: %s", default_paths.cafile)
|
|
146
|
+
return default_paths.cafile
|
|
147
|
+
|
|
148
|
+
if default_paths.openssl_cafile and os.path.isfile(default_paths.openssl_cafile):
|
|
149
|
+
logger.debug("Using OpenSSL CA file: %s", default_paths.openssl_cafile)
|
|
150
|
+
return default_paths.openssl_cafile
|
|
151
|
+
|
|
152
|
+
logger.debug("No default CA bundle found")
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _read_default_ca_certs() -> bytes | None:
|
|
157
|
+
"""
|
|
158
|
+
Read the default system CA certificates as bytes.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The default CA certificates as bytes, or None if not available.
|
|
162
|
+
"""
|
|
163
|
+
ca_bundle_path = _get_default_ca_bundle_path()
|
|
164
|
+
if not ca_bundle_path:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
with open(ca_bundle_path, "rb") as f:
|
|
169
|
+
ca_bytes = f.read()
|
|
170
|
+
if ca_bytes:
|
|
171
|
+
logger.debug(
|
|
172
|
+
"Read default CA certificates (%d bytes) from: %s",
|
|
173
|
+
len(ca_bytes),
|
|
174
|
+
ca_bundle_path,
|
|
175
|
+
)
|
|
176
|
+
return ca_bytes
|
|
177
|
+
except OSError:
|
|
178
|
+
logger.warning(
|
|
179
|
+
"Failed to read default CA bundle from %s", ca_bundle_path, exc_info=True
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_custom_ca_cert_bytes() -> bytes | None:
|
|
186
|
+
"""
|
|
187
|
+
Get CA certificate bytes combining default system certificates AND custom certificates.
|
|
188
|
+
|
|
189
|
+
This is useful for clients like Temporal that use their own TLS implementation
|
|
190
|
+
and require certificate data as bytes rather than an ssl.SSLContext.
|
|
191
|
+
|
|
192
|
+
If SSL_CERT_DIR is set and points to a valid directory containing certificate files,
|
|
193
|
+
this function reads all certificate files and concatenates them with the default
|
|
194
|
+
system CA certificates. This ensures that both public services (using well-known CAs)
|
|
195
|
+
and private services (using custom CAs) are trusted.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Combined certificate bytes (default + custom) if SSL_CERT_DIR is set
|
|
199
|
+
and contains valid certificate files, None otherwise.
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
>>> from temporalio.service import TLSConfig
|
|
203
|
+
>>> ca_cert_bytes = get_custom_ca_cert_bytes()
|
|
204
|
+
>>> if ca_cert_bytes:
|
|
205
|
+
... tls_config = TLSConfig(server_root_ca_cert=ca_cert_bytes)
|
|
206
|
+
"""
|
|
207
|
+
ssl_cert_dir = get_ssl_cert_dir()
|
|
208
|
+
if not ssl_cert_dir:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
cert_files = get_certificate_files(ssl_cert_dir)
|
|
212
|
+
if not cert_files:
|
|
213
|
+
logger.debug("No certificate files found in %s", ssl_cert_dir)
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
cert_bytes_list: list[bytes] = []
|
|
217
|
+
|
|
218
|
+
default_ca_bytes = _read_default_ca_certs()
|
|
219
|
+
if default_ca_bytes:
|
|
220
|
+
cert_bytes_list.append(default_ca_bytes)
|
|
221
|
+
logger.debug("Added default system CA certificates to trust store")
|
|
222
|
+
|
|
223
|
+
custom_cert_count = 0
|
|
224
|
+
for cert_file in cert_files:
|
|
225
|
+
try:
|
|
226
|
+
with open(cert_file, "rb") as f:
|
|
227
|
+
cert_data = f.read()
|
|
228
|
+
if cert_data:
|
|
229
|
+
cert_bytes_list.append(cert_data)
|
|
230
|
+
custom_cert_count += 1
|
|
231
|
+
logger.debug("Read custom certificate from: %s", cert_file)
|
|
232
|
+
except OSError:
|
|
233
|
+
logger.warning(
|
|
234
|
+
"Failed to read certificate from %s", cert_file, exc_info=True
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if not cert_bytes_list:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
combined_certs = b"\n".join(cert_bytes_list)
|
|
241
|
+
logger.debug(
|
|
242
|
+
"Combined default CA certificates and %d custom certificate(s) from %s (%d bytes total)",
|
|
243
|
+
custom_cert_count,
|
|
244
|
+
ssl_cert_dir,
|
|
245
|
+
len(combined_certs),
|
|
246
|
+
)
|
|
247
|
+
return combined_certs
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Shared utilities for Atlan application connectors.
|
|
2
|
+
|
|
3
|
+
Public API::
|
|
4
|
+
|
|
5
|
+
from application_sdk.common import (
|
|
6
|
+
# SQL filter helpers
|
|
7
|
+
read_sql_files, prepare_query, prepare_filters, normalize_filters,
|
|
8
|
+
# App-agnostic filter matching (regex vs exact)
|
|
9
|
+
FilterPattern, filter_matches,
|
|
10
|
+
# Concurrency helpers
|
|
11
|
+
get_actual_cpu_count, get_safe_num_threads,
|
|
12
|
+
# Task models
|
|
13
|
+
TaskStatistics, TaskResult,
|
|
14
|
+
# DataFrame type enum
|
|
15
|
+
DataframeType,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
Note: ``application_sdk.common.error_codes`` is retained for back-compat with
|
|
19
|
+
v2 connectors. Prefer ``application_sdk.errors`` for new code.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from application_sdk.common.concurrency import (
|
|
23
|
+
get_actual_cpu_count,
|
|
24
|
+
get_safe_num_threads,
|
|
25
|
+
)
|
|
26
|
+
from application_sdk.common.filter_matching import FilterPattern, filter_matches
|
|
27
|
+
from application_sdk.common.models import TaskResult, TaskStatistics
|
|
28
|
+
from application_sdk.common.sql_filters import (
|
|
29
|
+
normalize_filters,
|
|
30
|
+
prepare_filters,
|
|
31
|
+
prepare_query,
|
|
32
|
+
read_sql_files,
|
|
33
|
+
)
|
|
34
|
+
from application_sdk.common.types import DataframeType
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# SQL filter helpers
|
|
38
|
+
"read_sql_files",
|
|
39
|
+
"prepare_query",
|
|
40
|
+
"prepare_filters",
|
|
41
|
+
"normalize_filters",
|
|
42
|
+
# App-agnostic filter matching (regex vs exact)
|
|
43
|
+
"FilterPattern",
|
|
44
|
+
"filter_matches",
|
|
45
|
+
# Concurrency helpers
|
|
46
|
+
"get_actual_cpu_count",
|
|
47
|
+
"get_safe_num_threads",
|
|
48
|
+
# Task models
|
|
49
|
+
"TaskStatistics",
|
|
50
|
+
"TaskResult",
|
|
51
|
+
# DataFrame type enum
|
|
52
|
+
"DataframeType",
|
|
53
|
+
]
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""The shape of the committed ``app/generated/`` tree, in one place.
|
|
2
|
+
|
|
3
|
+
Three facts about that tree were, until FND-1667, each written down twice:
|
|
4
|
+
|
|
5
|
+
* **which layout the tree has** — a bundle nests one directory per entry point,
|
|
6
|
+
while a single-generated-contract app (including a route/card-split app with
|
|
7
|
+
several ``@entrypoint``\\ s behind one card) emits everything flat.
|
|
8
|
+
:func:`generated_layout` is the authority, and
|
|
9
|
+
:mod:`application_sdk.app._artifact_schema_guard` delegates to it.
|
|
10
|
+
* **which of the sibling ``*.json`` files is a setup form** — as opposed to the
|
|
11
|
+
DAG ``manifest`` or a per-object-store-family credential template.
|
|
12
|
+
:func:`is_form_configmap` is the authority, and
|
|
13
|
+
:mod:`application_sdk.handler.service` delegates to it.
|
|
14
|
+
* **where an entry point's form actually lives**, given the two above.
|
|
15
|
+
:func:`form_configmap` is the authority; the tenant-side route check in
|
|
16
|
+
:mod:`application_sdk.testing.setup_routes` is its first caller.
|
|
17
|
+
|
|
18
|
+
Keeping them here is not tidying. The route check added in FND-1667 asserts a
|
|
19
|
+
join between what an app's contract generates and what its tenant serves, and
|
|
20
|
+
the SDK is on *both* sides of that join: ``handler.service.get_configmap`` is
|
|
21
|
+
the endpoint the tenant's ``/api/service/configmaps/<name>`` proxies to. A
|
|
22
|
+
check that re-derived "which file is the form" would be comparing one guess
|
|
23
|
+
against another, and would drift from the server the moment the exclusion
|
|
24
|
+
vocabulary grew a prefix. There is one copy of each fact, and the check reads
|
|
25
|
+
the same copy the server does.
|
|
26
|
+
|
|
27
|
+
Deliberately dependency-light: stdlib only, no logger, no constants import. It
|
|
28
|
+
is imported by the request path in ``handler.service`` and by a CI-time check,
|
|
29
|
+
so it must cost nothing and pull nothing.
|
|
30
|
+
|
|
31
|
+
Private (leading underscore), like its siblings ``_artifact_schema_guard`` and
|
|
32
|
+
``_ep_registration``: these are the SDK's own rules about its own generated
|
|
33
|
+
tree, not a surface a consumer app should pin against. Nothing here is
|
|
34
|
+
re-exported from ``application_sdk.app``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from collections.abc import Sequence
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Literal
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"ARTIFACT_SCHEMAS_STEM",
|
|
45
|
+
"CREDENTIAL_TEMPLATE_PREFIXES",
|
|
46
|
+
"GeneratedLayout",
|
|
47
|
+
"MANIFEST_STEM",
|
|
48
|
+
"NON_FORM_STEMS",
|
|
49
|
+
"choose_form_configmap",
|
|
50
|
+
"eligible_form_configmaps",
|
|
51
|
+
"entrypoint_dirs",
|
|
52
|
+
"form_configmap",
|
|
53
|
+
"generated_layout",
|
|
54
|
+
"is_form_configmap",
|
|
55
|
+
"names_entrypoint",
|
|
56
|
+
"pick_form_configmap",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
#: Stem of the DAG manifest that sits alongside the generated setup forms.
|
|
60
|
+
MANIFEST_STEM = "manifest"
|
|
61
|
+
|
|
62
|
+
#: Stem of the artifact-schema declarations the toolkit emits for an app with an
|
|
63
|
+
#: ``artifactSchemas`` block (conformance K016). Read by
|
|
64
|
+
#: :mod:`application_sdk.validation.sources` and
|
|
65
|
+
#: :mod:`application_sdk.app._artifact_schema_guard`; named here because it is
|
|
66
|
+
#: also a *non-form* sibling and form discovery must skip it.
|
|
67
|
+
ARTIFACT_SCHEMAS_STEM = "artifact_schemas"
|
|
68
|
+
|
|
69
|
+
#: Whole stems — as opposed to the family prefixes below — that are never a
|
|
70
|
+
#: setup form.
|
|
71
|
+
#:
|
|
72
|
+
#: ``artifact_schemas`` earns its place the hard way (FND-1682): it sorts before
|
|
73
|
+
#: every real form stem, so once an app declared ``artifactSchemas`` the
|
|
74
|
+
#: alphabetical fallback in ``handler.service.get_configmap`` served the schema
|
|
75
|
+
#: document as the form. It has no ``config`` key and no ``properties``, so the
|
|
76
|
+
#: setup wizard rendered blank behind an HTTP 200 — nothing in the logs, the
|
|
77
|
+
#: network tab or pod stderr.
|
|
78
|
+
NON_FORM_STEMS = frozenset({MANIFEST_STEM, ARTIFACT_SCHEMAS_STEM})
|
|
79
|
+
|
|
80
|
+
#: Non-form JSON siblings that live in the generated dir next to the setup-form
|
|
81
|
+
#: configmaps. Credential templates are emitted per object-store family
|
|
82
|
+
#: (``atlan-connectors-*.json``, ``csa-connectors-*.json``).
|
|
83
|
+
#:
|
|
84
|
+
#: Centralised so the form-discovery exclusion vocabulary is named once instead
|
|
85
|
+
#: of re-spelled at each site: adding the next connector-family prefix here
|
|
86
|
+
#: updates the server's fallback (``handler.service.get_configmap``) and the
|
|
87
|
+
#: tenant-side route check together. They disagreeing is the failure this
|
|
88
|
+
#: constant exists to make impossible — the server would serve one file and the
|
|
89
|
+
#: check would compare against another, and the mismatch would read as a
|
|
90
|
+
#: contract regression rather than as two different exclusion lists.
|
|
91
|
+
CREDENTIAL_TEMPLATE_PREFIXES = ("atlan-connectors-", "csa-connectors-")
|
|
92
|
+
|
|
93
|
+
#: Shape of the committed ``app/generated/`` tree — the authority on where an
|
|
94
|
+
#: entry point's generated artifacts live. Mirrors conformance K016's contract
|
|
95
|
+
#: scan.
|
|
96
|
+
GeneratedLayout = Literal["multi", "single", "unknown"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_form_configmap(stem: str) -> bool:
|
|
100
|
+
"""Whether a generated JSON stem is a setup-form configmap.
|
|
101
|
+
|
|
102
|
+
True when *stem* is neither one of the well-known non-form documents
|
|
103
|
+
(:data:`NON_FORM_STEMS`) nor a credential template.
|
|
104
|
+
|
|
105
|
+
**This list can only ever name the siblings the toolkit emits today.**
|
|
106
|
+
FND-1682 is what a stale one costs: ``artifact_schemas.json`` was not on it,
|
|
107
|
+
sorted before every real form stem, and so was served as the form by every
|
|
108
|
+
app that adopted conformance K016. A caller with an entry-point name to go
|
|
109
|
+
on should ask :func:`pick_form_configmap`, which prefers a form that
|
|
110
|
+
:func:`names_entrypoint` over anything this filter merely fails to reject.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
stem: A generated file's name without its ``.json`` suffix.
|
|
114
|
+
"""
|
|
115
|
+
return stem not in NON_FORM_STEMS and not stem.startswith(
|
|
116
|
+
CREDENTIAL_TEMPLATE_PREFIXES
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def names_entrypoint(stem: str, entrypoint: str) -> bool:
|
|
121
|
+
"""Whether a form's *stem* identifies itself as *entrypoint*'s.
|
|
122
|
+
|
|
123
|
+
Two spellings are in the field, and both are identifications rather than
|
|
124
|
+
guesses:
|
|
125
|
+
|
|
126
|
+
* ``<entrypoint>`` — a flat app whose form is named for its entry point
|
|
127
|
+
(``bridge.json`` for ``bridge``).
|
|
128
|
+
* ``<source>-<entrypoint>`` — the connector convention, where the entry
|
|
129
|
+
point is the *role* and the file carries the source (``crawler`` →
|
|
130
|
+
``snowflake-crawler.json``, ``miner`` → ``postgres-miner.json``).
|
|
131
|
+
|
|
132
|
+
The suffix spelling is not a nicety: across the connector fleet the exact
|
|
133
|
+
spelling matches almost nothing, because the entry points are called
|
|
134
|
+
``crawler`` and ``miner`` while the files are named for the source. Matching
|
|
135
|
+
only the exact form leaves every connector on the last-resort scan.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
stem: A generated file's name without its ``.json`` suffix.
|
|
139
|
+
entrypoint: The entry point's kebab-case wire name.
|
|
140
|
+
"""
|
|
141
|
+
return stem == entrypoint or stem.endswith(f"-{entrypoint}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def entrypoint_dirs(generated: Path, entrypoint: str) -> tuple[Path, ...]:
|
|
145
|
+
"""Where *entrypoint*'s generated files may live, most specific first.
|
|
146
|
+
|
|
147
|
+
The kebab-case wire name, then its snake_case spelling. The contract toolkit
|
|
148
|
+
writes a UI-less system entry point's files under the snake_case directory
|
|
149
|
+
(FND-3563) so ``app.generated.<entrypoint_snake>._input`` imports; every
|
|
150
|
+
other entry point keeps the wire-name directory. One path when the two
|
|
151
|
+
spellings agree.
|
|
152
|
+
|
|
153
|
+
The kebab-to-snake rule is
|
|
154
|
+
:func:`application_sdk.app.entrypoint.entrypoint_module_segment`'s, restated
|
|
155
|
+
because this module must not import ``application_sdk.app``.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
generated: The generated directory, usually ``app/generated``.
|
|
159
|
+
entrypoint: The entry point's kebab-case wire name.
|
|
160
|
+
"""
|
|
161
|
+
kebab = generated / entrypoint
|
|
162
|
+
snake = generated / entrypoint.replace("-", "_")
|
|
163
|
+
return (kebab,) if snake == kebab else (kebab, snake)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def eligible_form_configmaps(directory: Path) -> list[Path]:
|
|
167
|
+
"""Every ``*.json`` in *directory* that could be a setup form, sorted.
|
|
168
|
+
|
|
169
|
+
The non-form siblings (:func:`is_form_configmap`) are already gone. What
|
|
170
|
+
remains is what any pick has to choose between, and a caller that wants to
|
|
171
|
+
*report* on the choice — the configmap endpoint warns when it had to fall
|
|
172
|
+
back to alphabetical order among several — needs the list, not just the
|
|
173
|
+
winner.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
directory: A directory in the generated tree. Missing or not a
|
|
177
|
+
directory yields ``[]``.
|
|
178
|
+
"""
|
|
179
|
+
if not directory.is_dir():
|
|
180
|
+
return []
|
|
181
|
+
return [
|
|
182
|
+
candidate
|
|
183
|
+
for candidate in sorted(directory.glob("*.json"))
|
|
184
|
+
if is_form_configmap(candidate.stem)
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def choose_form_configmap(candidates: Sequence[Path], entrypoint: str) -> Path | None:
|
|
189
|
+
"""Pick *entrypoint*'s form out of *candidates*, or ``None`` if empty.
|
|
190
|
+
|
|
191
|
+
Three steps, in this order:
|
|
192
|
+
|
|
193
|
+
1. The candidate whose stem :func:`names_entrypoint` — an identification.
|
|
194
|
+
2. The only candidate, when there is exactly one. Nothing else it could be.
|
|
195
|
+
3. Otherwise the alphabetically first, which **is** a guess.
|
|
196
|
+
|
|
197
|
+
**Step 3 stays, deliberately.** It is the compatibility path for every app
|
|
198
|
+
that has not adopted a name the SDK can recognise, and dropping it (or
|
|
199
|
+
turning ambiguity into a 404) would break apps that work today in order to
|
|
200
|
+
protect against a sibling the toolkit does not yet emit. FND-1682 was not
|
|
201
|
+
caused by the guess being *reachable*; it was caused by
|
|
202
|
+
``artifact_schemas.json`` being eligible at all, which
|
|
203
|
+
:data:`NON_FORM_STEMS` now fixes. Steps 1 and 2 shrink how often step 3 has
|
|
204
|
+
to run — across the connector fleet they answer every generated directory —
|
|
205
|
+
but the endpoint that calls this logs a warning when step 3 does run with
|
|
206
|
+
more than one candidate, so a future sibling shows up in the logs instead of
|
|
207
|
+
only in a blank wizard.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
candidates: Eligible forms, from :func:`eligible_form_configmaps`.
|
|
211
|
+
entrypoint: The entry point's kebab-case wire name.
|
|
212
|
+
"""
|
|
213
|
+
if not candidates:
|
|
214
|
+
return None
|
|
215
|
+
named = [c for c in candidates if names_entrypoint(c.stem, entrypoint)]
|
|
216
|
+
if len(named) == 1:
|
|
217
|
+
return named[0]
|
|
218
|
+
return candidates[0]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def pick_form_configmap(directory: Path, entrypoint: str) -> Path | None:
|
|
222
|
+
"""Return *directory*'s setup form for *entrypoint*, or ``None``.
|
|
223
|
+
|
|
224
|
+
:func:`eligible_form_configmaps` then :func:`choose_form_configmap` — the
|
|
225
|
+
convenience spelling for callers that do not need the candidate list.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
directory: A directory in the generated tree. Missing or not a
|
|
229
|
+
directory yields ``None``.
|
|
230
|
+
entrypoint: The entry point's kebab-case wire name.
|
|
231
|
+
"""
|
|
232
|
+
return choose_form_configmap(eligible_form_configmaps(directory), entrypoint)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def generated_layout(generated: Path) -> GeneratedLayout:
|
|
236
|
+
"""Classify the committed generated tree by the shape it actually has.
|
|
237
|
+
|
|
238
|
+
``multi``
|
|
239
|
+
One or more immediate subdirectories each holding a ``manifest.json``.
|
|
240
|
+
Each subdirectory name is an entry point's wire name.
|
|
241
|
+
``single``
|
|
242
|
+
A ``manifest.json`` at the root of *generated* and no per-entry-point
|
|
243
|
+
subdirectories.
|
|
244
|
+
``unknown``
|
|
245
|
+
No generated tree, or one carrying no ``manifest.json`` anywhere (a repo
|
|
246
|
+
that has not generated yet). Nothing can be inferred about the layout,
|
|
247
|
+
and every caller says so rather than guessing.
|
|
248
|
+
|
|
249
|
+
**Why the tree and not the Python entry-point count.** The layout is a
|
|
250
|
+
property of what the toolkit emitted, not of how many ``@entrypoint``
|
|
251
|
+
methods Python happens to see. A **route/card-split** app (BLDX-1342) is
|
|
252
|
+
exactly where the two disagree: it has several ``@entrypoint``\\ s the DAG
|
|
253
|
+
invokes by ``workflow_type``, but one marketplace card and therefore one
|
|
254
|
+
*flat* generated tree. Counting Python entry points calls it a bundle and
|
|
255
|
+
sends every consumer looking under a wire-name subdirectory the toolkit
|
|
256
|
+
will never write for that app.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
generated: The generated directory, usually ``app/generated``.
|
|
260
|
+
"""
|
|
261
|
+
try:
|
|
262
|
+
children = list(generated.iterdir())
|
|
263
|
+
except OSError:
|
|
264
|
+
return "unknown"
|
|
265
|
+
if any((child / "manifest.json").is_file() for child in children if child.is_dir()):
|
|
266
|
+
return "multi"
|
|
267
|
+
if (generated / "manifest.json").is_file():
|
|
268
|
+
return "single"
|
|
269
|
+
return "unknown"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def form_configmap(
|
|
273
|
+
generated: Path, entrypoint: str, *, layout: GeneratedLayout | None = None
|
|
274
|
+
) -> Path | None:
|
|
275
|
+
"""Return the setup-form configmap for *entrypoint*, or ``None``.
|
|
276
|
+
|
|
277
|
+
Which directory is searched follows the *layout*, not the caller's belief
|
|
278
|
+
about how many entry points exist — see :func:`generated_layout`:
|
|
279
|
+
|
|
280
|
+
``multi``
|
|
281
|
+
``<generated>/<entrypoint>/`` only. A bundle's forms are nested, and
|
|
282
|
+
falling back to the root would let the root-level credential template
|
|
283
|
+
or another entry point's form answer for this one.
|
|
284
|
+
``single``
|
|
285
|
+
``<generated>/`` only, and *entrypoint* is ignored: a flat tree has one
|
|
286
|
+
form serving every route behind the single card.
|
|
287
|
+
``unknown``
|
|
288
|
+
The nested directory, then the flat one — best effort, since a repo
|
|
289
|
+
that has not generated has nothing to be right about.
|
|
290
|
+
|
|
291
|
+
Within the chosen directory :func:`pick_form_configmap` decides, so a form
|
|
292
|
+
named after the entry point is identified by name and anything else falls
|
|
293
|
+
back to the sorted :func:`is_form_configmap` scan. That exclusion is what
|
|
294
|
+
keeps a flat app's ``atlan-connectors-<source>.json`` from being mistaken
|
|
295
|
+
for its form: sorted alphabetically the credential template comes *first*,
|
|
296
|
+
so a glob without the exclusion picks the wrong file on every
|
|
297
|
+
single-entrypoint connector.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
generated: The generated directory, usually ``app/generated``.
|
|
301
|
+
entrypoint: The entry point's kebab-case wire name, which is also its
|
|
302
|
+
directory name in a bundle's generated tree.
|
|
303
|
+
layout: The tree's shape. Computed from *generated* when omitted.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
The form's path, or ``None`` when the chosen directories hold no file
|
|
307
|
+
that qualifies.
|
|
308
|
+
"""
|
|
309
|
+
resolved = generated_layout(generated) if layout is None else layout
|
|
310
|
+
if resolved == "single":
|
|
311
|
+
search: tuple[Path, ...] = (generated,)
|
|
312
|
+
elif resolved == "multi":
|
|
313
|
+
search = (generated / entrypoint,)
|
|
314
|
+
else:
|
|
315
|
+
search = (generated / entrypoint, generated)
|
|
316
|
+
|
|
317
|
+
for directory in search:
|
|
318
|
+
found = pick_form_configmap(directory, entrypoint)
|
|
319
|
+
if found is not None:
|
|
320
|
+
return found
|
|
321
|
+
return None
|