roadshow 0.1.0__tar.gz

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,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) `2026` `Tucker Beck`
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.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: roadshow
3
+ Version: 0.1.0
4
+ Summary: A reusable framework for interactive Python demos
5
+ Author: Tucker Beck
6
+ Author-email: Tucker Beck <tucker.beck@gmail.com>
7
+ License-File: LICENSE.md
8
+ Requires-Dist: py-buzz>=9.0.1
9
+ Requires-Dist: rich>=14.0
10
+ Requires-Dist: typer>=0.24.1
11
+ Requires-Python: >=3.11
12
+ Project-URL: homepage, https://github.com/dusktreader/roadshow
13
+ Project-URL: source, https://github.com/dusktreader/roadshow
14
+ Project-URL: changelog, https://github.com/dusktreader/roadshow/blob/main/CHANGELOG.md
15
+ Description-Content-Type: text/markdown
16
+
17
+ [![Latest Version](https://img.shields.io/pypi/v/roadshow?label=pypi-version&logo=python&style=plastic)](https://pypi.org/project/roadshow/)
18
+ [![Python Versions](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fdusktreader%2Froadshow%2Fmain%2Fpyproject.toml&style=plastic&logo=python&label=python-versions)](https://www.python.org/)
19
+ [![Build Status](https://github.com/dusktreader/roadshow/actions/workflows/main.yml/badge.svg)](https://github.com/dusktreader/roadshow/actions/workflows/main.yml)
20
+ [![Documentation Status](https://github.com/dusktreader/roadshow/actions/workflows/docs.yml/badge.svg)](https://dusktreader.github.io/roadshow/)
21
+
22
+
23
+ ![roadshow-logo](https://github.com/dusktreader/roadshow/blob/main/docs/source/static/roadshow-text.png)
24
+
25
+ Roadshow makes it easy to demonstrate your project's capabilities in a cli with rich output.
26
+
27
+ [Documentation](http://dusktreader.github.io/roadshow/)
28
+
29
+
30
+ ## Super-quick Start
31
+
32
+ Requires: Python 3.11 to 3.15
33
+
34
+ Install through pip:
35
+
36
+ ```shell
37
+ pip install roadshow
38
+ ```
39
+
40
+
41
+ ## Documentation
42
+
43
+ The complete documentation can be found at the
44
+ [roadshow home page](https://dusktreader.github.io/roadshow)
45
+
46
+
47
+ ## Demo
48
+
49
+ To see the features in action, you can run the demo in an isolated environment with `uv`:
50
+
51
+ ```text
52
+ uvx roadshow-demo
53
+ ```
@@ -0,0 +1,37 @@
1
+ [![Latest Version](https://img.shields.io/pypi/v/roadshow?label=pypi-version&logo=python&style=plastic)](https://pypi.org/project/roadshow/)
2
+ [![Python Versions](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fdusktreader%2Froadshow%2Fmain%2Fpyproject.toml&style=plastic&logo=python&label=python-versions)](https://www.python.org/)
3
+ [![Build Status](https://github.com/dusktreader/roadshow/actions/workflows/main.yml/badge.svg)](https://github.com/dusktreader/roadshow/actions/workflows/main.yml)
4
+ [![Documentation Status](https://github.com/dusktreader/roadshow/actions/workflows/docs.yml/badge.svg)](https://dusktreader.github.io/roadshow/)
5
+
6
+
7
+ ![roadshow-logo](https://github.com/dusktreader/roadshow/blob/main/docs/source/static/roadshow-text.png)
8
+
9
+ Roadshow makes it easy to demonstrate your project's capabilities in a cli with rich output.
10
+
11
+ [Documentation](http://dusktreader.github.io/roadshow/)
12
+
13
+
14
+ ## Super-quick Start
15
+
16
+ Requires: Python 3.11 to 3.15
17
+
18
+ Install through pip:
19
+
20
+ ```shell
21
+ pip install roadshow
22
+ ```
23
+
24
+
25
+ ## Documentation
26
+
27
+ The complete documentation can be found at the
28
+ [roadshow home page](https://dusktreader.github.io/roadshow)
29
+
30
+
31
+ ## Demo
32
+
33
+ To see the features in action, you can run the demo in an isolated environment with `uv`:
34
+
35
+ ```text
36
+ uvx roadshow-demo
37
+ ```
@@ -0,0 +1,87 @@
1
+ [project]
2
+ name = "roadshow"
3
+ version = "0.1.0"
4
+ description = "A reusable framework for interactive Python demos"
5
+ readme = "README.md"
6
+ license-files = ["LICENSE.md"]
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "py-buzz>=9.0.1",
10
+ "rich>=14.0",
11
+ "typer>=0.24.1",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "Tucker Beck"
16
+ email = "tucker.beck@gmail.com"
17
+
18
+ [project.urls]
19
+ homepage = "https://github.com/dusktreader/roadshow"
20
+ source = "https://github.com/dusktreader/roadshow"
21
+ changelog = "https://github.com/dusktreader/roadshow/blob/main/CHANGELOG.md"
22
+
23
+ [project.scripts]
24
+ roadshow-demo = "roadshow_demo.main:main"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "debugpy>=1.8",
29
+ "ipython>=8.18",
30
+ "mkdocstrings[python]>=0.29",
31
+ "pyclean>=3.1",
32
+ "pygments>=2.19",
33
+ "pytest>=8.3",
34
+ "pytest-bdd>=7.0",
35
+ "pytest-cov>=6.0",
36
+ "pytest-mock>=3.14",
37
+ "pytest-pretty>=1.2",
38
+ "pytest-random-order>=1.1",
39
+ "ruff>=0.11",
40
+ "ty>=0.0",
41
+ "typos>=1.31",
42
+ "zensical>=0.1.0",
43
+ ]
44
+
45
+ [tool.uv]
46
+ package = true
47
+
48
+ [tool.pytest.ini_options]
49
+ addopts = [
50
+ "--random-order",
51
+ "--cov=src/roadshow",
52
+ "--cov-report=term-missing",
53
+ "--cov-fail-under=85",
54
+ "--cov-report=xml:.coverage.xml",
55
+ "--junitxml=.junit.xml",
56
+ "--override-ini=junit_family=legacy",
57
+ ]
58
+ testpaths = ["tests"]
59
+ python_files = [
60
+ "test_*.py",
61
+ "*_steps.py",
62
+ ]
63
+ markers = [
64
+ "integration: Integration tests that exercise the real package with no mocking",
65
+ "unit: Fast unit tests with all external dependencies mocked",
66
+ ]
67
+
68
+ [tool.ruff]
69
+ line-length = 120
70
+ src = [
71
+ "src/roadshow",
72
+ "tests",
73
+ "src/roadshow_demo",
74
+ ]
75
+
76
+ [tool.ruff.lint.per-file-ignores]
77
+ "src/roadshow/core.py" = ["BLE001"]
78
+
79
+ [tool.ruff.format]
80
+ docstring-code-format = true
81
+
82
+ [tool.typos.default]
83
+ extend-ignore-identifiers-re = []
84
+
85
+ [build-system]
86
+ requires = ["uv-build>=0.1.0"]
87
+ build-backend = "uv_build"
@@ -0,0 +1,89 @@
1
+ [project]
2
+ name = "roadshow"
3
+ version = "0.1.0"
4
+ description = "A reusable framework for interactive Python demos"
5
+ authors = [
6
+ {name = "Tucker Beck", email ="tucker.beck@gmail.com"},
7
+ ]
8
+ readme = "README.md"
9
+ license-files = ["LICENSE.md"]
10
+
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "py-buzz>=9.0.1",
14
+ "rich>=14.0",
15
+ "typer>=0.24.1",
16
+ ]
17
+
18
+
19
+ [project.urls]
20
+ homepage = "https://github.com/dusktreader/roadshow"
21
+ source = "https://github.com/dusktreader/roadshow"
22
+ changelog = "https://github.com/dusktreader/roadshow/blob/main/CHANGELOG.md"
23
+
24
+
25
+ [project.scripts]
26
+ roadshow-demo = "roadshow_demo.main:main"
27
+
28
+
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "debugpy>=1.8",
33
+ "ipython>=8.18",
34
+ "mkdocstrings[python]>=0.29",
35
+ "pyclean>=3.1",
36
+ "pygments>=2.19",
37
+ "pytest>=8.3",
38
+ "pytest-bdd>=7.0",
39
+ "pytest-cov>=6.0",
40
+ "pytest-mock>=3.14",
41
+ "pytest-pretty>=1.2",
42
+ "pytest-random-order>=1.1",
43
+ "ruff>=0.11",
44
+ "ty>=0.0",
45
+ "typos>=1.31",
46
+ "zensical>=0.1.0",
47
+ ]
48
+
49
+
50
+ [tool.uv]
51
+ package = true
52
+
53
+
54
+ [tool.pytest.ini_options]
55
+ addopts = [
56
+ "--random-order",
57
+ "--cov=src/roadshow",
58
+ "--cov-report=term-missing",
59
+ "--cov-fail-under=85",
60
+ "--cov-report=xml:.coverage.xml",
61
+ "--junitxml=.junit.xml",
62
+ "--override-ini=junit_family=legacy",
63
+ ]
64
+ testpaths = ["tests"]
65
+ python_files = ["test_*.py", "*_steps.py"]
66
+ markers = [
67
+ "integration: Integration tests that exercise the real package with no mocking",
68
+ "unit: Fast unit tests with all external dependencies mocked",
69
+ ]
70
+
71
+
72
+ [tool.ruff]
73
+ line-length = 120
74
+ src = ["src/roadshow", "tests", "src/roadshow_demo"]
75
+
76
+ [tool.ruff.lint.per-file-ignores]
77
+ "src/roadshow/core.py" = ["BLE001"]
78
+
79
+ [tool.ruff.format]
80
+ docstring-code-format = true
81
+
82
+
83
+ [tool.typos.default]
84
+ extend-ignore-identifiers-re = []
85
+
86
+
87
+ [build-system]
88
+ requires = ["uv-build>=0.1.0"]
89
+ build-backend = "uv_build"
@@ -0,0 +1,19 @@
1
+ from roadshow.app import Roadshow
2
+ from roadshow.core import DemoDetails, DemoFunction, DemoResult, capture_demo, decompose_demo, demo, discover_demos
3
+ from roadshow.errors import RoadshowError
4
+ from roadshow.version import get_version
5
+
6
+ __version__ = get_version()
7
+
8
+ __all__ = [
9
+ "DemoDetails",
10
+ "DemoFunction",
11
+ "DemoResult",
12
+ "Roadshow",
13
+ "RoadshowError",
14
+ "__version__",
15
+ "capture_demo",
16
+ "decompose_demo",
17
+ "demo",
18
+ "discover_demos",
19
+ ]
@@ -0,0 +1,144 @@
1
+ """Build and run tagged demo applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pkgutil
6
+ from collections import defaultdict
7
+ from importlib import import_module
8
+ from types import ModuleType
9
+ from typing import Annotated
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.markdown import Markdown
14
+ from rich.panel import Panel
15
+ from rich.prompt import Confirm
16
+
17
+ from roadshow.core import _DEMO_FEATURE_ATTRIBUTE, DemoFunction, discover_demos
18
+ from roadshow.errors import RoadshowError
19
+ from roadshow.render import display_demo
20
+
21
+
22
+ class Roadshow:
23
+ """Discover tagged demos in a package and expose them through a Typer CLI."""
24
+
25
+ def __init__(self, package: str | ModuleType, *, title: str | None = None):
26
+ """Create a Roadshow app for one package or module."""
27
+ self.package = package
28
+ self.package_name = package if isinstance(package, str) else package.__name__
29
+ self.title = title or self.package_name
30
+
31
+ def discover(self) -> dict[str, list[DemoFunction]]:
32
+ """Import the package's modules and group their demos by feature tag."""
33
+ if isinstance(self.package, str):
34
+ with RoadshowError.handle_errors(f"Could not import demo package {self.package!r}"):
35
+ root_module = import_module(self.package)
36
+ else:
37
+ root_module = self.package
38
+
39
+ modules = [root_module]
40
+ package_path = getattr(root_module, "__path__", None)
41
+ if package_path is not None:
42
+ with RoadshowError.handle_errors(f"Could not discover demo modules in {root_module.__name__!r}"):
43
+ module_infos = list(pkgutil.walk_packages(package_path, f"{root_module.__name__}."))
44
+ for module_info in module_infos:
45
+ with RoadshowError.handle_errors(f"Could not import demo module {module_info.name!r}"):
46
+ modules.append(import_module(module_info.name))
47
+
48
+ feature_map: defaultdict[str, list[DemoFunction]] = defaultdict(list)
49
+ for module in modules:
50
+ for function in discover_demos(module):
51
+ feature = function.__dict__[_DEMO_FEATURE_ATTRIBUTE]
52
+ feature_map[feature].append(function)
53
+
54
+ return {
55
+ feature: sorted(demos, key=lambda function: (function.__module__, function.__name__))
56
+ for feature, demos in sorted(feature_map.items())
57
+ }
58
+
59
+ def run(self) -> None:
60
+ """Start the Typer CLI for this Roadshow app."""
61
+ typer.run(self.start)
62
+
63
+ def start(
64
+ self,
65
+ feature: Annotated[
66
+ list[str] | None,
67
+ typer.Option(
68
+ "--feature",
69
+ "-f",
70
+ help="Features to run. Repeat this option to select multiple features; omit it to run all features.",
71
+ ),
72
+ ] = None,
73
+ non_interactive: Annotated[
74
+ bool,
75
+ typer.Option("--non-interactive", help="Run all selected demos without prompts."),
76
+ ] = False,
77
+ ) -> None:
78
+ """Run the selected demo features and report captured failures."""
79
+ all_features = self.discover()
80
+ selected_features = list(dict.fromkeys(feature or all_features))
81
+ unknown_features = sorted(set(selected_features) - all_features.keys())
82
+ if unknown_features:
83
+ available = ", ".join(all_features) or "none"
84
+ raise typer.BadParameter(f"Unknown feature tag(s): {', '.join(unknown_features)}. Available: {available}")
85
+
86
+ greeting_lines = [
87
+ f"This program shows the demos available in `{self.title}`.",
88
+ "",
89
+ "Selected features:",
90
+ ]
91
+ for selected_feature in selected_features:
92
+ greeting_lines.append(f"- `{selected_feature}`")
93
+ greeting_lines.extend(f" - `{function.__name__}()`" for function in all_features[selected_feature])
94
+ if not selected_features:
95
+ greeting_lines.append("- No tagged demos found.")
96
+
97
+ console = Console()
98
+ console.print(
99
+ Panel(
100
+ Markdown("\n".join(greeting_lines)),
101
+ title=f"[green]Welcome to {self.title}[/green]",
102
+ title_align="left",
103
+ padding=1,
104
+ border_style="green",
105
+ )
106
+ )
107
+ console.print()
108
+ console.print()
109
+ if not non_interactive and not Confirm.ask("Would you like to continue?", default=True):
110
+ return
111
+
112
+ demos_to_run = [
113
+ (selected_feature, function)
114
+ for selected_feature in selected_features
115
+ for function in all_features[selected_feature]
116
+ ]
117
+ total_demos = len(demos_to_run)
118
+ failed = False
119
+ for demo_number, (selected_feature, function) in enumerate(demos_to_run, start=1):
120
+ console.clear()
121
+ result = display_demo(
122
+ function,
123
+ console,
124
+ label=selected_feature,
125
+ progress=(demo_number, total_demos),
126
+ )
127
+ failed |= not result.succeeded
128
+ if not non_interactive and not Confirm.ask("Would you like to continue?", default=True):
129
+ break
130
+
131
+ console.clear()
132
+ console.print(
133
+ Panel(
134
+ f"Thanks for checking out {self.title}.",
135
+ title="[green]Done[/green]",
136
+ title_align="left",
137
+ padding=1,
138
+ border_style="green",
139
+ )
140
+ )
141
+ console.print()
142
+ console.print()
143
+ if failed and non_interactive:
144
+ raise typer.Exit(1)
@@ -0,0 +1,208 @@
1
+ """Discover, inspect, and capture Python demo functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import inspect
7
+ import io
8
+ import os
9
+ import sys
10
+ import tempfile
11
+ import textwrap
12
+ from collections.abc import Callable, Sequence
13
+ from contextlib import ExitStack, redirect_stderr, redirect_stdout
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from types import FunctionType, ModuleType
17
+ from unittest.mock import patch
18
+
19
+ from roadshow.errors import RoadshowError
20
+
21
+ DemoFunction = FunctionType
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class DemoDetails:
26
+ """Store the displayable parts of a demo function."""
27
+
28
+ module: str
29
+ name: str
30
+ docstring: str
31
+ source: str
32
+
33
+
34
+ @dataclass
35
+ class DemoResult:
36
+ """Store output and failures captured while running a demo."""
37
+
38
+ error: Exception | None = None
39
+ stdout: str | None = None
40
+ stderr: str | None = None
41
+ exit_code: int | str | None = None
42
+
43
+ @property
44
+ def succeeded(self) -> bool:
45
+ """Return whether the demo completed without an error or nonzero exit."""
46
+ return self.error is None and self.exit_code in (None, 0)
47
+
48
+
49
+ _DEMO_FEATURE_ATTRIBUTE = "__roadshow_feature__"
50
+ _DEMO_BEFORE_ATTRIBUTE = "__roadshow_before__"
51
+ _DEMO_AFTER_ATTRIBUTE = "__roadshow_after__"
52
+ _DEMO_INPUT_TEXT_ATTRIBUTE = "__roadshow_input_text__"
53
+ _DEMO_ARGV_ATTRIBUTE = "__roadshow_argv__"
54
+
55
+
56
+ def demo(
57
+ feature: str,
58
+ *,
59
+ before: Callable[[], object] | None = None,
60
+ after: Callable[[], object] | None = None,
61
+ input_text: str = "",
62
+ argv: Sequence[str] = (),
63
+ ) -> Callable[[DemoFunction], DemoFunction]:
64
+ """Mark a function as a demo and attach its feature, hooks, and input fixtures."""
65
+ RoadshowError.require_condition(
66
+ isinstance(feature, str) and feature and feature == feature.strip(),
67
+ "A demo feature must be a non-empty string without surrounding whitespace",
68
+ )
69
+ RoadshowError.require_condition(before is None or callable(before), "A demo before hook must be callable")
70
+ RoadshowError.require_condition(after is None or callable(after), "A demo after hook must be callable")
71
+ RoadshowError.require_condition(isinstance(input_text, str), "A demo's input_text must be a string")
72
+ RoadshowError.require_condition(
73
+ isinstance(argv, Sequence) and not isinstance(argv, (str, bytes)) and all(isinstance(arg, str) for arg in argv),
74
+ "A demo's argv must be a sequence of strings",
75
+ )
76
+ demo_argv = tuple(argv)
77
+
78
+ def decorate(function: DemoFunction) -> DemoFunction:
79
+ function.__dict__[_DEMO_FEATURE_ATTRIBUTE] = feature
80
+ function.__dict__[_DEMO_BEFORE_ATTRIBUTE] = before
81
+ function.__dict__[_DEMO_AFTER_ATTRIBUTE] = after
82
+ function.__dict__[_DEMO_INPUT_TEXT_ATTRIBUTE] = input_text
83
+ function.__dict__[_DEMO_ARGV_ATTRIBUTE] = demo_argv
84
+ return function
85
+
86
+ return decorate
87
+
88
+
89
+ def discover_demos(module: ModuleType) -> list[DemoFunction]:
90
+ """Return featured demo functions defined in a module, sorted by name."""
91
+ demos = [
92
+ member
93
+ for _, member in inspect.getmembers(module, inspect.isfunction)
94
+ if member.__module__ == module.__name__ and getattr(member, _DEMO_FEATURE_ATTRIBUTE, None)
95
+ ]
96
+ return sorted(demos, key=lambda function: function.__name__)
97
+
98
+
99
+ def decompose_demo(function: DemoFunction) -> DemoDetails:
100
+ """Extract a demo function's docstring and executable body."""
101
+ docstring = RoadshowError.enforce_defined(
102
+ inspect.getdoc(function),
103
+ f"Demo function {function.__name__!r} has no docstring",
104
+ )
105
+
106
+ source_lines, _ = inspect.getsourcelines(function)
107
+ source_text = textwrap.dedent("".join(source_lines))
108
+ syntax_tree = ast.parse(source_text)
109
+ function_node = RoadshowError.enforce_defined(
110
+ next(
111
+ (
112
+ node
113
+ for node in syntax_tree.body
114
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function.__name__
115
+ ),
116
+ None,
117
+ ),
118
+ f"Could not locate the source for demo function {function.__name__!r}",
119
+ )
120
+
121
+ body = function_node.body
122
+ if (
123
+ body
124
+ and isinstance(body[0], ast.Expr)
125
+ and isinstance(body[0].value, ast.Constant)
126
+ and isinstance(body[0].value.value, str)
127
+ ):
128
+ body = body[1:]
129
+
130
+ source = "\n".join(ast.unparse(statement) for statement in body)
131
+ if source:
132
+ source += "\n"
133
+
134
+ return DemoDetails(
135
+ module=function.__module__.rsplit(".", maxsplit=1)[-1],
136
+ name=function.__name__,
137
+ docstring=docstring,
138
+ source=source,
139
+ )
140
+
141
+
142
+ def capture_demo(
143
+ function: DemoFunction,
144
+ *,
145
+ input_text: str | None = None,
146
+ argv: Sequence[str] | None = None,
147
+ isolate_home: bool = True,
148
+ ) -> DemoResult:
149
+ """Run a demo while capturing standard streams, input, and process exit."""
150
+ configured_input_text: str = function.__dict__.get(_DEMO_INPUT_TEXT_ATTRIBUTE, "")
151
+ configured_argv: Sequence[str] = function.__dict__.get(_DEMO_ARGV_ATTRIBUTE, ())
152
+ effective_input_text = configured_input_text if input_text is None else input_text
153
+ effective_argv = configured_argv if argv is None else argv
154
+ result = DemoResult()
155
+ stdout = io.StringIO()
156
+ stderr = io.StringIO()
157
+ stdin = io.StringIO(effective_input_text)
158
+
159
+ before = function.__dict__.get(_DEMO_BEFORE_ATTRIBUTE)
160
+ after = function.__dict__.get(_DEMO_AFTER_ATTRIBUTE)
161
+ with ExitStack() as stack:
162
+ stack.enter_context(redirect_stdout(stdout))
163
+ stack.enter_context(redirect_stderr(stderr))
164
+ stack.enter_context(patch.object(sys, "stdin", stdin))
165
+ stack.enter_context(patch.object(sys, "argv", [function.__name__, *effective_argv]))
166
+
167
+ try:
168
+ try:
169
+ if before is not None:
170
+ before()
171
+ with ExitStack() as environment_stack:
172
+ if isolate_home:
173
+ temporary_home = environment_stack.enter_context(
174
+ tempfile.TemporaryDirectory(prefix="roadshow-")
175
+ )
176
+ home = Path(temporary_home)
177
+ cache_home = home / ".cache"
178
+ config_home = home / ".config"
179
+ state_home = home / ".local" / "state"
180
+ for path in (cache_home, config_home, state_home):
181
+ path.mkdir(parents=True, exist_ok=True)
182
+ environment_stack.enter_context(
183
+ patch.dict(
184
+ os.environ,
185
+ {
186
+ "HOME": temporary_home,
187
+ "USERPROFILE": temporary_home,
188
+ "XDG_CACHE_HOME": str(cache_home),
189
+ "XDG_CONFIG_HOME": str(config_home),
190
+ "XDG_STATE_HOME": str(state_home),
191
+ "COLUMNS": "80",
192
+ },
193
+ )
194
+ )
195
+
196
+ function()
197
+ finally:
198
+ if after is not None:
199
+ after()
200
+ except SystemExit as exited:
201
+ code = exited.code
202
+ result.exit_code = 0 if code is None else code if isinstance(code, (int, str)) else str(code)
203
+ except Exception as error:
204
+ result.error = error
205
+
206
+ result.stdout = stdout.getvalue() or None
207
+ result.stderr = stderr.getvalue() or None
208
+ return result
@@ -0,0 +1,7 @@
1
+ """Define Roadshow's base exception type."""
2
+
3
+ from buzz import Buzz
4
+
5
+
6
+ class RoadshowError(Buzz):
7
+ """Base class for errors raised by Roadshow."""
@@ -0,0 +1,10 @@
1
+ """Provide the Roadshow version command."""
2
+
3
+ from roadshow.version import get_version
4
+
5
+ __version__ = get_version()
6
+
7
+
8
+ def main() -> None:
9
+ """Print the installed Roadshow version."""
10
+ print(f"roadshow {__version__}")
File without changes
@@ -0,0 +1,126 @@
1
+ """Render demo descriptions and captured output with Rich."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from rich.console import Console, Group
8
+ from rich.markdown import Markdown
9
+ from rich.panel import Panel
10
+ from rich.rule import Rule
11
+ from rich.syntax import Syntax
12
+ from rich.text import Text
13
+
14
+ from roadshow.core import DemoFunction, DemoResult, capture_demo, decompose_demo
15
+
16
+
17
+ def display_demo(
18
+ function: DemoFunction,
19
+ console: Console,
20
+ *,
21
+ label: str | None = None,
22
+ input_text: str | None = None,
23
+ argv: Sequence[str] | None = None,
24
+ isolate_home: bool = True,
25
+ progress: tuple[int, int] | None = None,
26
+ ) -> DemoResult:
27
+ """Display a demo and its captured result, optionally showing progress in the outer panel."""
28
+ details = decompose_demo(function)
29
+ result = capture_demo(function, input_text=input_text, argv=argv, isolate_home=isolate_home)
30
+ parts = [
31
+ Markdown(details.docstring),
32
+ Rule(characters=" "),
33
+ Rule(characters=" "),
34
+ Panel(
35
+ Syntax(details.source or "pass\n", "python", line_numbers=True),
36
+ title=f"Source for [yellow]{details.name}()[/yellow]",
37
+ title_align="left",
38
+ expand=False,
39
+ padding=1,
40
+ border_style="yellow",
41
+ ),
42
+ ]
43
+
44
+ if result.stdout:
45
+ parts.extend(
46
+ [
47
+ Rule(characters=" "),
48
+ Rule(characters=" "),
49
+ Panel(
50
+ Text(result.stdout.rstrip()),
51
+ title=f"Captured stdout from [yellow]{details.name}()[/yellow]",
52
+ title_align="left",
53
+ expand=False,
54
+ padding=1,
55
+ border_style="green",
56
+ ),
57
+ ]
58
+ )
59
+ if result.stderr:
60
+ parts.extend(
61
+ [
62
+ Rule(characters=" "),
63
+ Rule(characters=" "),
64
+ Panel(
65
+ Text(result.stderr.rstrip()),
66
+ title=f"Captured stderr from [yellow]{details.name}()[/yellow]",
67
+ title_align="left",
68
+ expand=False,
69
+ padding=1,
70
+ border_style="yellow",
71
+ ),
72
+ ]
73
+ )
74
+ if result.error is not None:
75
+ exception = Text()
76
+ exception.append(result.error.__class__.__name__, style="red")
77
+ exception.append(": ")
78
+ exception.append(str(result.error), style="yellow")
79
+ parts.extend(
80
+ [
81
+ Rule(characters=" "),
82
+ Rule(characters=" "),
83
+ Panel(
84
+ exception,
85
+ title=f"[red]Uncaught exception[/red] from [yellow]{details.name}()[/yellow]",
86
+ title_align="left",
87
+ expand=False,
88
+ padding=1,
89
+ border_style="red",
90
+ ),
91
+ ]
92
+ )
93
+ if result.exit_code is not None:
94
+ exit_style = "green" if result.exit_code in (0, "0") else "red"
95
+ parts.extend(
96
+ [
97
+ Rule(characters=" "),
98
+ Rule(characters=" "),
99
+ Panel(
100
+ Text(str(result.exit_code), style=exit_style),
101
+ title=f"Exit code from [yellow]{details.name}()[/yellow]",
102
+ title_align="left",
103
+ expand=False,
104
+ padding=1,
105
+ border_style=exit_style,
106
+ ),
107
+ ]
108
+ )
109
+
110
+ display_title = (
111
+ f"[green]{label}[/green]: [yellow]{details.name}()[/yellow]" if label else f"[yellow]{details.name}()[/yellow]"
112
+ )
113
+ console.print(
114
+ Panel(
115
+ Group(*parts),
116
+ title=display_title,
117
+ title_align="left",
118
+ subtitle=f"Demo {progress[0]} of {progress[1]}" if progress is not None else None,
119
+ subtitle_align="left",
120
+ padding=1,
121
+ border_style="blue",
122
+ )
123
+ )
124
+ console.print(Rule(characters=" "))
125
+ console.print(Rule(characters=" "))
126
+ return result
@@ -0,0 +1,24 @@
1
+ import tomllib
2
+ from importlib import metadata
3
+
4
+
5
+ def get_version_from_metadata() -> str:
6
+ return metadata.version(__package__ or __name__)
7
+
8
+
9
+ def get_version_from_pyproject() -> str:
10
+ with open("pyproject.toml", "rb") as file:
11
+ return tomllib.load(file)["project"]["version"]
12
+
13
+
14
+ def get_version() -> str:
15
+ try:
16
+ return get_version_from_metadata()
17
+ except metadata.PackageNotFoundError:
18
+ try:
19
+ return get_version_from_pyproject()
20
+ except (FileNotFoundError, KeyError):
21
+ return "unknown"
22
+
23
+
24
+ __version__ = get_version()