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.
Files changed (78) hide show
  1. application_sdk/__init__.py +7 -0
  2. application_sdk/_build_info.py +62 -0
  3. application_sdk/_context_errors.py +65 -0
  4. application_sdk/_install.py +130 -0
  5. application_sdk/_logging.py +171 -0
  6. application_sdk/_runtime/__init__.py +29 -0
  7. application_sdk/_runtime/offload.py +1020 -0
  8. application_sdk/_runtime/progress.py +757 -0
  9. application_sdk/clients/__init__.py +63 -0
  10. application_sdk/clients/_interface.py +58 -0
  11. application_sdk/clients/_sql_failure_classifier.py +112 -0
  12. application_sdk/clients/base.py +565 -0
  13. application_sdk/clients/http_errors.py +28 -0
  14. application_sdk/clients/models.py +49 -0
  15. application_sdk/clients/sql.py +1218 -0
  16. application_sdk/clients/sql_errors.py +192 -0
  17. application_sdk/clients/sql_typecasters.py +431 -0
  18. application_sdk/clients/ssl_utils.py +247 -0
  19. application_sdk/common/__init__.py +53 -0
  20. application_sdk/common/_generated_tree.py +321 -0
  21. application_sdk/common/_listing.py +192 -0
  22. application_sdk/common/aws_utils.py +522 -0
  23. application_sdk/common/aws_utils_errors.py +88 -0
  24. application_sdk/common/build_identity.py +120 -0
  25. application_sdk/common/concurrency.py +45 -0
  26. application_sdk/common/errors.py +99 -0
  27. application_sdk/common/filter_matching.py +261 -0
  28. application_sdk/common/logger.py +11 -0
  29. application_sdk/common/models.py +31 -0
  30. application_sdk/common/sql_filters.py +719 -0
  31. application_sdk/common/sql_filters_errors.py +17 -0
  32. application_sdk/common/task_queue.py +431 -0
  33. application_sdk/common/types.py +51 -0
  34. application_sdk/common/utils.py +87 -0
  35. application_sdk/constants.py +1120 -0
  36. application_sdk/contracts/__init__.py +158 -0
  37. application_sdk/contracts/base.py +1178 -0
  38. application_sdk/contracts/events.py +251 -0
  39. application_sdk/contracts/types.py +801 -0
  40. application_sdk/contracts/types_errors.py +17 -0
  41. application_sdk/credentials/__init__.py +326 -0
  42. application_sdk/credentials/errors.py +226 -0
  43. application_sdk/credentials/extra.py +114 -0
  44. application_sdk/credentials/ingress.py +283 -0
  45. application_sdk/credentials/ref.py +376 -0
  46. application_sdk/credentials/spec.py +182 -0
  47. application_sdk/credentials/types.py +339 -0
  48. application_sdk/credentials/utils.py +257 -0
  49. application_sdk/errors/__init__.py +224 -0
  50. application_sdk/errors/base.py +504 -0
  51. application_sdk/errors/categories.py +118 -0
  52. application_sdk/errors/http.py +81 -0
  53. application_sdk/errors/leaves.py +526 -0
  54. application_sdk/errors/wire.py +124 -0
  55. application_sdk/handler/__init__.py +97 -0
  56. application_sdk/handler/_warmup.py +198 -0
  57. application_sdk/handler/asgi.py +252 -0
  58. application_sdk/handler/base.py +281 -0
  59. application_sdk/handler/context.py +212 -0
  60. application_sdk/handler/contracts.py +1166 -0
  61. application_sdk/handler/manifest.py +81 -0
  62. application_sdk/handler/metadata_routes.py +699 -0
  63. application_sdk/handler/object_routes.py +418 -0
  64. application_sdk/handler/request_contract.py +91 -0
  65. application_sdk/handler/routes.py +1118 -0
  66. application_sdk/handler/service_errors.py +37 -0
  67. application_sdk/server/__init__.py +15 -0
  68. application_sdk/server/fastapi/models.py +338 -0
  69. application_sdk/templates/__init__.py +52 -0
  70. application_sdk/templates/contracts/__init__.py +111 -0
  71. application_sdk/templates/contracts/base_metadata_extraction.py +32 -0
  72. application_sdk/templates/contracts/incremental_sql.py +563 -0
  73. application_sdk/templates/contracts/sql_metadata.py +824 -0
  74. application_sdk/templates/contracts/sql_query.py +105 -0
  75. application_sdk/version.py +40 -0
  76. atlan_application_sdk_api-3.51.0.dist-info/METADATA +230 -0
  77. atlan_application_sdk_api-3.51.0.dist-info/RECORD +78 -0
  78. 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