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.
- roadshow-0.1.0/LICENSE.md +21 -0
- roadshow-0.1.0/PKG-INFO +53 -0
- roadshow-0.1.0/README.md +37 -0
- roadshow-0.1.0/pyproject.toml +87 -0
- roadshow-0.1.0/pyproject.toml.orig +89 -0
- roadshow-0.1.0/src/roadshow/__init__.py +19 -0
- roadshow-0.1.0/src/roadshow/app.py +144 -0
- roadshow-0.1.0/src/roadshow/core.py +208 -0
- roadshow-0.1.0/src/roadshow/errors.py +7 -0
- roadshow-0.1.0/src/roadshow/main.py +10 -0
- roadshow-0.1.0/src/roadshow/py.typed +0 -0
- roadshow-0.1.0/src/roadshow/render.py +126 -0
- roadshow-0.1.0/src/roadshow/version.py +24 -0
|
@@ -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.
|
roadshow-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/roadshow/)
|
|
18
|
+
[](https://www.python.org/)
|
|
19
|
+
[](https://github.com/dusktreader/roadshow/actions/workflows/main.yml)
|
|
20
|
+
[](https://dusktreader.github.io/roadshow/)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+

|
|
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
|
+
```
|
roadshow-0.1.0/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[](https://pypi.org/project/roadshow/)
|
|
2
|
+
[](https://www.python.org/)
|
|
3
|
+
[](https://github.com/dusktreader/roadshow/actions/workflows/main.yml)
|
|
4
|
+
[](https://dusktreader.github.io/roadshow/)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+

|
|
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
|
|
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()
|