rich-transient 0.1.0__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,230 @@
1
+ """Reusable Rich-based braille spinner and transient live panel for CLI output.
2
+
3
+ This package can be used by any project that needs:
4
+ - A braille-style status spinner (accessible, compact)
5
+ - A transient live panel that streams output and clears on exit
6
+
7
+ Dependencies: rich
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ import time
14
+ from contextlib import contextmanager
15
+ from dataclasses import dataclass, fields, replace
16
+ from types import SimpleNamespace
17
+ from typing import Callable, Literal, TypeVar
18
+
19
+ from rich.console import Console
20
+ from rich.live import Live
21
+ from rich.panel import Panel
22
+ from rich.text import Text
23
+
24
+ T = TypeVar("T")
25
+
26
+ # Braille spinner frames (one per refresh) so the status line visibly animates.
27
+ SPINNER_BRAILLE: tuple[str, ...] = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
28
+
29
+ # Shared refresh rate for Live displays (transient panel and other live UIs).
30
+ LIVE_REFRESH_PER_SECOND: float = 8.0
31
+
32
+
33
+ def get_braille_frame() -> int:
34
+ """Return the current animation frame index for the braille spinner (0 to len(SPINNER_BRAILLE)-1).
35
+
36
+ Use with SPINNER_BRAILLE[get_braille_frame()] when building custom live UIs (e.g. tables, status lines).
37
+ """
38
+ return int(time.monotonic() * LIVE_REFRESH_PER_SECOND) % len(SPINNER_BRAILLE)
39
+
40
+
41
+ # Name under which we register the braille spinner in Rich's SPINNERS dict.
42
+ _BRAILLE_SPINNER_NAME = "braille"
43
+
44
+
45
+ def register_braille_spinner() -> None:
46
+ """Register the braille spinner with Rich so console.status(..., spinner='braille') works.
47
+
48
+ Idempotent; safe to call multiple times. Call once at startup or rely on braille_spinner_for_status().
49
+ """
50
+ try:
51
+ from rich import _spinners
52
+
53
+ _spinners.SPINNERS[_BRAILLE_SPINNER_NAME] = {
54
+ "frames": list(SPINNER_BRAILLE),
55
+ "interval": 1000.0 / LIVE_REFRESH_PER_SECOND,
56
+ }
57
+ except (ImportError, AttributeError):
58
+ pass
59
+
60
+
61
+ def braille_spinner_for_status() -> str:
62
+ """Return the Rich spinner name 'braille' for use with console.status(spinner=...).
63
+
64
+ Registers the braille frames with Rich on first use, then returns the name so Status
65
+ can look it up. Rich's Status expects a spinner name (str), not a Spinner instance.
66
+
67
+ Example:
68
+ with console.status("Loading...", spinner=braille_spinner_for_status()):
69
+ do_work()
70
+ """
71
+ register_braille_spinner()
72
+ return _BRAILLE_SPINNER_NAME
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class TransientPanelConfig:
77
+ """Configuration for transient live panels. Use presets via transient_live_panel(..., preset='streaming')."""
78
+
79
+ max_lines: int = 100
80
+ display_lines: int = 24
81
+ refresh_per_second: float = LIVE_REFRESH_PER_SECOND
82
+ default_status: str = "Running"
83
+ reserve_lines: int = 12 # Space for title, borders, padding, subtitle
84
+ border_style: str = "dim"
85
+ padding: tuple[int, int] = (0, 1)
86
+
87
+
88
+ # Presets for consistent behavior across commands.
89
+ TRANSIENT_PANEL_PRESETS: dict[str, TransientPanelConfig] = {
90
+ "default": TransientPanelConfig(),
91
+ "streaming": TransientPanelConfig(
92
+ max_lines=200,
93
+ display_lines=28,
94
+ default_status="Running",
95
+ ),
96
+ }
97
+
98
+
99
+ def _resolve_panel_config(
100
+ preset: Literal["default", "streaming"] | None = None,
101
+ config: TransientPanelConfig | None = None,
102
+ **overrides: object,
103
+ ) -> TransientPanelConfig:
104
+ """Resolve config from preset, optional explicit config, and overrides."""
105
+ base = config or TRANSIENT_PANEL_PRESETS.get(preset or "default") or TRANSIENT_PANEL_PRESETS["default"]
106
+ valid_names = {f.name for f in fields(TransientPanelConfig)}
107
+ clean = {k: v for k, v in overrides.items() if k in valid_names and v is not None}
108
+ if not clean:
109
+ return base
110
+ return replace(base, **clean)
111
+
112
+
113
+ @contextmanager
114
+ def transient_live_panel(
115
+ title: str,
116
+ preset: Literal["default", "streaming"] = "default",
117
+ config: TransientPanelConfig | None = None,
118
+ *,
119
+ max_lines: int | None = None,
120
+ display_lines: int | None = None,
121
+ border_style: str | None = None,
122
+ console: Console | None = None,
123
+ ):
124
+ """Context manager that shows streaming output in a live panel; panel is cleared on exit.
125
+
126
+ Use for verbose subprocess output: output streams into the panel while the task runs,
127
+ then the panel is removed so only the high-level success/failure line remains.
128
+
129
+ Presets:
130
+ - "default": Short steps. max_lines=100, display_lines=24.
131
+ - "streaming": Long tool output. max_lines=200, display_lines=28.
132
+
133
+ Optional kwargs override the preset (e.g. display_lines=30).
134
+ Pass console= to use a specific Rich Console (e.g. with custom theme).
135
+
136
+ Yields an object with:
137
+ - append(line: str) -> None
138
+ - set_status(text: str) -> None -- update the status line (e.g. "Downloading X...")
139
+ - run_task(task_callable: Callable[[], T]) -> T
140
+ """
141
+ target_console = console if console is not None else Console()
142
+ cfg = _resolve_panel_config(
143
+ preset=preset,
144
+ config=config,
145
+ max_lines=max_lines,
146
+ display_lines=display_lines,
147
+ border_style=border_style,
148
+ )
149
+ lines_list: list[str] = []
150
+ current_status: list[str] = [cfg.default_status]
151
+ lock = threading.Lock()
152
+ result_holder: list = []
153
+ try:
154
+ console_height = target_console.size.height
155
+ except Exception:
156
+ console_height = 30
157
+ tail = min(cfg.display_lines, max(1, console_height - cfg.reserve_lines))
158
+
159
+ def append(line: str) -> None:
160
+ with lock:
161
+ lines_list.append(line)
162
+
163
+ def set_status(text: str) -> None:
164
+ with lock:
165
+ current_status[0] = text
166
+
167
+ def render() -> Panel:
168
+ with lock:
169
+ recent = lines_list[-cfg.max_lines:] if len(lines_list) > cfg.max_lines else lines_list
170
+ visible = recent[-tail:] if len(recent) > tail else recent
171
+ content = "\n".join(visible)
172
+ status_text = current_status[0]
173
+ if content:
174
+ try:
175
+ streamed = Text.from_markup(content)
176
+ except Exception:
177
+ streamed = Text(content)
178
+ else:
179
+ streamed = Text("")
180
+ frame_idx = get_braille_frame()
181
+ status_line = Text(f" {SPINNER_BRAILLE[frame_idx]} {status_text}", style="dim")
182
+ return Panel(
183
+ streamed,
184
+ title=title,
185
+ subtitle=status_line,
186
+ border_style=cfg.border_style,
187
+ padding=cfg.padding,
188
+ expand=True,
189
+ )
190
+
191
+ def run_task(task_callable: Callable[[], T]) -> T:
192
+ result_holder.clear()
193
+ exc_holder: list[BaseException | None] = [None]
194
+
195
+ def run() -> None:
196
+ try:
197
+ result_holder.append(task_callable())
198
+ except BaseException as e:
199
+ exc_holder[0] = e
200
+
201
+ th = threading.Thread(target=run)
202
+ th.start()
203
+ with Live(
204
+ render(),
205
+ refresh_per_second=cfg.refresh_per_second,
206
+ transient=True,
207
+ console=target_console,
208
+ ) as live:
209
+ while th.is_alive():
210
+ live.update(render())
211
+ time.sleep(0.05)
212
+ live.update(render())
213
+ th.join()
214
+ if exc_holder[0] is not None:
215
+ raise exc_holder[0]
216
+ return result_holder[0]
217
+
218
+ yield SimpleNamespace(append=append, set_status=set_status, run_task=run_task)
219
+
220
+
221
+ __all__ = [
222
+ "SPINNER_BRAILLE",
223
+ "LIVE_REFRESH_PER_SECOND",
224
+ "TransientPanelConfig",
225
+ "TRANSIENT_PANEL_PRESETS",
226
+ "braille_spinner_for_status",
227
+ "get_braille_frame",
228
+ "register_braille_spinner",
229
+ "transient_live_panel",
230
+ ]
@@ -0,0 +1,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: rich-transient
3
+ Version: 0.1.0
4
+ Summary: Reusable Rich-based braille spinner and transient live panel for CLI output
5
+ Project-URL: Homepage, https://github.com/your-org/rich-transient
6
+ Project-URL: Repository, https://github.com/your-org/rich-transient
7
+ Project-URL: Documentation, https://github.com/your-org/rich-transient#readme
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: cli,live,panel,progress,rich,spinner,terminal
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: User Interfaces
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: rich>=13.7.1
22
+ Description-Content-Type: text/markdown
23
+
24
+ # rich_transient
25
+
26
+ Reusable [Rich](https://github.com/Textualize/rich)-based **braille spinner** and **transient live panel** for CLI output. Use it to show streaming subprocess or task output in a live-updating panel that disappears when the task finishes, with an animated status line.
27
+
28
+ **Requires:** Python 3.11+, `rich>=13.7.1`
29
+
30
+ ---
31
+
32
+ ## Installation
33
+
34
+ **From PyPI:**
35
+ ```bash
36
+ pip install rich-transient
37
+ ```
38
+
39
+ **Standalone (from this repo):**
40
+ ```bash
41
+ pip install -e /path/to/AutoIaC/rich_transient
42
+ ```
43
+
44
+ **As part of AutoIaC:**
45
+ ```bash
46
+ pip install -e /path/to/AutoIaC
47
+ # installs both auto_iac and rich_transient
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Quick start: transient live panel
53
+
54
+ Wrap a long-running task so its output streams into a live panel; when the task ends, the panel is cleared (transient) and only your final message remains.
55
+
56
+ ```python
57
+ from rich_transient import transient_live_panel
58
+
59
+ with transient_live_panel("Installing dependencies") as panel:
60
+ def do_work():
61
+ # Simulate streaming output
62
+ for i in range(20):
63
+ print(f"Step {i + 1}/20...")
64
+ return "done"
65
+
66
+ result = panel.run_task(do_work)
67
+
68
+ print(f"Result: {result}")
69
+ ```
70
+
71
+ The panel shows a scrolling tail of output and an animated braille spinner in the status line; when `run_task` returns, the panel is removed.
72
+
73
+ ---
74
+
75
+ ## Using the panel API
76
+
77
+ The context manager yields an object with three methods:
78
+
79
+ | Method | Description |
80
+ |--------|-------------|
81
+ | `panel.append(line: str)` | Add a line to the panel content (e.g. from a subprocess stdout). |
82
+ | `panel.set_status(text: str)` | Update the status line shown next to the spinner (e.g. "Downloading X..."). |
83
+ | `panel.run_task(callable)` | Run a callable in a background thread; the panel refreshes until it completes. Returns the callable's return value; re-raises any exception. |
84
+
85
+ **Streaming subprocess output into the panel:**
86
+
87
+ ```python
88
+ import subprocess
89
+ from rich_transient import transient_live_panel
90
+
91
+ with transient_live_panel("Running tests") as panel:
92
+ def run():
93
+ proc = subprocess.Popen(
94
+ ["pytest", "-v"],
95
+ stdout=subprocess.PIPE,
96
+ stderr=subprocess.STDOUT,
97
+ text=True,
98
+ )
99
+ for line in proc.stdout:
100
+ panel.append(line.rstrip())
101
+ proc.wait()
102
+ return proc.returncode
103
+
104
+ exit_code = panel.run_task(run)
105
+ print(f"Tests exited with code {exit_code}")
106
+ ```
107
+
108
+ **Updating the status line:**
109
+
110
+ ```python
111
+ with transient_live_panel("Building") as panel:
112
+ def build():
113
+ panel.set_status("Compiling...")
114
+ # do compile
115
+ panel.set_status("Linking...")
116
+ # do link
117
+ return 0
118
+
119
+ panel.run_task(build)
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Braille spinner
125
+
126
+ Use the spinner frames for your own live UI (e.g. a custom table or status line).
127
+
128
+ **With Rich `console.status()`** — use the built-in braille spinner so the whole app shares one style:
129
+
130
+ ```python
131
+ from rich.console import Console
132
+ from rich_transient import braille_spinner_for_status
133
+
134
+ console = Console()
135
+
136
+ with console.status("Loading state...", spinner=braille_spinner_for_status()) as status:
137
+ do_work()
138
+ status.update("Almost done...")
139
+ ```
140
+
141
+ **Custom live UI** — use `get_braille_frame()` so you don't duplicate the frame math:
142
+
143
+ ```python
144
+ import time
145
+ from rich.console import Console
146
+ from rich.live import Live
147
+ from rich_transient import SPINNER_BRAILLE, LIVE_REFRESH_PER_SECOND, get_braille_frame
148
+
149
+ console = Console()
150
+
151
+ def make_status():
152
+ frame = get_braille_frame()
153
+ return f"{SPINNER_BRAILLE[frame]} Working..."
154
+
155
+ with Live(make_status(), refresh_per_second=LIVE_REFRESH_PER_SECOND, console=console) as live:
156
+ for _ in range(20):
157
+ time.sleep(0.1)
158
+ live.update(make_status())
159
+ ```
160
+
161
+ **Exports:**
162
+
163
+ - `SPINNER_BRAILLE` — tuple of 10 braille characters for animation frames.
164
+ - `LIVE_REFRESH_PER_SECOND` — default refresh rate (8.0) for live displays.
165
+ - `get_braille_frame()` — current animation frame index (use with `SPINNER_BRAILLE[i]`).
166
+ - `braille_spinner_for_status()` — Registers the braille spinner with Rich and returns the name `"braille"` for `console.status(spinner=...)`.
167
+ - `register_braille_spinner()` — Idempotent registration of the braille spinner in Rich's SPINNERS dict (called automatically by `braille_spinner_for_status()`).
168
+
169
+ ---
170
+
171
+ ## Presets and options
172
+
173
+ **Presets** control buffer size and visible lines:
174
+
175
+ - `preset="default"` — max_lines=100, display_lines=24 (short steps).
176
+ - `preset="streaming"` — max_lines=200, display_lines=28 (long streaming output).
177
+
178
+ Override per call:
179
+
180
+ ```python
181
+ with transient_live_panel(
182
+ "Long log",
183
+ preset="streaming",
184
+ max_lines=500,
185
+ display_lines=40,
186
+ ) as panel:
187
+ panel.run_task(my_task)
188
+ ```
189
+
190
+ **Custom Rich theme:** pass a `Console` so the panel uses your theme:
191
+
192
+ ```python
193
+ from rich.console import Console
194
+ from rich.theme import Theme
195
+ from rich_transient import transient_live_panel
196
+
197
+ my_theme = Theme({"info": "cyan", "success": "green"})
198
+ console = Console(theme=my_theme)
199
+
200
+ with transient_live_panel("Task", console=console) as panel:
201
+ panel.run_task(my_task)
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Configuration
207
+
208
+ For full control, use `TransientPanelConfig` and `TRANSIENT_PANEL_PRESETS`:
209
+
210
+ ```python
211
+ from rich_transient import (
212
+ TransientPanelConfig,
213
+ TRANSIENT_PANEL_PRESETS,
214
+ transient_live_panel,
215
+ )
216
+
217
+ # Use a built-in preset
218
+ cfg = TRANSIENT_PANEL_PRESETS["streaming"]
219
+
220
+ # Or build your own
221
+ custom = TransientPanelConfig(
222
+ max_lines=300,
223
+ display_lines=30,
224
+ default_status="Processing...",
225
+ border_style="blue",
226
+ )
227
+
228
+ with transient_live_panel("Custom", config=custom) as panel:
229
+ panel.run_task(my_task)
230
+ ```
231
+
232
+ ---
233
+
234
+ ## API summary
235
+
236
+ | Export | Type | Description |
237
+ |--------|------|-------------|
238
+ | `SPINNER_BRAILLE` | `tuple[str, ...]` | Braille spinner frames. |
239
+ | `LIVE_REFRESH_PER_SECOND` | `float` | Default refresh rate for live displays. |
240
+ | `get_braille_frame()` | `() -> int` | Current animation frame index for use with `SPINNER_BRAILLE`. |
241
+ | `braille_spinner_for_status()` | `() -> str` | Registers braille spinner with Rich and returns `"braille"` for `console.status(spinner=...)`. |
242
+ | `register_braille_spinner()` | `() -> None` | Idempotent registration of braille spinner in Rich's SPINNERS. |
243
+ | `TransientPanelConfig` | dataclass | Panel configuration (max_lines, display_lines, border_style, etc.). |
244
+ | `TRANSIENT_PANEL_PRESETS` | `dict[str, TransientPanelConfig]` | `"default"` and `"streaming"` presets. |
245
+ | `transient_live_panel(...)` | context manager | Yields an object with `append`, `set_status`, `run_task`. |
@@ -0,0 +1,5 @@
1
+ rich_transient/__init__.py,sha256=LZo3iGwMpg1FAB7jushlI9QVcMa3tNC5CM_ovtD8fyY,7619
2
+ rich_transient-0.1.0.dist-info/METADATA,sha256=vg78FBOkmw_lu3v1mmXXjVDkwfYP_IElKMQSqgVU4WQ,7538
3
+ rich_transient-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
4
+ rich_transient-0.1.0.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
5
+ rich_transient-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.