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.
Files changed (39) hide show
  1. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/__init__.py +5 -0
  2. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/_registry.py +78 -0
  3. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/BANNER +5 -0
  4. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/LICENSE +3 -0
  5. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/README-PyPi.md +8 -0
  6. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/RELEASE-NOTES +1 -0
  7. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/THIRD-PARTY +3 -0
  8. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/assets/manifest/VERSION +1 -0
  9. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/cli_utils.py +993 -0
  10. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/shell.py +390 -0
  11. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/__init__.py +3 -0
  12. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/auth_group.py +197 -0
  13. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/cli.py +508 -0
  14. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/collection_group.py +458 -0
  15. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/config_utils.py +93 -0
  16. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/connection_group.py +159 -0
  17. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/examples_group.py +206 -0
  18. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/execution_utils.py +429 -0
  19. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/fn_group.py +1109 -0
  20. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/fnrun_group.py +285 -0
  21. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/group_group.py +184 -0
  22. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/metrics_group.py +323 -0
  23. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/permission_group.py +259 -0
  24. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/plan_group.py +324 -0
  25. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/project_group.py +170 -0
  26. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/role_group.py +630 -0
  27. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/settings_group.py +344 -0
  28. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/table_group.py +600 -0
  29. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/trigger_group.py +338 -0
  30. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/trx_group.py +329 -0
  31. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/user_group.py +200 -0
  32. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/td/worker_group.py +185 -0
  33. tabsdata_cli-2.0.0.data/purelib/tabsdatak/cli/template.py +781 -0
  34. tabsdata_cli-2.0.0.data/purelib/tabsdatak/py.typed +0 -0
  35. tabsdata_cli-2.0.0.dist-info/METADATA +39 -0
  36. tabsdata_cli-2.0.0.dist-info/RECORD +39 -0
  37. tabsdata_cli-2.0.0.dist-info/WHEEL +5 -0
  38. tabsdata_cli-2.0.0.dist-info/licenses/src/main/python/tabsdatak/cli/assets/manifest/LICENSE +3 -0
  39. 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]