lib-cli-exit-tools 2.2.1__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.
@@ -0,0 +1,261 @@
1
+ """Click-based CLI adapter for lib_cli_exit_tools.
2
+
3
+ Purpose:
4
+ Provide the end-user command-line surface while delegating error-handling
5
+ and exit-code logic to :mod:`lib_cli_exit_tools.lib_cli_exit_tools`.
6
+ Contents:
7
+ * :func:`cli` group exposing shared options.
8
+ * :func:`cli_info` subcommand reporting distribution metadata.
9
+ * :func:`cli_fail` subcommand triggering a deterministic failure for testing error paths.
10
+ * :func:`main` entry point used by console scripts and ``python -m``.
11
+ System Integration:
12
+ The CLI mutates :data:`lib_cli_exit_tools.config` based on the ``--traceback``
13
+ flag before handing execution off to :func:`lib_cli_exit_tools.run_cli`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from contextlib import contextmanager
19
+ from dataclasses import dataclass
20
+ from typing import Any, Iterator, Sequence, TypedDict
21
+
22
+ import rich_click as click
23
+ from rich_click import rich_click as rich_config
24
+
25
+ from . import __init__conf__
26
+ from . import lib_cli_exit_tools
27
+
28
+ #: Help flag aliases applied to every Click command so documentation and CLI
29
+ #: behaviour stay consistent (`-h` mirrors `--help`).
30
+ #: Note: This uses dict literal syntax as required by Click framework API.
31
+ CLICK_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
32
+
33
+
34
+ class RichClickSnapshot(TypedDict):
35
+ """Type-safe snapshot of rich-click global styling configuration.
36
+
37
+ Attributes:
38
+ FORCE_TERMINAL: Whether to force terminal styling.
39
+ COLOR_SYSTEM: Color system to use (e.g., "auto", "256", "truecolor", None).
40
+ STYLE_OPTIONS_PANEL_BOX: Box style for options panel.
41
+ STYLE_COMMANDS_PANEL_BOX: Box style for commands panel.
42
+ STYLE_ERRORS_PANEL_BOX: Box style for errors panel.
43
+
44
+ Note:
45
+ Uses Any for field types to accommodate the dynamic nature of rich-click
46
+ configuration, which doesn't provide stable typing guarantees.
47
+ """
48
+
49
+ FORCE_TERMINAL: Any
50
+ COLOR_SYSTEM: Any
51
+ STYLE_OPTIONS_PANEL_BOX: Any
52
+ STYLE_COMMANDS_PANEL_BOX: Any
53
+ STYLE_ERRORS_PANEL_BOX: Any
54
+
55
+
56
+ @dataclass
57
+ class CliContextState:
58
+ """Type-safe container for Click context object state.
59
+
60
+ Why:
61
+ Replaces untyped dict usage in ctx.obj to enforce type safety and
62
+ prevent string literal key access violations.
63
+
64
+ Attributes:
65
+ traceback: Whether to show full Python tracebacks on errors.
66
+ """
67
+
68
+ traceback: bool = False
69
+
70
+
71
+ def _needs_plain_output(stream: object) -> bool:
72
+ """Tell whether ``stream`` will honour Rich styling."""
73
+
74
+ return (not _stream_is_tty(stream)) or (not _stream_supports_utf(stream))
75
+
76
+
77
+ def _stream_is_tty(stream: object) -> bool:
78
+ """Return ``True`` when ``stream`` behaves like a TTY."""
79
+ checker = getattr(stream, "isatty", lambda: False)
80
+ try:
81
+ return bool(checker())
82
+ except Exception: # pragma: no cover - defensive shield
83
+ return False
84
+
85
+
86
+ def _stream_supports_utf(stream: object) -> bool:
87
+ """Tell whether ``stream`` reports a UTF-friendly encoding."""
88
+ encoding = (getattr(stream, "encoding", "") or "").lower()
89
+ return "utf" in encoding
90
+
91
+
92
+ def _prefer_ascii_layout() -> None:
93
+ """Downgrade rich-click global styling to ASCII-friendly defaults."""
94
+ rich_config.FORCE_TERMINAL = False
95
+ rich_config.COLOR_SYSTEM = None
96
+ rich_config.STYLE_OPTIONS_PANEL_BOX = None
97
+ rich_config.STYLE_COMMANDS_PANEL_BOX = None
98
+ rich_config.STYLE_ERRORS_PANEL_BOX = None
99
+
100
+
101
+ def _snapshot_rich_click_options() -> RichClickSnapshot:
102
+ """Capture rich-click global styling toggles for later restoration."""
103
+
104
+ return RichClickSnapshot(
105
+ FORCE_TERMINAL=getattr(rich_config, "FORCE_TERMINAL", None),
106
+ COLOR_SYSTEM=getattr(rich_config, "COLOR_SYSTEM", None),
107
+ STYLE_OPTIONS_PANEL_BOX=getattr(rich_config, "STYLE_OPTIONS_PANEL_BOX", None),
108
+ STYLE_COMMANDS_PANEL_BOX=getattr(rich_config, "STYLE_COMMANDS_PANEL_BOX", None),
109
+ STYLE_ERRORS_PANEL_BOX=getattr(rich_config, "STYLE_ERRORS_PANEL_BOX", None),
110
+ )
111
+
112
+
113
+ def _restore_rich_click_options(snapshot: RichClickSnapshot) -> None:
114
+ """Restore rich-click globals to the captured snapshot."""
115
+
116
+ rich_config.FORCE_TERMINAL = snapshot["FORCE_TERMINAL"]
117
+ rich_config.COLOR_SYSTEM = snapshot["COLOR_SYSTEM"]
118
+ rich_config.STYLE_OPTIONS_PANEL_BOX = snapshot["STYLE_OPTIONS_PANEL_BOX"]
119
+ rich_config.STYLE_COMMANDS_PANEL_BOX = snapshot["STYLE_COMMANDS_PANEL_BOX"]
120
+ rich_config.STYLE_ERRORS_PANEL_BOX = snapshot["STYLE_ERRORS_PANEL_BOX"]
121
+
122
+
123
+ @contextmanager
124
+ def _temporary_rich_click_configuration() -> Iterator[None]:
125
+ """Apply plain-output safeguards and restore rich-click globals afterwards."""
126
+
127
+ snapshot = _snapshot_rich_click_options()
128
+ stdout_stream = click.get_text_stream("stdout")
129
+ stderr_stream = click.get_text_stream("stderr")
130
+ if _needs_plain_output(stdout_stream) and _needs_plain_output(stderr_stream):
131
+ _prefer_ascii_layout()
132
+ try:
133
+ yield
134
+ finally:
135
+ _restore_rich_click_options(snapshot)
136
+
137
+
138
+ @click.group(help=__init__conf__.title, context_settings=CLICK_CONTEXT_SETTINGS)
139
+ @click.version_option(
140
+ version=__init__conf__.version,
141
+ prog_name=__init__conf__.shell_command,
142
+ message=f"{__init__conf__.shell_command} version {__init__conf__.version}",
143
+ )
144
+ @click.option(
145
+ "--traceback/--no-traceback",
146
+ is_flag=True,
147
+ default=False,
148
+ help="Show full Python traceback on errors",
149
+ )
150
+ @click.pass_context
151
+ def cli(ctx: click.Context, traceback: bool) -> None:
152
+ """Root Click group that primes shared configuration state.
153
+
154
+ Why:
155
+ Accept a single ``--traceback`` flag that determines whether downstream
156
+ helpers emit stack traces.
157
+ Parameters:
158
+ ctx: Click context object for the current invocation.
159
+ traceback: When ``True`` enables traceback output for subsequent commands.
160
+ Side Effects:
161
+ Mutates ``ctx.obj`` and :data:`lib_cli_exit_tools.config.traceback`.
162
+ Examples:
163
+ >>> from click.testing import CliRunner
164
+ >>> runner = CliRunner()
165
+ >>> result = runner.invoke(cli, ["--help"])
166
+ >>> result.exit_code == 0
167
+ True
168
+ """
169
+ _store_traceback_flag(ctx, traceback)
170
+ lib_cli_exit_tools.config.traceback = traceback
171
+
172
+
173
+ def _store_traceback_flag(ctx: click.Context, traceback: bool) -> None:
174
+ """Store traceback flag in typed context state.
175
+
176
+ Parameters:
177
+ ctx: Click context object for the current invocation.
178
+ traceback: Whether to show full Python tracebacks on errors.
179
+
180
+ Side Effects:
181
+ Initializes ctx.obj as CliContextState if not already set and updates
182
+ the traceback field.
183
+ """
184
+ ctx.ensure_object(CliContextState)
185
+ # Type narrowing: ctx.obj is guaranteed to be CliContextState after ensure_object
186
+ state = ctx.obj
187
+ if isinstance(state, CliContextState): # nosec B101 - type guard, not assertion
188
+ state.traceback = traceback
189
+
190
+
191
+ @cli.command("info", context_settings=CLICK_CONTEXT_SETTINGS)
192
+ def cli_info() -> None:
193
+ """Display package metadata exported by :mod:`lib_cli_exit_tools.__init__conf__`.
194
+
195
+ Why:
196
+ Offer a zero-dependency way for users to confirm the installed version
197
+ and provenance of the CLI.
198
+ Side Effects:
199
+ Writes formatted metadata to stdout.
200
+ Examples:
201
+ >>> from click.testing import CliRunner
202
+ >>> runner = CliRunner()
203
+ >>> result = runner.invoke(cli, ["info"])
204
+ >>> "Info for" in result.output
205
+ True
206
+ """
207
+ __init__conf__.print_info()
208
+
209
+
210
+ @cli.command("fail", context_settings=CLICK_CONTEXT_SETTINGS)
211
+ def cli_fail() -> None:
212
+ """Intentionally raise a failure to validate error handling.
213
+
214
+ Why:
215
+ Exercise the error-reporting path so engineers can confirm exit-code
216
+ translation and optional traceback behaviour without crafting custom
217
+ failing commands.
218
+ Side Effects:
219
+ Delegates to :func:`lib_cli_exit_tools.i_should_fail`, which raises
220
+ ``RuntimeError`` for the caller to handle.
221
+ Examples:
222
+ >>> from click.testing import CliRunner
223
+ >>> runner = CliRunner()
224
+ >>> result = runner.invoke(cli, ["fail"])
225
+ >>> result.exit_code != 0
226
+ True
227
+ >>> "i should fail" in result.output or "i should fail" in result.stderr
228
+ True
229
+ """
230
+
231
+ lib_cli_exit_tools.i_should_fail()
232
+
233
+
234
+ def main(argv: Sequence[str] | None = None) -> int:
235
+ """Run the CLI with :func:`lib_cli_exit_tools.run_cli` wiring.
236
+
237
+ Why:
238
+ Serve as the target for console scripts and ``python -m`` execution by
239
+ returning an integer exit code instead of exiting directly.
240
+ Parameters:
241
+ argv: Optional iterable of arguments passed to Click (without program name).
242
+ Returns:
243
+ Integer exit code from :func:`lib_cli_exit_tools.run_cli`.
244
+ Side Effects:
245
+ Delegates to Click and may write to stdout/stderr.
246
+ Examples:
247
+ >>> import contextlib, io
248
+ >>> buffer = io.StringIO()
249
+ >>> with contextlib.redirect_stdout(buffer):
250
+ ... exit_code = main(["info"])
251
+ >>> exit_code
252
+ 0
253
+ >>> "Info for" in buffer.getvalue()
254
+ True
255
+ """
256
+ with _temporary_rich_click_configuration():
257
+ return lib_cli_exit_tools.run_cli(
258
+ cli,
259
+ argv=list(argv) if argv is not None else None,
260
+ prog_name=__init__conf__.shell_command,
261
+ )
@@ -0,0 +1,16 @@
1
+ """Core domain logic for lib_cli_exit_tools.
2
+
3
+ Purpose:
4
+ Host configuration dataclasses, exit-code mapping helpers, and other
5
+ framework-agnostic primitives consumed by higher layers.
6
+ Contents:
7
+ Modules extracted from the legacy monolith will expose configuration
8
+ objects and pure helpers for exit handling.
9
+ System Integration:
10
+ Intended to be imported by application orchestration and adapters without
11
+ depending on Click or other I/O frameworks.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __all__: list[str] = []
@@ -0,0 +1,155 @@
1
+ """Runtime configuration primitives for lib_cli_exit_tools.
2
+
3
+ Purpose:
4
+ Expose the mutable configuration dataclass shared across the package so
5
+ adapters can toggle behaviour (tracebacks, exit codes, broken-pipe
6
+ semantics) without re-implementing global state.
7
+ Contents:
8
+ * :class:`_Config` – dataclass capturing toggleable runtime flags.
9
+ * :data:`config` – module-level singleton mutated by CLI adapters and tests.
10
+ * :func:`config_overrides` – context manager that snapshots and restores
11
+ configuration state for embedders and tests.
12
+ * :func:`reset_config` – helper that restores defaults defined by
13
+ :class:`_Config`.
14
+ System Integration:
15
+ Imported by higher layers (`application.runner`, `adapters.click_adapter`)
16
+ to align behaviour while keeping the configuration schema centralized. The
17
+ additional helpers remove the need for ad-hoc fixtures when temporarily
18
+ tweaking settings in multi-layer integrations.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Iterator, Mapping
24
+ from contextlib import contextmanager
25
+ from dataclasses import dataclass, fields
26
+ from typing import Literal, TypedDict
27
+
28
+ __all__ = ["_Config", "config", "config_overrides", "reset_config"]
29
+
30
+
31
+ class ConfigSnapshot(TypedDict):
32
+ """Type-safe snapshot of configuration values.
33
+
34
+ Why:
35
+ Provides compile-time safety when capturing and restoring configuration
36
+ state. Mirrors the _Config dataclass fields exactly to prevent typos
37
+ and ensure type consistency.
38
+ Fields:
39
+ traceback: Current traceback emission flag.
40
+ exit_code_style: Current exit code mapping strategy.
41
+ broken_pipe_exit_code: Current broken pipe exit code.
42
+ traceback_force_color: Current traceback color forcing flag.
43
+ """
44
+
45
+ traceback: bool
46
+ exit_code_style: Literal["errno", "sysexits"]
47
+ broken_pipe_exit_code: int
48
+ traceback_force_color: bool
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class _Config:
53
+ """Centralized runtime flags shared across all CLI executions.
54
+
55
+ Why:
56
+ Prevent each CLI adapter from re-implementing toggles for traceback
57
+ emission and exit-code semantics. The Click adapter mutates these fields
58
+ once per process based on global command options.
59
+ What:
60
+ Stores the behavioural switches consulted by error printers and
61
+ exit-code helpers. Values are mutated in place so that successive calls
62
+ reuse the same configuration.
63
+ Fields:
64
+ traceback: Enables stack-trace passthrough when ``True`` to aid
65
+ debugging without altering default UX for end users.
66
+ exit_code_style: Selects the exit-code mapping strategy, allowing
67
+ consumers to opt into BSD ``sysexits`` values when shell scripts rely
68
+ on them.
69
+ broken_pipe_exit_code: Exit code returned when a ``BrokenPipeError``
70
+ occurs; defaults to ``141`` so pipelines can detect truncated
71
+ output.
72
+ traceback_force_color: Force Rich to emit ANSI-coloured tracebacks even
73
+ when stdout/stderr are not detected as TTYs.
74
+ Side Effects:
75
+ Mutations are process wide because :data:`config` exports a module-level
76
+ instance. Callers should restore values in tests to avoid leakage.
77
+ """
78
+
79
+ traceback: bool = False
80
+ exit_code_style: Literal["errno", "sysexits"] = "errno"
81
+ broken_pipe_exit_code: int = 141
82
+ traceback_force_color: bool = False
83
+
84
+
85
+ #: Shared configuration singleton consulted by CLI orchestration helpers.
86
+ config = _Config()
87
+
88
+
89
+ def _field_names() -> tuple[str, ...]:
90
+ """Return the ordered configuration field names."""
91
+
92
+ return tuple(field.name for field in fields(_Config))
93
+
94
+
95
+ def _default_values() -> ConfigSnapshot:
96
+ """Return the canonical configuration defaults as a type-safe snapshot."""
97
+
98
+ defaults = _Config()
99
+ return ConfigSnapshot(
100
+ traceback=defaults.traceback,
101
+ exit_code_style=defaults.exit_code_style,
102
+ broken_pipe_exit_code=defaults.broken_pipe_exit_code,
103
+ traceback_force_color=defaults.traceback_force_color,
104
+ )
105
+
106
+
107
+ def _snapshot_current_settings() -> ConfigSnapshot:
108
+ """Capture the current configuration values as a type-safe snapshot."""
109
+
110
+ return ConfigSnapshot(
111
+ traceback=config.traceback,
112
+ exit_code_style=config.exit_code_style,
113
+ broken_pipe_exit_code=config.broken_pipe_exit_code,
114
+ traceback_force_color=config.traceback_force_color,
115
+ )
116
+
117
+
118
+ def _restore_settings(snapshot: ConfigSnapshot) -> None:
119
+ """Bring :data:`config` back to the provided type-safe snapshot."""
120
+
121
+ config.traceback = snapshot["traceback"]
122
+ config.exit_code_style = snapshot["exit_code_style"]
123
+ config.broken_pipe_exit_code = snapshot["broken_pipe_exit_code"]
124
+ config.traceback_force_color = snapshot["traceback_force_color"]
125
+
126
+
127
+ def _reject_unknown_fields(overrides: Mapping[str, object]) -> None:
128
+ """Guard against typos in override names."""
129
+
130
+ known = set(_field_names())
131
+ unknown = set(overrides) - known
132
+ if unknown:
133
+ raise AttributeError(f"Unknown configuration fields: {sorted(unknown)}")
134
+
135
+
136
+ def reset_config() -> None:
137
+ """Restore the shared configuration to its default values."""
138
+
139
+ _restore_settings(_default_values())
140
+
141
+
142
+ @contextmanager
143
+ def config_overrides(**overrides: object) -> Iterator[_Config]:
144
+ """Snapshot configuration state and optionally apply temporary overrides."""
145
+
146
+ _reject_unknown_fields(overrides)
147
+ snapshot = _snapshot_current_settings()
148
+
149
+ for name, value in overrides.items():
150
+ setattr(config, name, value)
151
+
152
+ try:
153
+ yield config
154
+ finally:
155
+ _restore_settings(snapshot)