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.
Files changed (52) hide show
  1. untaped/__init__.py +10 -0
  2. untaped/api.py +130 -0
  3. untaped/app_context.py +70 -0
  4. untaped/batch.py +114 -0
  5. untaped/cli.py +372 -0
  6. untaped/config/__init__.py +5 -0
  7. untaped/config/application/__init__.py +19 -0
  8. untaped/config/application/context.py +67 -0
  9. untaped/config/application/get_setting.py +26 -0
  10. untaped/config/application/list_settings.py +135 -0
  11. untaped/config/application/ports.py +31 -0
  12. untaped/config/application/set_setting.py +39 -0
  13. untaped/config/application/unset_setting.py +40 -0
  14. untaped/config/domain/__init__.py +4 -0
  15. untaped/config/domain/formatting.py +28 -0
  16. untaped/config/domain/models.py +49 -0
  17. untaped/config/infrastructure/__init__.py +3 -0
  18. untaped/config/infrastructure/settings_repo.py +181 -0
  19. untaped/config_app.py +406 -0
  20. untaped/config_file.py +207 -0
  21. untaped/config_schema.py +142 -0
  22. untaped/errors.py +74 -0
  23. untaped/http.py +549 -0
  24. untaped/identity.py +32 -0
  25. untaped/output.py +54 -0
  26. untaped/pipe.py +96 -0
  27. untaped/profile/__init__.py +17 -0
  28. untaped/profile/app.py +263 -0
  29. untaped/profile/models.py +42 -0
  30. untaped/profile/ports.py +46 -0
  31. untaped/profile/repository.py +119 -0
  32. untaped/profile/use_cases.py +208 -0
  33. untaped/profile_resolver.py +139 -0
  34. untaped/progress.py +224 -0
  35. untaped/prompts.py +274 -0
  36. untaped/py.typed +0 -0
  37. untaped/quiet.py +33 -0
  38. untaped/run.py +322 -0
  39. untaped/settings.py +376 -0
  40. untaped/settings_layout.py +83 -0
  41. untaped/skills.py +390 -0
  42. untaped/skills_app.py +134 -0
  43. untaped/stdin.py +116 -0
  44. untaped/testing.py +127 -0
  45. untaped/theme.py +148 -0
  46. untaped/tool.py +85 -0
  47. untaped/ui.py +584 -0
  48. untaped/verbose.py +59 -0
  49. untaped-2.4.4.dist-info/METADATA +153 -0
  50. untaped-2.4.4.dist-info/RECORD +52 -0
  51. untaped-2.4.4.dist-info/WHEEL +4 -0
  52. 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