tabsdata-cli 2.0.0__cp312-abi3-win_amd64.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.
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/__init__.py +5 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/_registry.py +78 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/BANNER +5 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/LICENSE +3 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/README-PyPi.md +8 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/RELEASE-NOTES +1 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/THIRD-PARTY +3 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/VERSION +1 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/cli_utils.py +993 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/shell.py +390 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/__init__.py +3 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/auth_group.py +197 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/cli.py +508 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/collection_group.py +458 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/config_utils.py +93 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/connection_group.py +159 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/examples_group.py +206 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/execution_utils.py +429 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/fn_group.py +1109 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/fnrun_group.py +285 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/group_group.py +184 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/metrics_group.py +323 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/permission_group.py +259 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/plan_group.py +324 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/project_group.py +170 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/role_group.py +630 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/settings_group.py +344 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/table_group.py +600 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/trigger_group.py +338 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/trx_group.py +329 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/user_group.py +200 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/worker_group.py +185 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/template.py +781 -0
- tabsdata_cli-2.0.0.data/purelib/tabsdatak/py.typed +0 -0
- tabsdata_cli-2.0.0.dist-info/METADATA +39 -0
- tabsdata_cli-2.0.0.dist-info/RECORD +39 -0
- tabsdata_cli-2.0.0.dist-info/WHEEL +5 -0
- tabsdata_cli-2.0.0.dist-info/licenses/src/main/python/tabsdatak/cli/assets/manifest/LICENSE +3 -0
- tabsdata_cli-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Tabsdata Inc.
|
|
3
|
+
#
|
|
4
|
+
|
|
5
|
+
"""Connection-template engine for the ``tdk connection`` CLI.
|
|
6
|
+
|
|
7
|
+
Introspects the connectors registered through the SPI registry
|
|
8
|
+
(:func:`tabsdatak._connectors.avail_srcs` / ``avail_stream_srcs`` /
|
|
9
|
+
``avail_dests``) and emits the connectors' own envelope dialect as YAML::
|
|
10
|
+
|
|
11
|
+
# Connection template for: mysql-sql-in (src)
|
|
12
|
+
# Plain values use the 'str:' prefix; secret values the 'secret:' prefix.
|
|
13
|
+
kind: connectionDef
|
|
14
|
+
apiVersion: '1.0'
|
|
15
|
+
type: tabsdatak.conn.mysql:MySQLSrcConn
|
|
16
|
+
spec:
|
|
17
|
+
|
|
18
|
+
# accepts: str:<value> | secret:<value>
|
|
19
|
+
# replace <value> with the actual uri
|
|
20
|
+
#
|
|
21
|
+
uri: 'str:<value> or secret:<value>'
|
|
22
|
+
credentials: # ... nested userPasswordCredentials envelope ...
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
The ``spec`` mirrors the connection :class:`~tabsdatak.spi.Conn` dataclass's
|
|
26
|
+
pydantic fields. Each fill-in field renders a ``<value>`` placeholder generated
|
|
27
|
+
from the **first** member of its type union:
|
|
28
|
+
|
|
29
|
+
- a :class:`~tabsdatak.api.Secret` first member renders as ``'secret:<value>'``
|
|
30
|
+
(or ``'str:<value> or secret:<value>'`` when the union also accepts a plain
|
|
31
|
+
string, whose ``str:`` prefix keeps it distinct from a secret); the runtime
|
|
32
|
+
store resolves the value the user fills in;
|
|
33
|
+
- a nested-object first member recurses into its own envelope; when that object
|
|
34
|
+
is an abstract base with more than one concrete subclass (a polymorphic field
|
|
35
|
+
like ``staging: CloudLocation`` -> S3 / AzBlob / GCS) there is no single
|
|
36
|
+
default, so every subclass is rendered commented-out, grouped together in
|
|
37
|
+
place under the field name, for the user to uncomment exactly one;
|
|
38
|
+
- any other first member renders ``'<value>'`` or a concrete typed sample
|
|
39
|
+
(enum / int / bool / a field carrying a default).
|
|
40
|
+
|
|
41
|
+
Every fill-in field carries a comment block above it: a blank-line separator,
|
|
42
|
+
an optional ``# accepts: ...`` line (only when the union accepts more than one
|
|
43
|
+
form, e.g. uri / bucket / region), a ``# replace <value> ...`` hint, then a
|
|
44
|
+
bare ``#`` border just above the key. Fields rendering a concrete sample (a
|
|
45
|
+
default, enum, ...) carry no block.
|
|
46
|
+
|
|
47
|
+
[`build_template`][build_template] / [`build_template_interactive`] return a
|
|
48
|
+
YAML-ready :class:`~ruamel.yaml.comments.CommentedMap`; [`to_yaml`] renders it
|
|
49
|
+
(comments included) and [`from_yaml`] parses a document back (YAML is a superset
|
|
50
|
+
of JSON, so JSON parses too).
|
|
51
|
+
|
|
52
|
+
No Click, no terminal I/O: the interactive builder takes the prompt function as a
|
|
53
|
+
callback. ``validate`` constructs the concrete connection via the connector's own
|
|
54
|
+
``from_dict`` -- the full pydantic validation, kind/type cross-check, and
|
|
55
|
+
field-pattern enforcement.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import enum as _enum
|
|
61
|
+
import io
|
|
62
|
+
import textwrap
|
|
63
|
+
import types as _types
|
|
64
|
+
import typing
|
|
65
|
+
from dataclasses import dataclass
|
|
66
|
+
from typing import Any, Callable
|
|
67
|
+
|
|
68
|
+
from ruamel.yaml import YAML
|
|
69
|
+
from ruamel.yaml.comments import CommentedMap
|
|
70
|
+
from ruamel.yaml.error import CommentMark
|
|
71
|
+
from ruamel.yaml.scalarstring import SingleQuotedScalarString
|
|
72
|
+
from ruamel.yaml.tokens import CommentToken
|
|
73
|
+
|
|
74
|
+
# noinspection PyProtectedMember
|
|
75
|
+
from tabsdatak._api import _STR_PREFIX, DescriptorBase, qualified_class, resolve_class
|
|
76
|
+
from tabsdatak._connectors import avail_dests, avail_srcs, avail_stream_srcs
|
|
77
|
+
from tabsdatak.api import Secret
|
|
78
|
+
from tabsdatak.spi import Conn
|
|
79
|
+
|
|
80
|
+
# pydantic marks a required field's default with a sentinel; treat it as unset.
|
|
81
|
+
try:
|
|
82
|
+
from pydantic_core import PydanticUndefined as _UNSET
|
|
83
|
+
except Exception: # pragma: no cover - import shape differs across builds
|
|
84
|
+
_UNSET = object()
|
|
85
|
+
|
|
86
|
+
_NONE = type(None)
|
|
87
|
+
_ENVELOPE_KEYS = frozenset({"kind", "apiVersion", "type", "spec"})
|
|
88
|
+
|
|
89
|
+
Direction = str # one of _DIRECTIONS
|
|
90
|
+
_DIRECTIONS = ("src", "stream-src", "dest")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TemplateError(Exception):
|
|
94
|
+
"""User-facing error (unknown connector, malformed document). Rendered
|
|
95
|
+
verbatim by the CLI."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Human-readable rendering of a connector's direction, shown by
|
|
99
|
+
# ``tdk connection types`` as the "Mode" column.
|
|
100
|
+
_MODE_BY_DIRECTION = {
|
|
101
|
+
"src": "Source",
|
|
102
|
+
"stream-src": "Stream Source",
|
|
103
|
+
"dest": "Destination",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class ConnectorInfo:
|
|
109
|
+
"""One registered connector binding: its wire type, direction, version,
|
|
110
|
+
target system and the concrete :class:`~tabsdatak.spi.Conn` subclass to
|
|
111
|
+
introspect."""
|
|
112
|
+
|
|
113
|
+
type: str
|
|
114
|
+
direction: Direction
|
|
115
|
+
version: str
|
|
116
|
+
system: str
|
|
117
|
+
conn_cls: type
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def kind(self) -> str:
|
|
121
|
+
return self.conn_cls.__kind__
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def mode(self) -> str:
|
|
125
|
+
"""Human-readable direction (``Source`` / ``Destination`` /
|
|
126
|
+
``Stream Source``)."""
|
|
127
|
+
return _MODE_BY_DIRECTION[self.direction]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Discovery -- strictly via the SPI registry.
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _user_facing(conn_cls: type) -> bool:
|
|
136
|
+
"""A real connection class: a pydantic-introspectable connection that
|
|
137
|
+
declares its own config fields. Excludes internal field-less bindings
|
|
138
|
+
like the stream-stage publisher's ``_StreamStageSrcConn`` (every
|
|
139
|
+
connection now shares the same ``connectionDef`` kind, so the kind no
|
|
140
|
+
longer distinguishes them)."""
|
|
141
|
+
return hasattr(conn_cls, "__pydantic_fields__")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def list_connectors() -> list[ConnectorInfo]:
|
|
145
|
+
"""Every installed user-facing connector, flattened from the three SPI
|
|
146
|
+
registries."""
|
|
147
|
+
rows: list[ConnectorInfo] = []
|
|
148
|
+
for type_, d in avail_srcs().items():
|
|
149
|
+
rows.append(ConnectorInfo(type_, "src", d.src_version, d.system, d.conn))
|
|
150
|
+
for type_, d in avail_stream_srcs().items():
|
|
151
|
+
rows.append(ConnectorInfo(type_, "stream-src", d.src_version, d.system, d.conn))
|
|
152
|
+
for type_, d in avail_dests().items():
|
|
153
|
+
rows.append(ConnectorInfo(type_, "dest", d.dest_version, d.system, d.conn))
|
|
154
|
+
rows = [r for r in rows if _user_facing(r.conn_cls)]
|
|
155
|
+
rows.sort(key=lambda r: (r.type, r.direction))
|
|
156
|
+
return rows
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_conn(type_: str, direction: str | None) -> ConnectorInfo:
|
|
160
|
+
"""Resolve a connector ``type`` (+ optional ``direction``) to its binding.
|
|
161
|
+
|
|
162
|
+
Infers the direction when the type is unambiguous; raises a clear
|
|
163
|
+
:class:`TemplateError` for an unknown type, an absent direction on an
|
|
164
|
+
ambiguous type, or a direction the type does not offer."""
|
|
165
|
+
if direction is not None and direction not in _DIRECTIONS:
|
|
166
|
+
raise TemplateError(
|
|
167
|
+
f"unknown direction {direction!r}; expected one of {', '.join(_DIRECTIONS)}"
|
|
168
|
+
)
|
|
169
|
+
rows = list_connectors()
|
|
170
|
+
matches = [r for r in rows if r.type == type_]
|
|
171
|
+
if not matches:
|
|
172
|
+
types_ = ", ".join(sorted({r.type for r in rows}))
|
|
173
|
+
raise TemplateError(f"unknown connector type {type_!r}; available: {types_}")
|
|
174
|
+
if direction is not None:
|
|
175
|
+
for r in matches:
|
|
176
|
+
if r.direction == direction:
|
|
177
|
+
return r
|
|
178
|
+
offered = ", ".join(sorted(r.direction for r in matches))
|
|
179
|
+
raise TemplateError(
|
|
180
|
+
f"connector {type_!r} has no {direction!r} direction; it offers: {offered}"
|
|
181
|
+
)
|
|
182
|
+
if len(matches) == 1:
|
|
183
|
+
return matches[0]
|
|
184
|
+
offered = ", ".join(sorted(r.direction for r in matches))
|
|
185
|
+
raise TemplateError(
|
|
186
|
+
f"connector {type_!r} is ambiguous; specify -d one of: {offered}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Annotation analysis.
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _members(ann: Any) -> list[Any]:
|
|
196
|
+
"""Union members with ``Annotated`` peeled and ``None`` dropped (covers
|
|
197
|
+
both ``typing.Union`` and PEP-604 ``X | Y``)."""
|
|
198
|
+
if typing.get_origin(ann) in (typing.Union, _types.UnionType):
|
|
199
|
+
raw = [a for a in typing.get_args(ann) if a is not _NONE]
|
|
200
|
+
else:
|
|
201
|
+
raw = [ann]
|
|
202
|
+
out = []
|
|
203
|
+
for m in raw:
|
|
204
|
+
base = m
|
|
205
|
+
while typing.get_origin(base) is typing.Annotated:
|
|
206
|
+
base = typing.get_args(base)[0]
|
|
207
|
+
out.append(base)
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ref_class(first: Any) -> type | None:
|
|
212
|
+
"""If the union's first member is a :class:`~tabsdatak.api.Secret`
|
|
213
|
+
reference type, return it (else ``None``).
|
|
214
|
+
Drives both the static sample and the wizard's secret-style prompt."""
|
|
215
|
+
if isinstance(first, type) and issubclass(first, Secret):
|
|
216
|
+
return first
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _type_comment(members: list[Any]) -> str | None:
|
|
221
|
+
"""A one-line note listing the forms a field accepts, in the union's
|
|
222
|
+
declaration order -- e.g. a ``str`` | ``Secret`` field (uri, bucket,
|
|
223
|
+
region) -> ``accepts: str:<value> | secret:<value>``. The plain arm of a
|
|
224
|
+
string-or-secret field carries the ``str:`` prefix (it and the secret arm
|
|
225
|
+
are both strings on the wire); an ``int`` / ``bool`` plain arm stays bare.
|
|
226
|
+
``None`` for a field that accepts a single form (a plain scalar, a lone
|
|
227
|
+
``Secret`` credential, or a nested object carries no such note)."""
|
|
228
|
+
has_ref = any(isinstance(m, type) and issubclass(m, Secret) for m in members)
|
|
229
|
+
if not has_ref:
|
|
230
|
+
return None
|
|
231
|
+
forms: list[str] = []
|
|
232
|
+
for m in members:
|
|
233
|
+
if isinstance(m, type) and issubclass(m, Secret):
|
|
234
|
+
forms.append("secret:<value>")
|
|
235
|
+
elif m is str:
|
|
236
|
+
forms.append("str:<value>")
|
|
237
|
+
else:
|
|
238
|
+
forms.append("<value>")
|
|
239
|
+
if len(forms) <= 1:
|
|
240
|
+
return None
|
|
241
|
+
return "accepts: " + " | ".join(forms)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _concrete_subs(cls: type) -> list[type]:
|
|
245
|
+
"""Introspectable concrete descendants of a nested field type -- those
|
|
246
|
+
carrying their own pydantic fields (an abstract base like ``CloudLocation``
|
|
247
|
+
or ``GCPCredentials`` defines none; its subclasses do)."""
|
|
248
|
+
out: list[type] = []
|
|
249
|
+
if hasattr(cls, "__pydantic_fields__"):
|
|
250
|
+
out.append(cls)
|
|
251
|
+
for sub in cls.__subclasses__():
|
|
252
|
+
out.extend(_concrete_subs(sub))
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _first_concrete(cls: type) -> type:
|
|
257
|
+
"""A concrete, field-bearing class for a nested field type; ``cls`` itself
|
|
258
|
+
when it already carries fields."""
|
|
259
|
+
subs = _concrete_subs(cls)
|
|
260
|
+
if not subs:
|
|
261
|
+
raise TemplateError(f"no concrete subclass for type {cls.__name__}")
|
|
262
|
+
return subs[0]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _pattern_of(ann: Any) -> str | None:
|
|
266
|
+
"""The first regex pattern declared on the annotation's ``Annotated``
|
|
267
|
+
metadata (``StringConstraints``), for wizard display."""
|
|
268
|
+
candidates = (
|
|
269
|
+
list(typing.get_args(ann))
|
|
270
|
+
if typing.get_origin(ann) in (typing.Union, _types.UnionType)
|
|
271
|
+
else [ann]
|
|
272
|
+
)
|
|
273
|
+
for m in candidates:
|
|
274
|
+
if typing.get_origin(m) is typing.Annotated:
|
|
275
|
+
for meta in typing.get_args(m)[1:]:
|
|
276
|
+
pat = getattr(meta, "pattern", None)
|
|
277
|
+
if isinstance(pat, str):
|
|
278
|
+
return pat
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _field_pattern(fi: Any) -> str | None:
|
|
283
|
+
"""The regex a field advertises for the wizard prompt. Checks the
|
|
284
|
+
annotation's inline ``Annotated`` metadata first, then the field-level
|
|
285
|
+
metadata -- where a field's ``ValueConstraints`` ends up once pydantic
|
|
286
|
+
peels the outer ``Annotated`` off ``fi.annotation``. Only regex-shaped
|
|
287
|
+
rules advertise a pattern; the rules themselves remain the contract."""
|
|
288
|
+
pat = _pattern_of(fi.annotation)
|
|
289
|
+
if pat is not None:
|
|
290
|
+
return pat
|
|
291
|
+
for meta in getattr(fi, "metadata", ()):
|
|
292
|
+
candidate = getattr(meta, "pattern", None)
|
|
293
|
+
if isinstance(candidate, str):
|
|
294
|
+
return candidate
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _envelope(cls: type, spec: dict) -> CommentedMap:
|
|
299
|
+
env = CommentedMap()
|
|
300
|
+
env["kind"] = cls.__kind__
|
|
301
|
+
env["apiVersion"] = cls.__api_version__
|
|
302
|
+
env["type"] = qualified_class(cls)
|
|
303
|
+
env["spec"] = spec
|
|
304
|
+
return env
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _scalar_placeholder(members: list[Any], default: Any, path: str) -> Any:
|
|
308
|
+
if default is not None:
|
|
309
|
+
return default
|
|
310
|
+
for b in members:
|
|
311
|
+
origin = typing.get_origin(b)
|
|
312
|
+
if origin is typing.Literal:
|
|
313
|
+
args = typing.get_args(b)
|
|
314
|
+
if args:
|
|
315
|
+
return args[0]
|
|
316
|
+
if isinstance(b, type) and issubclass(b, _enum.Enum):
|
|
317
|
+
for member in b:
|
|
318
|
+
return member.value
|
|
319
|
+
if origin in (list, tuple, set, frozenset) or b in (list, tuple, set):
|
|
320
|
+
return []
|
|
321
|
+
if origin is dict or b is dict:
|
|
322
|
+
return {}
|
|
323
|
+
if b is bool:
|
|
324
|
+
return False
|
|
325
|
+
if b is int:
|
|
326
|
+
return 0
|
|
327
|
+
if b is float:
|
|
328
|
+
return 0.0
|
|
329
|
+
return f"<FILL: {path}>"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
# Static template.
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# A spec's keys sit two columns in from its ``spec:`` header; each nested
|
|
338
|
+
# envelope (``kind``/``apiVersion``/``type``/``spec``) adds another four.
|
|
339
|
+
_SPEC_INDENT = 2
|
|
340
|
+
_NEST_INDENT = 4
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _set_template_header(env: CommentedMap, conn_cls: type) -> None:
|
|
344
|
+
"""Prepend a header naming the connector / direction the template targets and
|
|
345
|
+
reminding that secret values carry the ``secret:`` prefix (or a
|
|
346
|
+
``$secret:<ENV_VAR>`` environment reference resolved client-side at
|
|
347
|
+
create/update time)."""
|
|
348
|
+
label = conn_cls.__kind__
|
|
349
|
+
for r in list_connectors():
|
|
350
|
+
if r.conn_cls is conn_cls:
|
|
351
|
+
label = f"{r.type} ({r.direction})"
|
|
352
|
+
break
|
|
353
|
+
# Keep every rendered comment line within 80 columns: ruamel prefixes each
|
|
354
|
+
# with ``"# "`` (2 cols), so wrap the prose paragraphs at 78.
|
|
355
|
+
lines = [f"Connection template for: {label}"]
|
|
356
|
+
for paragraph in (
|
|
357
|
+
"In a string-or-secret field, a plain value uses the 'str:' prefix "
|
|
358
|
+
"and a secret value the 'secret:' prefix (or '$secret:<ENV_VAR>' to "
|
|
359
|
+
"read the value from an environment variable).",
|
|
360
|
+
"A '$secret:' value is resolved on the client when the source or "
|
|
361
|
+
"destination is created or updated with this connection.",
|
|
362
|
+
):
|
|
363
|
+
lines.extend(textwrap.wrap(paragraph, width=78))
|
|
364
|
+
env.yaml_set_start_comment("\n".join(lines))
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def build_template(conn_cls: type, *, all_fields: bool = False) -> CommentedMap:
|
|
368
|
+
"""A fill-in connection envelope (a YAML-ready :class:`CommentedMap`).
|
|
369
|
+
Required fields by default; optional fields too under ``all_fields``.
|
|
370
|
+
Placeholders (``<FILL: ...>``) are not guaranteed to satisfy field regexes
|
|
371
|
+
-- see the module / wizard split."""
|
|
372
|
+
env = _envelope(conn_cls, _build_spec(conn_cls, "", all_fields, _SPEC_INDENT))
|
|
373
|
+
_set_template_header(env, conn_cls)
|
|
374
|
+
return env
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _is_secret(m: Any) -> bool:
|
|
378
|
+
return isinstance(m, type) and issubclass(m, Secret)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _placeholder_value(members: list[Any], default: Any, path: str) -> Any:
|
|
382
|
+
"""The fill-in placeholder a scalar (non-nested) field renders as.
|
|
383
|
+
|
|
384
|
+
Secret-capable fields render a quoted ``<value>`` hint so the file shows
|
|
385
|
+
exactly what to type; plain typed fields (Literal / enum / int / bool /
|
|
386
|
+
list / dict, or a field carrying a default) keep a concrete valid sample.
|
|
387
|
+
The :class:`SingleQuotedScalarString` wrapper both forces single-quote
|
|
388
|
+
YAML rendering and marks the value as a fill-in placeholder, so
|
|
389
|
+
:func:`_field_comment` knows to annotate it."""
|
|
390
|
+
if default is not None:
|
|
391
|
+
# A str|Secret field's string default carries the ``str:`` marker so the
|
|
392
|
+
# emitted value re-parses (a bare string is no longer accepted). Kept a
|
|
393
|
+
# plain str (not a fill-in placeholder) so it renders as a real default.
|
|
394
|
+
if (
|
|
395
|
+
isinstance(default, str)
|
|
396
|
+
and any(_is_secret(m) for m in members)
|
|
397
|
+
and any(m is str for m in members)
|
|
398
|
+
):
|
|
399
|
+
return f"{_STR_PREFIX}{default}"
|
|
400
|
+
return default
|
|
401
|
+
if any(_is_secret(m) for m in members):
|
|
402
|
+
if any(not _is_secret(m) for m in members):
|
|
403
|
+
plain = "str:<value>" if any(m is str for m in members) else "<value>"
|
|
404
|
+
return SingleQuotedScalarString(f"{plain} or secret:<value>")
|
|
405
|
+
return SingleQuotedScalarString("secret:<value>")
|
|
406
|
+
if members[0] is str:
|
|
407
|
+
return SingleQuotedScalarString("<value>")
|
|
408
|
+
return _scalar_placeholder([members[0]], default, path)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _expected_note(members: list[Any]) -> str | None:
|
|
412
|
+
"""A very short 'what's expected' note for a union / dict / list / choice
|
|
413
|
+
field — shown on optional fields and commented-out ones. ``None`` for a
|
|
414
|
+
plain single-form scalar."""
|
|
415
|
+
accepts = _type_comment(members)
|
|
416
|
+
if accepts is not None:
|
|
417
|
+
return accepts
|
|
418
|
+
choices: list[str] = []
|
|
419
|
+
for m in members:
|
|
420
|
+
if typing.get_origin(m) is typing.Literal:
|
|
421
|
+
choices.extend(str(a) for a in typing.get_args(m))
|
|
422
|
+
if choices:
|
|
423
|
+
return "one of: " + " | ".join(choices)
|
|
424
|
+
for m in members:
|
|
425
|
+
origin = typing.get_origin(m)
|
|
426
|
+
if origin is dict or m is dict:
|
|
427
|
+
return "a dict (str-keyed)"
|
|
428
|
+
if origin in (list, tuple, set, frozenset) or m in (list, tuple, set):
|
|
429
|
+
return "a list"
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _field_comment_lines(
|
|
434
|
+
name: str, members: list[Any], value: Any, required: bool, default: Any
|
|
435
|
+
) -> list[str] | None:
|
|
436
|
+
"""The content lines of the comment block shown above a real (uncommented)
|
|
437
|
+
field (borders are added by :func:`_set_block_comment`). A required fill-in
|
|
438
|
+
placeholder gets::
|
|
439
|
+
|
|
440
|
+
# accepts: <value> | secret:<value> (only when >1 accepted form)
|
|
441
|
+
# replace <value> with the actual <name>
|
|
442
|
+
|
|
443
|
+
an optional field shown with its default gets an ``optional (default: …)``
|
|
444
|
+
note plus a short type note. ``None`` for a required field rendering a bare
|
|
445
|
+
concrete sample."""
|
|
446
|
+
if isinstance(value, SingleQuotedScalarString):
|
|
447
|
+
lines = []
|
|
448
|
+
accepts = _type_comment(members)
|
|
449
|
+
if accepts is not None:
|
|
450
|
+
lines.append(accepts)
|
|
451
|
+
lines.append(f"replace <value> with the actual {name}")
|
|
452
|
+
return lines
|
|
453
|
+
if not required:
|
|
454
|
+
lines = [f"optional (default: {default!r})"]
|
|
455
|
+
note = _expected_note(members)
|
|
456
|
+
if note is not None:
|
|
457
|
+
lines.append(note)
|
|
458
|
+
return lines
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _set_block_comment(
|
|
463
|
+
cm: CommentedMap, key: str, lines: list[str], indent: int
|
|
464
|
+
) -> None:
|
|
465
|
+
"""Attach a comment block before ``key``: a blank-line separator, the
|
|
466
|
+
given comment lines (each ``# <text>``), then a single ``#`` border just
|
|
467
|
+
above the key. Built from low-level ruamel tokens because the high-level
|
|
468
|
+
API renders empty lines as blank rather than as a bare ``#`` border."""
|
|
469
|
+
mark = CommentMark(indent)
|
|
470
|
+
tokens = [CommentToken("\n", mark)]
|
|
471
|
+
tokens += [CommentToken(f"# {line}\n", mark) for line in lines]
|
|
472
|
+
tokens.append(CommentToken("#\n", mark))
|
|
473
|
+
cm.ca.items[key] = [None, tokens, None, None]
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _strip_comments(node: Any) -> Any:
|
|
477
|
+
"""Recursively drop attached comments, so a commented-out nested field's
|
|
478
|
+
sample renders as plain structure (not comments-within-comments)."""
|
|
479
|
+
if isinstance(node, CommentedMap):
|
|
480
|
+
node.ca.items.clear()
|
|
481
|
+
for v in node.values():
|
|
482
|
+
_strip_comments(v)
|
|
483
|
+
return node
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _commented_lines(
|
|
487
|
+
name: str, first: Any, members: list[Any], indent: int
|
|
488
|
+
) -> list[str]:
|
|
489
|
+
"""Content lines (no leading ``#``) for an optional field whose default is
|
|
490
|
+
None, rendered commented-out: a short note, then the field as it would be
|
|
491
|
+
written (a scalar sample, or the full nested envelope)."""
|
|
492
|
+
note = _expected_note(members)
|
|
493
|
+
head = f"{name} (optional, default: none)" + (f" -- {note}" if note else "")
|
|
494
|
+
body_map = CommentedMap()
|
|
495
|
+
if isinstance(first, type) and issubclass(first, DescriptorBase):
|
|
496
|
+
obj = _first_concrete(first)
|
|
497
|
+
body_map[name] = _strip_comments(
|
|
498
|
+
_envelope(obj, _build_spec(obj, "", True, _SPEC_INDENT))
|
|
499
|
+
)
|
|
500
|
+
else:
|
|
501
|
+
body_map[name] = _placeholder_value(members, None, name)
|
|
502
|
+
return [head, *to_yaml(body_map).rstrip("\n").split("\n")]
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _polymorphic_option_lines(
|
|
506
|
+
name: str, subs: list[type], required: bool, all_fields: bool
|
|
507
|
+
) -> list[str]:
|
|
508
|
+
"""Commented-out blocks for EVERY concrete subclass of a polymorphic nested
|
|
509
|
+
field, grouped together so the user uncomments exactly one. A field typed as
|
|
510
|
+
an abstract base with several concrete subclasses (e.g.
|
|
511
|
+
``staging: CloudLocation`` -> S3 / AzBlob / GCS in the clickhouse / druid /
|
|
512
|
+
starrocks destinations) has no single default, so all its options are
|
|
513
|
+
rendered commented-out in place under the field name."""
|
|
514
|
+
head = f"{name}: uncomment exactly ONE of the following blocks"
|
|
515
|
+
if required:
|
|
516
|
+
head += " (required)"
|
|
517
|
+
lines = [head]
|
|
518
|
+
for sub in subs:
|
|
519
|
+
body_map = CommentedMap()
|
|
520
|
+
body_map[name] = _strip_comments(
|
|
521
|
+
_envelope(sub, _build_spec(sub, "", all_fields, _SPEC_INDENT))
|
|
522
|
+
)
|
|
523
|
+
lines.append("")
|
|
524
|
+
lines.extend(to_yaml(body_map).rstrip("\n").split("\n"))
|
|
525
|
+
return lines
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _attach_after_last(out: CommentedMap, lines: list[str], indent: int) -> None:
|
|
529
|
+
"""Attach commented `lines` immediately after the spec's current last field,
|
|
530
|
+
so a fully-commented field (a polymorphic field with no active default)
|
|
531
|
+
renders in field-order position rather than drifting to the spec's end. An
|
|
532
|
+
empty spec (the field is first) gets them as a leading comment."""
|
|
533
|
+
if not len(out):
|
|
534
|
+
out.yaml_set_start_comment("\n".join(lines), indent=indent)
|
|
535
|
+
return
|
|
536
|
+
pad = " " * indent
|
|
537
|
+
body = "".join(f"{pad}# {ln}\n" if ln else f"{pad}#\n" for ln in lines)
|
|
538
|
+
owner, key = _deepest_last(out)
|
|
539
|
+
entry = owner.ca.items.get(key) or [None, None, None, None]
|
|
540
|
+
prev = entry[2].value.rstrip("\n") + "\n" if entry[2] is not None else ""
|
|
541
|
+
entry[2] = CommentToken(prev + "\n" + body, CommentMark(indent))
|
|
542
|
+
owner.ca.items[key] = entry
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _deepest_last(cm: CommentedMap) -> tuple[CommentedMap, str]:
|
|
546
|
+
"""The owner map + key of the spec's last-rendered scalar leaf (drilling
|
|
547
|
+
into the last nested map), where a trailing map comment must be attached."""
|
|
548
|
+
key = next(reversed(cm))
|
|
549
|
+
val = cm[key]
|
|
550
|
+
if isinstance(val, CommentedMap) and len(val):
|
|
551
|
+
return _deepest_last(val)
|
|
552
|
+
return cm, key
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _attach_commented(out: CommentedMap, lines: list[str], indent: int) -> None:
|
|
556
|
+
"""Attach commented-out optional-field `lines` at the end of spec `out`
|
|
557
|
+
(after its last real field), indented at the spec's own level. An
|
|
558
|
+
all-optional spec (no real fields) gets them as a leading comment."""
|
|
559
|
+
if not out:
|
|
560
|
+
out.yaml_set_start_comment("\n".join(lines), indent=indent)
|
|
561
|
+
return
|
|
562
|
+
pad = " " * indent
|
|
563
|
+
text = "\n" + "".join(f"{pad}# {ln}\n" if ln else f"{pad}#\n" for ln in lines)
|
|
564
|
+
owner, key = _deepest_last(out)
|
|
565
|
+
# Preserve any before-comment already on this leaf; only set its EOL slot.
|
|
566
|
+
entry = owner.ca.items.get(key) or [None, None, None, None]
|
|
567
|
+
entry[2] = CommentToken(text, CommentMark(indent))
|
|
568
|
+
owner.ca.items[key] = entry
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _build_spec(cls: type, prefix: str, all_fields: bool, indent: int) -> CommentedMap:
|
|
572
|
+
out = CommentedMap()
|
|
573
|
+
commented: list[str] = []
|
|
574
|
+
for name, fi in cls.__pydantic_fields__.items():
|
|
575
|
+
required = fi.is_required()
|
|
576
|
+
if not required and not all_fields:
|
|
577
|
+
continue
|
|
578
|
+
path = f"{prefix}{name}"
|
|
579
|
+
members = _members(fi.annotation)
|
|
580
|
+
# The sample is generated from the FIRST member of the field's union.
|
|
581
|
+
first = members[0]
|
|
582
|
+
default = None if fi.default is _UNSET else fi.default
|
|
583
|
+
if not required and fi.default is None:
|
|
584
|
+
# Optional, defaults to None -> shown commented-out (keeps the
|
|
585
|
+
# rendered document valid) with a short 'what's expected' note.
|
|
586
|
+
commented.extend(_commented_lines(name, first, members, indent))
|
|
587
|
+
continue
|
|
588
|
+
if isinstance(first, type) and issubclass(first, DescriptorBase):
|
|
589
|
+
subs = _concrete_subs(first)
|
|
590
|
+
if not subs:
|
|
591
|
+
raise TemplateError(f"no concrete subclass for type {first.__name__}")
|
|
592
|
+
if len(subs) > 1:
|
|
593
|
+
# Polymorphic field (an abstract base with several concrete
|
|
594
|
+
# subclasses, e.g. `staging: CloudLocation` -> S3 / AzBlob / GCS):
|
|
595
|
+
# no single default, so render every option commented-out together
|
|
596
|
+
# in place, for the user to uncomment exactly one.
|
|
597
|
+
_attach_after_last(
|
|
598
|
+
out,
|
|
599
|
+
_polymorphic_option_lines(name, subs, required, all_fields),
|
|
600
|
+
indent,
|
|
601
|
+
)
|
|
602
|
+
continue
|
|
603
|
+
# Single concrete subclass -> its own envelope; no fill-in comment.
|
|
604
|
+
obj = subs[0]
|
|
605
|
+
out[name] = _envelope(
|
|
606
|
+
obj, _build_spec(obj, f"{path}.", all_fields, indent + _NEST_INDENT)
|
|
607
|
+
)
|
|
608
|
+
continue
|
|
609
|
+
value = _placeholder_value(members, default, path)
|
|
610
|
+
out[name] = value
|
|
611
|
+
lines = _field_comment_lines(name, members, value, required, default)
|
|
612
|
+
if lines is not None:
|
|
613
|
+
_set_block_comment(out, name, lines, indent)
|
|
614
|
+
if commented:
|
|
615
|
+
_attach_commented(out, commented, indent)
|
|
616
|
+
return out
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
# Interactive wizard.
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
# prompt(path, *, secret, optional, default, pattern, choices) -> str | None
|
|
624
|
+
PromptFn = Callable[..., str | None]
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def build_template_interactive(conn_cls: type, prompt: PromptFn) -> dict:
|
|
628
|
+
"""Walk every field, asking ``prompt`` per leaf, and assemble the envelope.
|
|
629
|
+
Secret leaves capture an inline value only if the user supplies one (else a
|
|
630
|
+
bare runtime-resolved :class:`~tabsdatak.api.Secret` ref); no value is ever
|
|
631
|
+
required to be written to disk."""
|
|
632
|
+
env = _envelope(conn_cls, _prompt_spec(conn_cls, "", prompt))
|
|
633
|
+
_set_template_header(env, conn_cls)
|
|
634
|
+
return env
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _prompt_spec(cls: type, prefix: str, prompt: PromptFn) -> dict:
|
|
638
|
+
out: dict[str, Any] = {}
|
|
639
|
+
for name, fi in cls.__pydantic_fields__.items():
|
|
640
|
+
path = f"{prefix}{name}"
|
|
641
|
+
optional = not fi.is_required()
|
|
642
|
+
# Classify by the FIRST member of the field's union (mirrors the static
|
|
643
|
+
# template).
|
|
644
|
+
first = _members(fi.annotation)[0]
|
|
645
|
+
ref = _ref_class(first)
|
|
646
|
+
if isinstance(first, type) and issubclass(first, DescriptorBase):
|
|
647
|
+
obj = _choose_concrete(first, path, prompt)
|
|
648
|
+
out[name] = _envelope(obj, _prompt_spec(obj, f"{path}.", prompt))
|
|
649
|
+
elif ref is not None:
|
|
650
|
+
value = prompt(
|
|
651
|
+
path,
|
|
652
|
+
secret=True,
|
|
653
|
+
optional=optional,
|
|
654
|
+
default=None,
|
|
655
|
+
pattern=None,
|
|
656
|
+
choices=None,
|
|
657
|
+
)
|
|
658
|
+
# A user-supplied value is captured as the hydrated wire form
|
|
659
|
+
# ("secret:<value>"), which `dehydrate` harvests out at upsert
|
|
660
|
+
# time; with no value, the dehydrated marker (":secret:") is
|
|
661
|
+
# written and the value is resolved at run time.
|
|
662
|
+
out[name] = f"{ref._PREFIX}{value}" if value else ref().to_dict()
|
|
663
|
+
else:
|
|
664
|
+
members = _members(fi.annotation)
|
|
665
|
+
value = prompt(
|
|
666
|
+
path,
|
|
667
|
+
secret=False,
|
|
668
|
+
optional=optional,
|
|
669
|
+
default=(None if fi.default is _UNSET else fi.default),
|
|
670
|
+
pattern=_field_pattern(fi),
|
|
671
|
+
choices=None,
|
|
672
|
+
)
|
|
673
|
+
if value is not None and value != "":
|
|
674
|
+
# A string-or-secret field stores its plain arm with the
|
|
675
|
+
# ``str:`` marker (plain and secret arms are both strings on
|
|
676
|
+
# the wire); a bare scalar (int / bool / plain-str field) is
|
|
677
|
+
# stored as typed.
|
|
678
|
+
if any(_is_secret(m) for m in members) and members[0] is str:
|
|
679
|
+
out[name] = f"{_STR_PREFIX}{value}"
|
|
680
|
+
else:
|
|
681
|
+
out[name] = value
|
|
682
|
+
elif not optional:
|
|
683
|
+
out[name] = f"<FILL: {path}>"
|
|
684
|
+
return out
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _choose_concrete(cls: type, path: str, prompt: PromptFn) -> type:
|
|
688
|
+
"""For a polymorphic nested type, ask which concrete subclass to use; for a
|
|
689
|
+
single concrete/abstract type, resolve it directly."""
|
|
690
|
+
subs = _concrete_subs(cls)
|
|
691
|
+
if len(subs) <= 1:
|
|
692
|
+
return _first_concrete(cls)
|
|
693
|
+
names = [s.__name__ for s in subs]
|
|
694
|
+
chosen = prompt(
|
|
695
|
+
path,
|
|
696
|
+
secret=False,
|
|
697
|
+
optional=False,
|
|
698
|
+
default=names[0],
|
|
699
|
+
pattern=None,
|
|
700
|
+
choices=names,
|
|
701
|
+
)
|
|
702
|
+
for s in subs:
|
|
703
|
+
if s.__name__ == chosen:
|
|
704
|
+
return s
|
|
705
|
+
return subs[0]
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ---------------------------------------------------------------------------
|
|
709
|
+
# YAML serialization.
|
|
710
|
+
# ---------------------------------------------------------------------------
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _yaml() -> YAML:
|
|
714
|
+
y = YAML()
|
|
715
|
+
y.default_flow_style = False
|
|
716
|
+
return y
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def to_yaml(document: Any) -> str:
|
|
720
|
+
"""Render a connection document (the :class:`CommentedMap` from
|
|
721
|
+
:func:`build_template` / :func:`build_template_interactive`, or any dict) as
|
|
722
|
+
YAML text, including the field comments attached by the template engine."""
|
|
723
|
+
buf = io.StringIO()
|
|
724
|
+
_yaml().dump(document, buf)
|
|
725
|
+
return buf.getvalue()
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def from_yaml(text: str) -> Any:
|
|
729
|
+
"""Parse a YAML connection document into a plain Python structure. YAML is a
|
|
730
|
+
superset of JSON, so a JSON document parses too."""
|
|
731
|
+
return _yaml().load(text)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ---------------------------------------------------------------------------
|
|
735
|
+
# Validation.
|
|
736
|
+
# ---------------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def validate(document: Any) -> str:
|
|
740
|
+
"""Validate a connection envelope by constructing the concrete connection
|
|
741
|
+
via its own ``from_dict`` (full field + pattern validation, kind/type
|
|
742
|
+
cross-check). Returns the ``kind``; raises :class:`TemplateError`."""
|
|
743
|
+
if not isinstance(document, dict):
|
|
744
|
+
raise TemplateError("connection document must be a JSON object")
|
|
745
|
+
missing = _ENVELOPE_KEYS - document.keys()
|
|
746
|
+
if missing:
|
|
747
|
+
raise TemplateError(
|
|
748
|
+
f"connection document missing envelope key(s): {', '.join(sorted(missing))}"
|
|
749
|
+
)
|
|
750
|
+
kind = document["kind"]
|
|
751
|
+
if not isinstance(kind, str):
|
|
752
|
+
raise TemplateError("connection document 'kind' must be a string")
|
|
753
|
+
try:
|
|
754
|
+
cls = resolve_class(document["type"])
|
|
755
|
+
except ValueError as e:
|
|
756
|
+
raise TemplateError(str(e))
|
|
757
|
+
if not (isinstance(cls, type) and issubclass(cls, Conn)):
|
|
758
|
+
raise TemplateError(
|
|
759
|
+
f"type {document['type']!r} does not resolve to a connection class"
|
|
760
|
+
)
|
|
761
|
+
try:
|
|
762
|
+
cls.from_dict(document)
|
|
763
|
+
except Exception as e: # noqa: BLE001 -- surface any construction failure
|
|
764
|
+
raise TemplateError(f"invalid {kind} connection: {e}")
|
|
765
|
+
return kind
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def dehydrate_document(document: Any) -> tuple[dict, list[dict]]:
|
|
769
|
+
"""Validate a filled connection document and dehydrate it for storage.
|
|
770
|
+
|
|
771
|
+
Secret values are harvested into the returned ``[{"key", "value"}]`` payload
|
|
772
|
+
and the document keeps only the ``:secret:`` marker, so no secret lands in
|
|
773
|
+
the stored connection. ``$secret:<ENV>`` references must be resolved first.
|
|
774
|
+
Raises :class:`TemplateError` if the document is invalid."""
|
|
775
|
+
from tabsdatak.common._json_serde import dehydrate
|
|
776
|
+
|
|
777
|
+
validate(document)
|
|
778
|
+
conn_cls = resolve_class(document["type"])
|
|
779
|
+
model = conn_cls.from_dict(document)
|
|
780
|
+
dehydrated, secrets = dehydrate(document, model.to_dict())
|
|
781
|
+
return dehydrated, [{"key": kv.key, "value": kv.value} for kv in secrets]
|