untaped 2.4.4__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.
- untaped/__init__.py +10 -0
- untaped/api.py +130 -0
- untaped/app_context.py +70 -0
- untaped/batch.py +114 -0
- untaped/cli.py +372 -0
- untaped/config/__init__.py +5 -0
- untaped/config/application/__init__.py +19 -0
- untaped/config/application/context.py +67 -0
- untaped/config/application/get_setting.py +26 -0
- untaped/config/application/list_settings.py +135 -0
- untaped/config/application/ports.py +31 -0
- untaped/config/application/set_setting.py +39 -0
- untaped/config/application/unset_setting.py +40 -0
- untaped/config/domain/__init__.py +4 -0
- untaped/config/domain/formatting.py +28 -0
- untaped/config/domain/models.py +49 -0
- untaped/config/infrastructure/__init__.py +3 -0
- untaped/config/infrastructure/settings_repo.py +181 -0
- untaped/config_app.py +406 -0
- untaped/config_file.py +207 -0
- untaped/config_schema.py +142 -0
- untaped/errors.py +74 -0
- untaped/http.py +549 -0
- untaped/identity.py +32 -0
- untaped/output.py +54 -0
- untaped/pipe.py +96 -0
- untaped/profile/__init__.py +17 -0
- untaped/profile/app.py +263 -0
- untaped/profile/models.py +42 -0
- untaped/profile/ports.py +46 -0
- untaped/profile/repository.py +119 -0
- untaped/profile/use_cases.py +208 -0
- untaped/profile_resolver.py +139 -0
- untaped/progress.py +224 -0
- untaped/prompts.py +274 -0
- untaped/py.typed +0 -0
- untaped/quiet.py +33 -0
- untaped/run.py +322 -0
- untaped/settings.py +376 -0
- untaped/settings_layout.py +83 -0
- untaped/skills.py +390 -0
- untaped/skills_app.py +134 -0
- untaped/stdin.py +116 -0
- untaped/testing.py +127 -0
- untaped/theme.py +148 -0
- untaped/tool.py +85 -0
- untaped/ui.py +584 -0
- untaped/verbose.py +59 -0
- untaped-2.4.4.dist-info/METADATA +153 -0
- untaped-2.4.4.dist-info/RECORD +52 -0
- untaped-2.4.4.dist-info/WHEEL +4 -0
- untaped-2.4.4.dist-info/licenses/LICENSE +21 -0
untaped/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""The untaped SDK — a batteries-included CLI framework built on cyclopts.
|
|
2
|
+
|
|
3
|
+
This package root re-exports the public surface defined in :mod:`untaped.api`,
|
|
4
|
+
so ``from untaped import X`` and ``from untaped.api import X`` are equivalent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from untaped import api as _api
|
|
8
|
+
from untaped.api import * # noqa: F403
|
|
9
|
+
|
|
10
|
+
__all__ = list(_api.__all__)
|
untaped/api.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The untaped SDK surface.
|
|
2
|
+
|
|
3
|
+
Tools import from this module (``from untaped.api import ...``) instead of
|
|
4
|
+
reaching into SDK internals. Names listed in ``__all__`` are the SDK contract:
|
|
5
|
+
additions are backwards-compatible; removing a name or changing its behaviour
|
|
6
|
+
is a major SDK version event. Internal modules stay free to reorganize as long
|
|
7
|
+
as this surface keeps resolving. ``untaped`` (the package root) re-exports this
|
|
8
|
+
exact surface, so ``from untaped import X`` and ``from untaped.api import X``
|
|
9
|
+
are equivalent.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from untaped.app_context import AppContext, app_context
|
|
15
|
+
from untaped.batch import BatchOutcome, batch_apply
|
|
16
|
+
from untaped.cli import (
|
|
17
|
+
ColumnsOption,
|
|
18
|
+
FormatOption,
|
|
19
|
+
clamp_parallel,
|
|
20
|
+
create_app,
|
|
21
|
+
echo,
|
|
22
|
+
emit,
|
|
23
|
+
existing_directory,
|
|
24
|
+
existing_file,
|
|
25
|
+
parse_kv_pairs,
|
|
26
|
+
raise_usage,
|
|
27
|
+
render_rows,
|
|
28
|
+
report_errors,
|
|
29
|
+
resolve_each,
|
|
30
|
+
)
|
|
31
|
+
from untaped.config_file import ensure_config, mutate_tool_state, read_tool_state
|
|
32
|
+
from untaped.errors import (
|
|
33
|
+
ConfigError,
|
|
34
|
+
HttpError,
|
|
35
|
+
HttpStatusError,
|
|
36
|
+
HttpTransportError,
|
|
37
|
+
UntapedError,
|
|
38
|
+
first_validation_error,
|
|
39
|
+
)
|
|
40
|
+
from untaped.http import (
|
|
41
|
+
HttpClient,
|
|
42
|
+
RetryPolicy,
|
|
43
|
+
connected_client,
|
|
44
|
+
paginate_offset,
|
|
45
|
+
paginate_pages,
|
|
46
|
+
resolve_verify,
|
|
47
|
+
)
|
|
48
|
+
from untaped.output import OutputFormat
|
|
49
|
+
from untaped.pipe import PipeEnvelope, common_kind, is_envelope_line, parse_envelope_line
|
|
50
|
+
from untaped.progress import ProgressHandle
|
|
51
|
+
from untaped.prompts import PromptChoice
|
|
52
|
+
from untaped.run import build_tool_app, run_tool
|
|
53
|
+
from untaped.settings import (
|
|
54
|
+
HttpSettings,
|
|
55
|
+
get_config_section,
|
|
56
|
+
get_core_settings,
|
|
57
|
+
get_settings,
|
|
58
|
+
)
|
|
59
|
+
from untaped.stdin import read_identifiers, read_records, read_stdin
|
|
60
|
+
from untaped.theme import ThemeSpec
|
|
61
|
+
from untaped.tool import SkillAsset, ToolSpec, register_tool
|
|
62
|
+
from untaped.ui import UiContext, ui_context
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def invalidate_settings_cache() -> None:
|
|
66
|
+
"""Drop the cached settings instance so the next read re-resolves.
|
|
67
|
+
|
|
68
|
+
Root-option handlers (e.g. the built-in ``--profile``) call this after
|
|
69
|
+
changing process state that feeds settings resolution.
|
|
70
|
+
"""
|
|
71
|
+
get_settings.cache_clear()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"AppContext",
|
|
76
|
+
"BatchOutcome",
|
|
77
|
+
"ColumnsOption",
|
|
78
|
+
"ConfigError",
|
|
79
|
+
"FormatOption",
|
|
80
|
+
"HttpClient",
|
|
81
|
+
"HttpError",
|
|
82
|
+
"HttpSettings",
|
|
83
|
+
"HttpStatusError",
|
|
84
|
+
"HttpTransportError",
|
|
85
|
+
"OutputFormat",
|
|
86
|
+
"PipeEnvelope",
|
|
87
|
+
"ProgressHandle",
|
|
88
|
+
"PromptChoice",
|
|
89
|
+
"RetryPolicy",
|
|
90
|
+
"SkillAsset",
|
|
91
|
+
"ThemeSpec",
|
|
92
|
+
"ToolSpec",
|
|
93
|
+
"UiContext",
|
|
94
|
+
"UntapedError",
|
|
95
|
+
"app_context",
|
|
96
|
+
"batch_apply",
|
|
97
|
+
"build_tool_app",
|
|
98
|
+
"clamp_parallel",
|
|
99
|
+
"common_kind",
|
|
100
|
+
"connected_client",
|
|
101
|
+
"create_app",
|
|
102
|
+
"echo",
|
|
103
|
+
"emit",
|
|
104
|
+
"ensure_config",
|
|
105
|
+
"existing_directory",
|
|
106
|
+
"existing_file",
|
|
107
|
+
"first_validation_error",
|
|
108
|
+
"get_config_section",
|
|
109
|
+
"get_core_settings",
|
|
110
|
+
"get_settings",
|
|
111
|
+
"invalidate_settings_cache",
|
|
112
|
+
"is_envelope_line",
|
|
113
|
+
"mutate_tool_state",
|
|
114
|
+
"paginate_offset",
|
|
115
|
+
"paginate_pages",
|
|
116
|
+
"parse_envelope_line",
|
|
117
|
+
"parse_kv_pairs",
|
|
118
|
+
"raise_usage",
|
|
119
|
+
"read_identifiers",
|
|
120
|
+
"read_records",
|
|
121
|
+
"read_stdin",
|
|
122
|
+
"read_tool_state",
|
|
123
|
+
"register_tool",
|
|
124
|
+
"render_rows",
|
|
125
|
+
"report_errors",
|
|
126
|
+
"resolve_each",
|
|
127
|
+
"resolve_verify",
|
|
128
|
+
"run_tool",
|
|
129
|
+
"ui_context",
|
|
130
|
+
]
|
untaped/app_context.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Per-invocation tool execution context.
|
|
2
|
+
|
|
3
|
+
``app_context()`` resolves settings exactly once and hands a tool a frozen
|
|
4
|
+
value object. Profile (or any other scope) selection happens before command
|
|
5
|
+
dispatch via the root ``--profile`` option, so nothing about the resolution
|
|
6
|
+
leaks into ambient process state from here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from untaped.errors import ConfigError
|
|
16
|
+
from untaped.settings import HttpSettings, Settings, get_settings
|
|
17
|
+
from untaped.theme import UiSettings, resolve_theme_or_default
|
|
18
|
+
from untaped.ui import UiContext, ui_context
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AppContext:
|
|
23
|
+
"""Resolved settings plus typed accessors handed to a tool command."""
|
|
24
|
+
|
|
25
|
+
settings: Settings
|
|
26
|
+
|
|
27
|
+
def section[T: BaseModel](self, name: str, model_cls: type[T]) -> T:
|
|
28
|
+
"""Return one typed, registered settings section.
|
|
29
|
+
|
|
30
|
+
Unlike :func:`untaped.settings.get_config_section`, this never builds
|
|
31
|
+
a one-off model for unregistered sections — the context is a frozen
|
|
32
|
+
snapshot, so it can only serve sections that were registered (via
|
|
33
|
+
:func:`untaped.tool.register_tool`) before it was created.
|
|
34
|
+
"""
|
|
35
|
+
value = getattr(self.settings, name, None)
|
|
36
|
+
if value is None:
|
|
37
|
+
raise ConfigError(
|
|
38
|
+
f"config section {name!r} is not registered; register the tool's "
|
|
39
|
+
f"section with untaped.tool.register_tool before resolving a context"
|
|
40
|
+
)
|
|
41
|
+
if isinstance(value, model_cls):
|
|
42
|
+
return value
|
|
43
|
+
if isinstance(value, BaseModel):
|
|
44
|
+
return model_cls.model_validate(value.model_dump())
|
|
45
|
+
return model_cls.model_validate(value)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def http(self) -> HttpSettings:
|
|
49
|
+
"""Cross-cutting HTTP settings for building clients."""
|
|
50
|
+
return self.settings.http
|
|
51
|
+
|
|
52
|
+
def ui(self, *, strict: bool = True) -> UiContext:
|
|
53
|
+
"""The themed UI context for messages and prompts.
|
|
54
|
+
|
|
55
|
+
Built from this context's frozen settings snapshot, so the theme is
|
|
56
|
+
stable for the life of the context even if the settings cache is later
|
|
57
|
+
invalidated. ``strict=False`` degrades a theme-resolution
|
|
58
|
+
:class:`ConfigError` (e.g. an unknown theme name) to the default theme.
|
|
59
|
+
"""
|
|
60
|
+
theme = resolve_theme_or_default(lambda: self.section("ui", UiSettings), strict=strict)
|
|
61
|
+
return ui_context(theme=theme)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def app_context() -> AppContext:
|
|
65
|
+
"""Resolve effective settings once and return a frozen :class:`AppContext`.
|
|
66
|
+
|
|
67
|
+
Profile selection happens before dispatch via the root ``--profile`` option,
|
|
68
|
+
so no parameters are needed here.
|
|
69
|
+
"""
|
|
70
|
+
return AppContext(settings=get_settings())
|
untaped/batch.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Standardized destructive-batch UX: preview → confirm → execute → summarize.
|
|
2
|
+
|
|
3
|
+
:func:`batch_apply` is the shared front-end a tool uses to act on a *set* of
|
|
4
|
+
already-resolved items (typically read from a ``--format pipe`` stream via
|
|
5
|
+
:func:`untaped.stdin.read_identifiers` / :func:`untaped.stdin.read_records`). It
|
|
6
|
+
previews the targets, gates a destructive verb behind a confirmation (or
|
|
7
|
+
``--yes``), runs the per-item ``action`` under a progress indicator, and reports
|
|
8
|
+
per-item failures — leaving the caller to render the outcome rows and choose the
|
|
9
|
+
exit code (summary shape and prior-failure composition are caller concerns).
|
|
10
|
+
|
|
11
|
+
This is distinct from the ``apply`` *command* some tools expose (a file-based
|
|
12
|
+
declarative reconciler): :func:`batch_apply` is a pipe-consumer mutation helper,
|
|
13
|
+
not a YAML applier, and its ``--yes`` means "skip the confirm" (not "write").
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from untaped.cli import echo
|
|
23
|
+
from untaped.errors import ConfigError, UntapedError
|
|
24
|
+
from untaped.ui import UiContext
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class BatchOutcome[T, R]:
|
|
29
|
+
"""The result of a :func:`batch_apply` run.
|
|
30
|
+
|
|
31
|
+
``results`` pairs each successfully actioned item with its action result so
|
|
32
|
+
callers keep the originating input (e.g. for a ``(name, job)`` monitor
|
|
33
|
+
phase). ``planned_rows`` is ``describe(item)`` for every input — reused for
|
|
34
|
+
the ``--dry-run`` output and any summary so callers don't recompute it.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
results: list[tuple[T, R]]
|
|
38
|
+
failed: int
|
|
39
|
+
planned_rows: list[dict[str, object]]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def any_failed(self) -> bool:
|
|
43
|
+
return self.failed > 0
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def total(self) -> int:
|
|
47
|
+
return len(self.planned_rows)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _stdin_is_interactive() -> bool:
|
|
51
|
+
# Whether stdin is a real terminal we can prompt on. False when stdin is the
|
|
52
|
+
# data pipe (``list --format pipe | <verb> --stdin``), so the gate never
|
|
53
|
+
# tries to read a y/N from the data — it refuses with a --yes hint instead.
|
|
54
|
+
return sys.stdin.isatty()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def batch_apply[T, R](
|
|
58
|
+
items: Sequence[T],
|
|
59
|
+
action: Callable[[T], R],
|
|
60
|
+
*,
|
|
61
|
+
verb: str,
|
|
62
|
+
noun: str,
|
|
63
|
+
label: Callable[[T], str],
|
|
64
|
+
describe: Callable[[T], dict[str, object]],
|
|
65
|
+
ui: UiContext,
|
|
66
|
+
destructive: bool = False,
|
|
67
|
+
assume_yes: bool = False,
|
|
68
|
+
preview_only: bool = False,
|
|
69
|
+
render_generic_preview: bool = True,
|
|
70
|
+
) -> BatchOutcome[T, R]:
|
|
71
|
+
"""Preview, optionally confirm, then run ``action`` over ``items``.
|
|
72
|
+
|
|
73
|
+
``verb``/``noun`` phrase the preview and progress ("delete"/"JobTemplate").
|
|
74
|
+
``label(item)`` is the identifier shown in progress and ``error: <label>: …``
|
|
75
|
+
lines; ``describe(item)`` is the row used for the preview and ``planned_rows``.
|
|
76
|
+
|
|
77
|
+
A **destructive** verb gates execution: with ``assume_yes`` it proceeds; on an
|
|
78
|
+
interactive stdin it previews then prompts (decline → no action run);
|
|
79
|
+
otherwise it raises :class:`ConfigError` (stdin is the data pipe, so there is
|
|
80
|
+
nothing to confirm against — pass ``--yes``). Callers that already rendered a
|
|
81
|
+
richer preview can pass ``render_generic_preview=False`` to keep the
|
|
82
|
+
confirmation prompt without the generic tab-row preview. Benign verbs and
|
|
83
|
+
``--yes`` skip straight to execution. ``preview_only`` (``--dry-run``) returns
|
|
84
|
+
``planned_rows`` without running ``action``.
|
|
85
|
+
|
|
86
|
+
Per-item :class:`UntapedError` is caught and counted; anything else
|
|
87
|
+
propagates. The helper never renders the summary or raises ``SystemExit`` —
|
|
88
|
+
the caller owns stdout and the exit code.
|
|
89
|
+
"""
|
|
90
|
+
planned_rows = [describe(item) for item in items]
|
|
91
|
+
if not items or preview_only:
|
|
92
|
+
return BatchOutcome(results=[], failed=0, planned_rows=planned_rows)
|
|
93
|
+
total = len(planned_rows)
|
|
94
|
+
if destructive and not assume_yes:
|
|
95
|
+
if _stdin_is_interactive():
|
|
96
|
+
if render_generic_preview:
|
|
97
|
+
echo(f"About to {verb} {total} {noun}(s):", err=True)
|
|
98
|
+
for row in planned_rows:
|
|
99
|
+
echo(" - " + "\t".join(str(value) for value in row.values()), err=True)
|
|
100
|
+
if not ui.confirm("Continue?"):
|
|
101
|
+
return BatchOutcome(results=[], failed=0, planned_rows=planned_rows)
|
|
102
|
+
else:
|
|
103
|
+
raise ConfigError(f"{verb} requires --yes when stdin is not interactive")
|
|
104
|
+
results: list[tuple[T, R]] = []
|
|
105
|
+
failed = 0
|
|
106
|
+
with ui.progress(f"{verb.capitalize()} {total} {noun}(s)") as handle:
|
|
107
|
+
for index, item in enumerate(items, 1):
|
|
108
|
+
handle.update(label(item), fraction=index / total)
|
|
109
|
+
try:
|
|
110
|
+
results.append((item, action(item)))
|
|
111
|
+
except UntapedError as exc:
|
|
112
|
+
echo(f"error: {label(item)}: {exc}", err=True)
|
|
113
|
+
failed += 1
|
|
114
|
+
return BatchOutcome(results=results, failed=failed, planned_rows=planned_rows)
|
untaped/cli.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""CLI helpers shared by every Cyclopts command in the suite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
|
9
|
+
from contextlib import contextmanager, suppress
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated, Any, Literal, NoReturn
|
|
12
|
+
|
|
13
|
+
from cyclopts import App, Parameter
|
|
14
|
+
from cyclopts.exceptions import CycloptsError
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from untaped.errors import HttpError, UntapedError
|
|
19
|
+
from untaped.output import OutputFormat
|
|
20
|
+
from untaped.ui import UiContext, ui_context
|
|
21
|
+
from untaped.verbose import is_verbose
|
|
22
|
+
|
|
23
|
+
FormatOption = Annotated[
|
|
24
|
+
OutputFormat,
|
|
25
|
+
Parameter(name=["--format", "-f"], help="Output format."),
|
|
26
|
+
]
|
|
27
|
+
"""Shared ``--format / -f`` option for any command that prints rows."""
|
|
28
|
+
|
|
29
|
+
ColumnsOption = Annotated[
|
|
30
|
+
list[str] | None,
|
|
31
|
+
Parameter(
|
|
32
|
+
name=["--columns", "-c"],
|
|
33
|
+
help="Columns to include (repeatable).",
|
|
34
|
+
consume_multiple=False,
|
|
35
|
+
),
|
|
36
|
+
]
|
|
37
|
+
"""Shared ``--columns / -c`` option for any command that prints rows."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_app(*, name: str, help: str = "") -> App:
|
|
41
|
+
"""Create a Cyclopts app with the suite's default command-group settings."""
|
|
42
|
+
return App(name=name, help=help)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def echo(message: object = "", *, err: bool = False, nl: bool = True) -> None:
|
|
46
|
+
"""Print a CLI message to stdout or stderr."""
|
|
47
|
+
end = "\n" if nl else ""
|
|
48
|
+
print(message, file=sys.stderr if err else sys.stdout, end=end)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def raise_usage(message: str) -> NoReturn:
|
|
52
|
+
"""Raise a command-usage error with the suite's stable exit code."""
|
|
53
|
+
echo(f"error: {message}", err=True)
|
|
54
|
+
raise SystemExit(2)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_rows(
|
|
58
|
+
rows: Sequence[dict[str, object]],
|
|
59
|
+
*,
|
|
60
|
+
fmt: OutputFormat,
|
|
61
|
+
columns: list[str] | None = None,
|
|
62
|
+
empty: str | bool | None = None,
|
|
63
|
+
kind: str | None = None,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Render a row collection: themed table for humans, plain output for pipes.
|
|
66
|
+
|
|
67
|
+
Only ``table`` goes through the settings-resolved :func:`ui_context` —
|
|
68
|
+
structured formats (json, raw, pipe, ...) must stay byte-stable regardless of
|
|
69
|
+
the active theme, so they render through a bare :class:`UiContext`. ``empty``
|
|
70
|
+
is a human hint printed to stderr only when ``table`` output has no rows.
|
|
71
|
+
``kind`` tags ``--format pipe`` records with a producer hint (ignored by
|
|
72
|
+
every other format).
|
|
73
|
+
"""
|
|
74
|
+
if columns == ["?"]:
|
|
75
|
+
_print_available_columns(list(rows[0]) if rows else [])
|
|
76
|
+
return ""
|
|
77
|
+
ui = ui_context() if fmt == "table" else UiContext()
|
|
78
|
+
return ui.collection(rows, fmt=fmt, columns=columns, empty=empty, kind=kind)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _print_available_columns(keys: Iterable[str]) -> None:
|
|
82
|
+
"""Print the addressable top-level column names to stderr (for ``--columns ?``)."""
|
|
83
|
+
names = list(dict.fromkeys(keys))
|
|
84
|
+
if not names:
|
|
85
|
+
echo("no columns available (no records to inspect)", err=True)
|
|
86
|
+
return
|
|
87
|
+
echo("available columns:", err=True)
|
|
88
|
+
for name in names:
|
|
89
|
+
echo(f" {name}", err=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def emit(
|
|
93
|
+
records: BaseModel | Mapping[str, object] | Sequence[BaseModel | Mapping[str, object]],
|
|
94
|
+
*,
|
|
95
|
+
fmt: OutputFormat,
|
|
96
|
+
columns: list[str] | None = None,
|
|
97
|
+
empty: str | bool | None = None,
|
|
98
|
+
kind: str | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Render records to stdout, dispatching by shape.
|
|
101
|
+
|
|
102
|
+
A single model or mapping renders as a vertical ``key: value`` detail view
|
|
103
|
+
(a bare object under structured formats); a sequence renders as a collection
|
|
104
|
+
(themed table for humans, array/NDJSON for pipes). Accepts pydantic models
|
|
105
|
+
directly — no manual ``model_dump()`` — and writes the result itself, so
|
|
106
|
+
there is no "forgot to ``echo``" silent-no-output trap. ``empty`` and
|
|
107
|
+
``kind`` behave as in :func:`render_rows`; ``empty`` applies to a sequence
|
|
108
|
+
only.
|
|
109
|
+
"""
|
|
110
|
+
if columns == ["?"]:
|
|
111
|
+
_print_available_columns(_candidate_columns(records))
|
|
112
|
+
return
|
|
113
|
+
if isinstance(records, BaseModel | Mapping):
|
|
114
|
+
ui = ui_context() if fmt == "table" else UiContext()
|
|
115
|
+
rendered = ui.detail(_as_row(records), fmt=fmt, columns=columns, kind=kind)
|
|
116
|
+
else:
|
|
117
|
+
# The collection path is exactly render_rows; reuse it (it returns the
|
|
118
|
+
# string and emits any empty-state hint to stderr itself).
|
|
119
|
+
rendered = render_rows(
|
|
120
|
+
[_as_row(record) for record in records],
|
|
121
|
+
fmt=fmt,
|
|
122
|
+
columns=columns,
|
|
123
|
+
empty=empty,
|
|
124
|
+
kind=kind,
|
|
125
|
+
)
|
|
126
|
+
if rendered:
|
|
127
|
+
echo(rendered)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _as_row(record: BaseModel | Mapping[str, object]) -> dict[str, object]:
|
|
131
|
+
"""Normalize a model or mapping into a plain row dict."""
|
|
132
|
+
if isinstance(record, BaseModel):
|
|
133
|
+
return record.model_dump()
|
|
134
|
+
return dict(record)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _candidate_columns(
|
|
138
|
+
records: BaseModel | Mapping[str, object] | Sequence[BaseModel | Mapping[str, object]],
|
|
139
|
+
) -> list[str]:
|
|
140
|
+
"""Top-level column names a record exposes (for ``emit(..., columns=['?'])``)."""
|
|
141
|
+
if isinstance(records, BaseModel | Mapping):
|
|
142
|
+
return list(_as_row(records))
|
|
143
|
+
for record in records:
|
|
144
|
+
return list(_as_row(record))
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_cyclopts_app(
|
|
149
|
+
app: App,
|
|
150
|
+
tokens: Iterable[str] | None,
|
|
151
|
+
*,
|
|
152
|
+
console: Console | None = None,
|
|
153
|
+
error_console: Console | None = None,
|
|
154
|
+
result_action: Literal[
|
|
155
|
+
"return_value",
|
|
156
|
+
"call_if_callable",
|
|
157
|
+
"print_non_int_return_int_as_exit_code",
|
|
158
|
+
"print_str_return_int_as_exit_code",
|
|
159
|
+
"print_str_return_zero",
|
|
160
|
+
"print_non_none_return_int_as_exit_code",
|
|
161
|
+
"print_non_none_return_zero",
|
|
162
|
+
"return_int_as_exit_code_else_zero",
|
|
163
|
+
"print_non_int_sys_exit",
|
|
164
|
+
"sys_exit",
|
|
165
|
+
"return_none",
|
|
166
|
+
"return_zero",
|
|
167
|
+
"print_return_zero",
|
|
168
|
+
"sys_exit_zero",
|
|
169
|
+
"print_sys_exit_zero",
|
|
170
|
+
]
|
|
171
|
+
| Callable[[Any], Any]
|
|
172
|
+
| None = None,
|
|
173
|
+
) -> object:
|
|
174
|
+
"""Run a Cyclopts app while preserving untaped's usage-error contract.
|
|
175
|
+
|
|
176
|
+
Also converts a broken downstream pipe — the consumer closed it early, e.g.
|
|
177
|
+
``untaped-tool list | head`` or a consumer that exits before reading all of
|
|
178
|
+
its input — into a clean ``SystemExit(1)``. Without this the producer's
|
|
179
|
+
buffered stdout flush fails at interpreter shutdown and Python prints a
|
|
180
|
+
noisy ``Exception ignored while flushing sys.stdout: BrokenPipeError``.
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
result = app(
|
|
184
|
+
tokens,
|
|
185
|
+
console=console,
|
|
186
|
+
error_console=error_console,
|
|
187
|
+
exit_on_error=False,
|
|
188
|
+
print_error=False,
|
|
189
|
+
result_action=result_action,
|
|
190
|
+
)
|
|
191
|
+
except CycloptsError as exc:
|
|
192
|
+
echo(f"error: {exc}", err=True)
|
|
193
|
+
raise SystemExit(2) from exc
|
|
194
|
+
except BrokenPipeError:
|
|
195
|
+
# Pipe broke mid-write (output large enough to flush before we got here).
|
|
196
|
+
_exit_broken_pipe()
|
|
197
|
+
except SystemExit:
|
|
198
|
+
# cyclopts exits (0 on success) rather than returning. Flush buffered
|
|
199
|
+
# stdout now so a broken pipe surfaces here — catchable — instead of at
|
|
200
|
+
# interpreter shutdown, where it can't be handled.
|
|
201
|
+
_flush_stdout()
|
|
202
|
+
raise
|
|
203
|
+
_flush_stdout()
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _flush_stdout() -> None:
|
|
208
|
+
"""Flush stdout, converting a broken pipe into a clean exit."""
|
|
209
|
+
try:
|
|
210
|
+
sys.stdout.flush()
|
|
211
|
+
except BrokenPipeError:
|
|
212
|
+
_exit_broken_pipe()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _exit_broken_pipe() -> NoReturn:
|
|
216
|
+
"""Silence the interpreter's final stdout flush, then exit 1.
|
|
217
|
+
|
|
218
|
+
Redirecting the stdout fd to ``/dev/null`` stops Python re-raising the
|
|
219
|
+
broken pipe when it flushes the standard streams on the way out. The guard
|
|
220
|
+
covers streams with no real fd (a captured ``StringIO`` under tests)."""
|
|
221
|
+
with suppress(OSError, ValueError):
|
|
222
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
223
|
+
raise SystemExit(1) from None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def existing_directory(type_: object, value: Path | None) -> None:
|
|
227
|
+
"""Cyclopts validator for an existing directory path."""
|
|
228
|
+
if value is None:
|
|
229
|
+
return
|
|
230
|
+
if not value.exists():
|
|
231
|
+
raise ValueError(f"path does not exist: {value}")
|
|
232
|
+
if not value.is_dir():
|
|
233
|
+
raise ValueError(f"path is not a directory: {value}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def existing_file(type_: object, value: Path | None) -> None:
|
|
237
|
+
"""Cyclopts validator for an existing file path."""
|
|
238
|
+
if value is None:
|
|
239
|
+
return
|
|
240
|
+
if not value.exists():
|
|
241
|
+
raise ValueError(f"path does not exist: {value}")
|
|
242
|
+
if not value.is_file():
|
|
243
|
+
raise ValueError(f"path is not a file: {value}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def parse_kv_pairs(values: Iterable[str] | None, *, flag: str) -> dict[str, str]:
|
|
247
|
+
"""Parse repeated ``KEY=VALUE`` flag entries into a dict.
|
|
248
|
+
|
|
249
|
+
Splits on the first ``=`` so values containing ``=`` survive intact.
|
|
250
|
+
Malformed entries are rejected up front rather than passed through.
|
|
251
|
+
"""
|
|
252
|
+
if not values:
|
|
253
|
+
return {}
|
|
254
|
+
out: dict[str, str] = {}
|
|
255
|
+
for entry in values:
|
|
256
|
+
key, sep, value = entry.partition("=")
|
|
257
|
+
key = key.strip()
|
|
258
|
+
if not sep or not key:
|
|
259
|
+
raise_usage(f"{flag} expects KEY=VALUE (got {entry!r})")
|
|
260
|
+
out[key] = value
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def resolve_each[R](ids: list[str], fn: Callable[[str], R]) -> tuple[list[R], bool]:
|
|
265
|
+
"""Resolve each identifier via ``fn``; aggregate per-id failures.
|
|
266
|
+
|
|
267
|
+
Echoes ``error: <id>: <exc>`` to stderr for any :class:`UntapedError` and
|
|
268
|
+
returns ``(results, any_failed)`` so the caller decides exit code and
|
|
269
|
+
aggregate rendering. Companion to :func:`read_identifiers` for stdin-fed
|
|
270
|
+
list commands across domains.
|
|
271
|
+
|
|
272
|
+
Only :class:`UntapedError` is caught: non-:class:`UntapedError` exceptions
|
|
273
|
+
(including :class:`SystemExit` raised by interactive prompts) propagate
|
|
274
|
+
immediately, aborting the loop. This is intentional — bugs and explicit
|
|
275
|
+
user aborts must not be swallowed alongside per-id resolution failures.
|
|
276
|
+
"""
|
|
277
|
+
results: list[R] = []
|
|
278
|
+
any_failed = False
|
|
279
|
+
for id_ in ids:
|
|
280
|
+
try:
|
|
281
|
+
results.append(fn(id_))
|
|
282
|
+
except UntapedError as exc:
|
|
283
|
+
echo(f"error: {id_}: {_format_error(exc)}", err=True)
|
|
284
|
+
any_failed = True
|
|
285
|
+
return results, any_failed
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def clamp_parallel(requested: int, *, cap: int, policy: str) -> int:
|
|
289
|
+
"""Cap ``--parallel`` at ``cap`` with a uniform stderr warning.
|
|
290
|
+
|
|
291
|
+
Shared by every Cyclopts command that exposes ``-j / --parallel``
|
|
292
|
+
(workspace sync, workspace foreach, awx apply, ...) so the
|
|
293
|
+
cap-with-warning shape is one helper, not one per call site.
|
|
294
|
+
Friendly clamp rather than ``BadParameter`` so shell idioms like
|
|
295
|
+
``-j $(nproc)`` keep composing on hosts where ``nproc`` already
|
|
296
|
+
exceeds the cap.
|
|
297
|
+
|
|
298
|
+
Only handles the upper bound. ``< 1`` policy stays per-caller
|
|
299
|
+
(workspace foreach silently coerces; sync and awx apply reject) — the lower
|
|
300
|
+
bound isn't a typo-vs-typo judgement, it's per-command UX.
|
|
301
|
+
|
|
302
|
+
``policy`` is a short human-readable rationale (e.g.
|
|
303
|
+
``"2 * os.cpu_count()"`` or ``"HTTP connection pool default"``)
|
|
304
|
+
appended in parens so users know *why* their value was capped
|
|
305
|
+
without grepping source.
|
|
306
|
+
"""
|
|
307
|
+
if requested <= cap:
|
|
308
|
+
return requested
|
|
309
|
+
echo(
|
|
310
|
+
f"warning: --parallel {requested} clamped to {cap} ({policy})",
|
|
311
|
+
err=True,
|
|
312
|
+
)
|
|
313
|
+
return cap
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@contextmanager
|
|
317
|
+
def report_errors() -> Iterator[None]:
|
|
318
|
+
"""Convert :class:`UntapedError` into a clean stderr message + exit code 1.
|
|
319
|
+
|
|
320
|
+
Wrap every Cyclopts command body in this so users see ``error: ...``
|
|
321
|
+
instead of a Python traceback. Non-:class:`UntapedError` exceptions are
|
|
322
|
+
left to Cyclopts' default handling — those represent bugs we want to see.
|
|
323
|
+
"""
|
|
324
|
+
try:
|
|
325
|
+
yield
|
|
326
|
+
except UntapedError as exc:
|
|
327
|
+
echo(f"error: {_format_error(exc)}", err=True)
|
|
328
|
+
raise SystemExit(1) from exc
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _format_error(exc: UntapedError) -> str:
|
|
332
|
+
message = str(exc)
|
|
333
|
+
if isinstance(exc, HttpError) and not exc.body and exc.url and exc.url not in message:
|
|
334
|
+
message = f"{message} for {exc.url}"
|
|
335
|
+
if not isinstance(exc, HttpError) or not exc.body:
|
|
336
|
+
return message
|
|
337
|
+
friendly = _api_error_message(exc.body)
|
|
338
|
+
if friendly is None:
|
|
339
|
+
# Unparseable / unrecognised body — show it raw so detail isn't lost.
|
|
340
|
+
return f"{message}\nresponse: {exc.body}"
|
|
341
|
+
if is_verbose():
|
|
342
|
+
return f"{message} — {friendly}\nresponse: {exc.body}"
|
|
343
|
+
return f"{message} — {friendly}"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _api_error_message(body: str) -> str | None:
|
|
347
|
+
"""Pull a human message out of a JSON error body, if present.
|
|
348
|
+
|
|
349
|
+
Recognises the shapes most JSON APIs use — a top-level
|
|
350
|
+
``message``/``error``/``detail`` string or ``errors: [{"message": ...}]``
|
|
351
|
+
(GitHub, AWX, DRF, ...) — and returns the first match. Returns ``None`` for
|
|
352
|
+
a non-JSON body or an unrecognised shape so the caller falls back to the raw
|
|
353
|
+
snippet.
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
data = json.loads(body)
|
|
357
|
+
except ValueError:
|
|
358
|
+
return None
|
|
359
|
+
if not isinstance(data, dict):
|
|
360
|
+
return None
|
|
361
|
+
for key in ("message", "error", "detail"):
|
|
362
|
+
value = data.get(key)
|
|
363
|
+
if isinstance(value, str) and value.strip():
|
|
364
|
+
return value.strip()
|
|
365
|
+
errors = data.get("errors")
|
|
366
|
+
if isinstance(errors, list):
|
|
367
|
+
for item in errors:
|
|
368
|
+
if isinstance(item, dict):
|
|
369
|
+
nested = item.get("message")
|
|
370
|
+
if isinstance(nested, str) and nested.strip():
|
|
371
|
+
return nested.strip()
|
|
372
|
+
return None
|