sphinx-dynamic-command-builder 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,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+ docs/_build/
10
+ examples/_build/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sphinx-dynamic-command-builder contributors
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,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: sphinx-dynamic-command-builder
3
+ Version: 0.1.0
4
+ Summary: Interactive command builders for Sphinx documentation
5
+ Project-URL: Homepage, https://github.com/Aionw/sphinx-dynamic-command-builder
6
+ Project-URL: Repository, https://github.com/Aionw/sphinx-dynamic-command-builder
7
+ Project-URL: Issues, https://github.com/Aionw/sphinx-dynamic-command-builder/issues
8
+ Author: sphinx-dynamic-command-builder contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: command,directive,documentation,interactive,sphinx
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: Sphinx :: Extension
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Documentation
24
+ Classifier: Topic :: Documentation :: Sphinx
25
+ Classifier: Topic :: Software Development :: Documentation
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: pyyaml>=6
28
+ Requires-Dist: sphinx>=5
29
+ Provides-Extra: docs
30
+ Requires-Dist: myst-parser>=2; extra == 'docs'
31
+ Requires-Dist: sphinx-book-theme>=1; extra == 'docs'
32
+ Provides-Extra: test
33
+ Requires-Dist: pytest>=7; extra == 'test'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # sphinx-dynamic-command-builder
37
+
38
+ [![PyPI](https://img.shields.io/pypi/v/sphinx-dynamic-command-builder.svg)](https://pypi.org/project/sphinx-dynamic-command-builder/)
39
+
40
+ Interactive command builders for Sphinx documentation.
41
+
42
+ `sphinx-dynamic-command-builder` adds a `dynamic-command` directive that renders a small selector UI from YAML and updates a generated command in the browser. It is useful for docs that need to show command-line examples assembled from several independent choices.
43
+
44
+ Demo: [GitHub Pages](https://aionw.github.io/sphinx-dynamic-command-builder/)
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install sphinx-dynamic-command-builder
50
+ ```
51
+
52
+ Then enable the extension in `conf.py`:
53
+
54
+ ```python
55
+ extensions = [
56
+ "sphinx_dynamic_command_builder",
57
+ ]
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ````md
63
+ ```{dynamic-command}
64
+ base: python -m sglang.launch_server --model-path [model_path]
65
+ format:
66
+ line_break: options
67
+ indent: " "
68
+ options:
69
+ - label: Topology
70
+ key: nodes
71
+ default: single
72
+ choices:
73
+ - label: Single node
74
+ value: single
75
+ args: --host 0.0.0.0 --port 30000
76
+ - label: Multi node
77
+ value: multi
78
+ args: --host 0.0.0.0 --port 30000 --disaggregation-ib-device mlx5_1
79
+ ```
80
+ ````
81
+
82
+ Each option group is rendered as one selector row. Selecting a choice updates the generated command.
83
+
84
+ ## YAML schema
85
+
86
+ See [Configuration](docs/configuration.md) for the full field reference and formatting rules.
87
+
88
+ - `base`: base command string.
89
+ - `command_label`: optional output label. Defaults to `Generated command`.
90
+ - `format.line_break`: optional command wrapping mode. Use `options` to put each `--option` group on its own shell-continuation line, or `none` to render a single line. Defaults to `options`.
91
+ - `format.indent`: optional indentation for continuation lines. Defaults to two spaces.
92
+ - `options`: list of option groups.
93
+ - `options[].label`: visible group label.
94
+ - `options[].key`: stable group key.
95
+ - `options[].default`: optional default choice value. Defaults to the first choice.
96
+ - `options[].choices`: list of choices.
97
+ - `choices[].label`: visible choice label.
98
+ - `choices[].value`: stable choice value.
99
+ - `choices[].env`: optional text prepended before the base command.
100
+ - `choices[].args`: optional text appended after the base command.
101
+ - `choices[].base`: optional replacement base command for this choice.
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ uv venv
107
+ uv pip install -e ".[test,docs]"
108
+ pytest
109
+ sphinx-build -M html docs docs/_build
110
+ ```
@@ -0,0 +1,75 @@
1
+ # sphinx-dynamic-command-builder
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/sphinx-dynamic-command-builder.svg)](https://pypi.org/project/sphinx-dynamic-command-builder/)
4
+
5
+ Interactive command builders for Sphinx documentation.
6
+
7
+ `sphinx-dynamic-command-builder` adds a `dynamic-command` directive that renders a small selector UI from YAML and updates a generated command in the browser. It is useful for docs that need to show command-line examples assembled from several independent choices.
8
+
9
+ Demo: [GitHub Pages](https://aionw.github.io/sphinx-dynamic-command-builder/)
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install sphinx-dynamic-command-builder
15
+ ```
16
+
17
+ Then enable the extension in `conf.py`:
18
+
19
+ ```python
20
+ extensions = [
21
+ "sphinx_dynamic_command_builder",
22
+ ]
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ````md
28
+ ```{dynamic-command}
29
+ base: python -m sglang.launch_server --model-path [model_path]
30
+ format:
31
+ line_break: options
32
+ indent: " "
33
+ options:
34
+ - label: Topology
35
+ key: nodes
36
+ default: single
37
+ choices:
38
+ - label: Single node
39
+ value: single
40
+ args: --host 0.0.0.0 --port 30000
41
+ - label: Multi node
42
+ value: multi
43
+ args: --host 0.0.0.0 --port 30000 --disaggregation-ib-device mlx5_1
44
+ ```
45
+ ````
46
+
47
+ Each option group is rendered as one selector row. Selecting a choice updates the generated command.
48
+
49
+ ## YAML schema
50
+
51
+ See [Configuration](docs/configuration.md) for the full field reference and formatting rules.
52
+
53
+ - `base`: base command string.
54
+ - `command_label`: optional output label. Defaults to `Generated command`.
55
+ - `format.line_break`: optional command wrapping mode. Use `options` to put each `--option` group on its own shell-continuation line, or `none` to render a single line. Defaults to `options`.
56
+ - `format.indent`: optional indentation for continuation lines. Defaults to two spaces.
57
+ - `options`: list of option groups.
58
+ - `options[].label`: visible group label.
59
+ - `options[].key`: stable group key.
60
+ - `options[].default`: optional default choice value. Defaults to the first choice.
61
+ - `options[].choices`: list of choices.
62
+ - `choices[].label`: visible choice label.
63
+ - `choices[].value`: stable choice value.
64
+ - `choices[].env`: optional text prepended before the base command.
65
+ - `choices[].args`: optional text appended after the base command.
66
+ - `choices[].base`: optional replacement base command for this choice.
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ uv venv
72
+ uv pip install -e ".[test,docs]"
73
+ pytest
74
+ sphinx-build -M html docs docs/_build
75
+ ```
@@ -0,0 +1,7 @@
1
+ extensions = [
2
+ "myst_parser",
3
+ "sphinx_dynamic_command_builder",
4
+ ]
5
+
6
+ project = "sphinx-dynamic-command-builder"
7
+ html_theme = "sphinx_book_theme"
@@ -0,0 +1,93 @@
1
+ # Configuration
2
+
3
+ The `dynamic-command` directive reads a YAML mapping. The YAML describes the
4
+ base command, the generated command format, and the option groups rendered as
5
+ selectors.
6
+
7
+ ## Minimal Example
8
+
9
+ ````md
10
+ ```{dynamic-command}
11
+ base: python -m sglang.launch_server --model-path [model_path]
12
+ options:
13
+ - label: Topology
14
+ key: nodes
15
+ default: single
16
+ choices:
17
+ - label: Single node
18
+ value: single
19
+ args: --host 0.0.0.0 --port 30000
20
+ - label: Multi node
21
+ value: multi
22
+ args: --host 0.0.0.0 --port 30000 --disaggregation-ib-device mlx5_1
23
+ ```
24
+ ````
25
+
26
+ ## Top-Level Fields
27
+
28
+ - `base`: required string. The default command before selected choices add or replace anything.
29
+ - `command_label`: optional string. Label shown above the generated command. Defaults to `Generated command`.
30
+ - `format`: optional mapping. Controls how the generated command is displayed.
31
+ - `options`: required non-empty list. Each item defines one selector row.
32
+
33
+ ## Format Fields
34
+
35
+ `format.line_break` controls command wrapping:
36
+
37
+ - `options`: default. Render the command as shell-continuation lines and put each `--option` group on its own line.
38
+ - `none`: render the command as a single line.
39
+
40
+ `format.indent` controls indentation for continuation lines. It defaults to two spaces.
41
+
42
+ Example:
43
+
44
+ ```yaml
45
+ format:
46
+ line_break: options
47
+ indent: " "
48
+ ```
49
+
50
+ This renders:
51
+
52
+ ```bash
53
+ python -m sglang.launch_server \
54
+ --model-path [model_path] \
55
+ --host 0.0.0.0 \
56
+ --port 30000
57
+ ```
58
+
59
+ Use `line_break: none` when the exact single-line command matters:
60
+
61
+ ```yaml
62
+ format:
63
+ line_break: none
64
+ ```
65
+
66
+ ## Option Fields
67
+
68
+ Each item in `options` defines one selector group:
69
+
70
+ - `options[].label`: required string. Visible group label.
71
+ - `options[].key`: required string. Stable key used to track the selected choice.
72
+ - `options[].default`: optional string. Default choice value. Defaults to the first choice.
73
+ - `options[].choices`: required non-empty list. Available choices for the group.
74
+
75
+ Each item in `choices` defines one selectable command fragment:
76
+
77
+ - `choices[].label`: required string. Visible button label.
78
+ - `choices[].value`: required string. Stable choice value.
79
+ - `choices[].env`: optional string. Prepended before the command when selected.
80
+ - `choices[].args`: optional string. Appended after the command when selected.
81
+ - `choices[].base`: optional string. Replaces the top-level `base` command when selected.
82
+
83
+ ## Command Assembly
84
+
85
+ The generated command is assembled in this order:
86
+
87
+ 1. Selected `env` fragments.
88
+ 2. The active command, either top-level `base` or the selected choice `base`.
89
+ 3. Selected `args` fragments in option group order.
90
+
91
+ With `format.line_break: options`, tokens beginning with `--` start a new
92
+ continuation line. Values following an option stay on the same line as that
93
+ option.
@@ -0,0 +1,49 @@
1
+ # sphinx-dynamic-command-builder
2
+
3
+ ```{toctree}
4
+ :maxdepth: 2
5
+
6
+ configuration
7
+ ```
8
+
9
+ ```{dynamic-command}
10
+ base: python -m sglang.launch_server --model-path [model_path]
11
+ format:
12
+ line_break: options
13
+ indent: " "
14
+ options:
15
+ - label: Integration path
16
+ key: path
17
+ default: hicache
18
+ choices:
19
+ - label: HiCache L3
20
+ value: hicache
21
+ env: MOONCAKE_MASTER=127.0.0.1:50051
22
+ args: --enable-hierarchical-cache --hicache-storage-backend mooncake
23
+ - label: PD disaggregation
24
+ value: pd
25
+ args: --disaggregation-mode prefill
26
+ - label: Topology
27
+ key: nodes
28
+ default: single
29
+ choices:
30
+ - label: Single node
31
+ value: single
32
+ args: --host 0.0.0.0 --port 30000
33
+ - label: Multi node
34
+ value: multi
35
+ args: --host 0.0.0.0 --port 30000 --disaggregation-ib-device mlx5_1
36
+ - label: Parallelism
37
+ key: tp
38
+ default: "4"
39
+ choices:
40
+ - label: TP 1
41
+ value: "1"
42
+ args: --tp-size 1
43
+ - label: TP 4
44
+ value: "4"
45
+ args: --tp-size 4
46
+ - label: TP 8
47
+ value: "8"
48
+ args: --tp-size 8
49
+ ```
@@ -0,0 +1,14 @@
1
+ # Interactive example
2
+
3
+ This directory is a minimal Sphinx project that uses the local
4
+ `sphinx_dynamic_command_builder` extension.
5
+
6
+ Build it from the repository root:
7
+
8
+ ```bash
9
+ sphinx-build -M html examples examples/_build
10
+ ```
11
+
12
+ Then open `examples/_build/html/index.html` in a browser. The generated page
13
+ loads the extension's CSS and JavaScript through Sphinx instead of relying on a
14
+ hand-written HTML mockup.
@@ -0,0 +1,64 @@
1
+ :root {
2
+ --example-bg: #f7f8fb;
3
+ --example-text: #1f2328;
4
+ --example-muted: #59636e;
5
+ }
6
+
7
+ * {
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ html {
12
+ background: var(--example-bg);
13
+ }
14
+
15
+ body {
16
+ background: var(--example-bg);
17
+ color: var(--example-text);
18
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
19
+ line-height: 1.5;
20
+ margin: 0;
21
+ }
22
+
23
+ a.headerlink {
24
+ display: none;
25
+ }
26
+
27
+ div.related,
28
+ div.sphinxsidebar,
29
+ div.footer,
30
+ div.clearer {
31
+ display: none;
32
+ }
33
+
34
+ div.document {
35
+ margin: 0 auto;
36
+ max-width: 1040px;
37
+ padding: 48px 20px 64px;
38
+ }
39
+
40
+ div.documentwrapper,
41
+ div.bodywrapper {
42
+ float: none;
43
+ margin: 0;
44
+ }
45
+
46
+ div.body {
47
+ margin: 0;
48
+ min-width: 0;
49
+ max-width: none;
50
+ }
51
+
52
+ h1 {
53
+ font-size: clamp(2rem, 5vw, 3.7rem);
54
+ letter-spacing: 0;
55
+ line-height: 1.05;
56
+ margin: 0 0 14px;
57
+ }
58
+
59
+ p {
60
+ color: var(--example-muted);
61
+ font-size: 1.04rem;
62
+ margin: 0 0 1.5rem;
63
+ max-width: 700px;
64
+ }
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
7
+
8
+ project = "sphinx-dynamic-command-builder example"
9
+ extensions = [
10
+ "sphinx_dynamic_command_builder",
11
+ ]
12
+
13
+ html_title = project
14
+ html_theme = "basic"
15
+ html_static_path = ["_static"]
16
+ html_css_files = ["example.css"]
17
+ html_sidebars = {"**": []}
18
+ html_show_sourcelink = False
19
+ html_use_index = False
20
+ exclude_patterns = ["_build"]
@@ -0,0 +1,57 @@
1
+ sphinx-dynamic-command-builder example
2
+ ==============================
3
+
4
+ This page is built by Sphinx and renders the command builder through the
5
+ ``sphinx_dynamic_command_builder`` extension.
6
+
7
+ .. dynamic-command::
8
+
9
+ base: python -m sglang.launch_server --model-path [model_path]
10
+ command_label: Generated command
11
+ format:
12
+ line_break: options
13
+ indent: " "
14
+ options:
15
+ - label: Integration path
16
+ key: path
17
+ default: hicache
18
+ choices:
19
+ - label: HiCache L3
20
+ value: hicache
21
+ env: MOONCAKE_MASTER=127.0.0.1:50051
22
+ args: --enable-hierarchical-cache --hicache-storage-backend mooncake
23
+ - label: PD disaggregation
24
+ value: pd
25
+ args: --disaggregation-mode prefill
26
+ - label: Topology
27
+ key: nodes
28
+ default: single
29
+ choices:
30
+ - label: Single node
31
+ value: single
32
+ args: --host 0.0.0.0 --port 30000
33
+ - label: Multi node
34
+ value: multi
35
+ args: --host 0.0.0.0 --port 30000 --disaggregation-ib-device mlx5_1
36
+ - label: Parallelism
37
+ key: tp
38
+ default: "4"
39
+ choices:
40
+ - label: TP 1
41
+ value: "1"
42
+ args: --tp-size 1
43
+ - label: TP 4
44
+ value: "4"
45
+ args: --tp-size 4
46
+ - label: TP 8
47
+ value: "8"
48
+ args: --tp-size 8
49
+ - label: Runtime
50
+ key: runtime
51
+ default: python
52
+ choices:
53
+ - label: Python module
54
+ value: python
55
+ - label: uv run
56
+ value: uv
57
+ base: uv run python -m sglang.launch_server --model-path [model_path]
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sphinx-dynamic-command-builder"
7
+ version = "0.1.0"
8
+ description = "Interactive command builders for Sphinx documentation"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "sphinx-dynamic-command-builder contributors" },
14
+ ]
15
+ keywords = ["sphinx", "documentation", "directive", "command", "interactive"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Framework :: Sphinx :: Extension",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Documentation",
29
+ "Topic :: Documentation :: Sphinx",
30
+ "Topic :: Software Development :: Documentation",
31
+ ]
32
+ dependencies = [
33
+ "PyYAML>=6",
34
+ "Sphinx>=5",
35
+ ]
36
+
37
+ [project.optional-dependencies]
38
+ docs = [
39
+ "myst-parser>=2",
40
+ "sphinx-book-theme>=1",
41
+ ]
42
+ test = [
43
+ "pytest>=7",
44
+ ]
45
+
46
+ [project.urls]
47
+ Homepage = "https://github.com/Aionw/sphinx-dynamic-command-builder"
48
+ Repository = "https://github.com/Aionw/sphinx-dynamic-command-builder"
49
+ Issues = "https://github.com/Aionw/sphinx-dynamic-command-builder/issues"
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["src/sphinx_dynamic_command_builder"]
53
+
54
+ [tool.hatch.build.targets.sdist]
55
+ include = [
56
+ "/src",
57
+ "/docs",
58
+ "/examples",
59
+ "/tests",
60
+ "/README.md",
61
+ "/LICENSE",
62
+ "/pyproject.toml",
63
+ ]
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from sphinx.util.fileutil import copy_asset
6
+
7
+ from .directive import DynamicCommandDirective
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ STATIC_DIR = Path(__file__).parent / "static"
12
+
13
+
14
+ def _copy_static_assets(app, exc):
15
+ if exc is not None or app.builder.format != "html":
16
+ return
17
+
18
+ static_out = Path(app.outdir) / "_static"
19
+ copy_asset(str(STATIC_DIR / "sphinx-dynamic-command-builder.css"), str(static_out))
20
+ copy_asset(str(STATIC_DIR / "sphinx-dynamic-command-builder.js"), str(static_out))
21
+
22
+
23
+ def setup(app):
24
+ app.add_directive("dynamic-command", DynamicCommandDirective)
25
+ app.add_css_file("sphinx-dynamic-command-builder.css")
26
+ app.add_js_file("sphinx-dynamic-command-builder.js")
27
+ app.connect("build-finished", _copy_static_assets)
28
+
29
+ return {
30
+ "version": __version__,
31
+ "parallel_read_safe": True,
32
+ "parallel_write_safe": True,
33
+ }
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ from html import escape
4
+ from typing import Any
5
+
6
+ from docutils import nodes
7
+ from docutils.parsers.rst import Directive
8
+ import yaml
9
+
10
+
11
+ def _as_str(value: Any, field: str) -> str:
12
+ if value is None:
13
+ return ""
14
+ if not isinstance(value, str):
15
+ raise ValueError(f"{field} must be a string")
16
+ return value
17
+
18
+
19
+ def _choice_button(group_key: str, group_default: str, choice: dict[str, Any]) -> str:
20
+ label = _as_str(choice.get("label"), "choice.label")
21
+ value = _as_str(choice.get("value"), "choice.value")
22
+ if not label:
23
+ raise ValueError("choice.label is required")
24
+ if not value:
25
+ raise ValueError("choice.value is required")
26
+
27
+ attrs = {
28
+ "class": "sdc-button",
29
+ "type": "button",
30
+ "data-sdc-option": group_key,
31
+ "data-sdc-value": value,
32
+ }
33
+
34
+ command_base = _as_str(choice.get("base"), "choice.base")
35
+ env = _as_str(choice.get("env"), "choice.env")
36
+ args = _as_str(choice.get("args"), "choice.args")
37
+ if command_base:
38
+ attrs["data-sdc-base"] = command_base
39
+ if env:
40
+ attrs["data-sdc-env"] = env
41
+ if args:
42
+ attrs["data-sdc-args"] = args
43
+ if value == group_default:
44
+ attrs["data-sdc-default"] = "true"
45
+
46
+ rendered_attrs = " ".join(
47
+ f'{escape(name)}="{escape(attr_value, quote=True)}"'
48
+ for name, attr_value in attrs.items()
49
+ )
50
+ return f"<button {rendered_attrs}>{escape(label)}</button>"
51
+
52
+
53
+ def _option_group(group: dict[str, Any]) -> str:
54
+ label = _as_str(group.get("label"), "options.label")
55
+ key = _as_str(group.get("key"), "options.key")
56
+ default = _as_str(group.get("default"), "options.default")
57
+ choices = group.get("choices")
58
+
59
+ if not label:
60
+ raise ValueError("options.label is required")
61
+ if not key:
62
+ raise ValueError("options.key is required")
63
+ if not isinstance(choices, list) or not choices:
64
+ raise ValueError(f"options.{key}.choices must be a non-empty list")
65
+
66
+ if not default:
67
+ default = _as_str(choices[0].get("value"), "choice.value")
68
+
69
+ buttons = "\n".join(_choice_button(key, default, choice) for choice in choices)
70
+ return f"""
71
+ <div class="sdc-group">
72
+ <div class="sdc-label">{escape(label)}</div>
73
+ <div class="sdc-buttons">
74
+ {buttons}
75
+ </div>
76
+ </div>
77
+ """
78
+
79
+
80
+ def _format_attrs(config: dict[str, Any]) -> dict[str, str]:
81
+ format_config = config.get("format", {})
82
+ if format_config is None:
83
+ format_config = {}
84
+ if not isinstance(format_config, dict):
85
+ raise ValueError("format must be a YAML mapping")
86
+
87
+ line_break = _as_str(format_config.get("line_break", "options"), "format.line_break")
88
+ if line_break not in {"options", "none"}:
89
+ raise ValueError("format.line_break must be one of: options, none")
90
+
91
+ indent = _as_str(format_config.get("indent", " "), "format.indent")
92
+
93
+ return {
94
+ "data-sdc-line-break": line_break,
95
+ "data-sdc-indent": indent,
96
+ }
97
+
98
+
99
+ class DynamicCommandDirective(Directive):
100
+ """Render a selector-driven command generator from YAML content."""
101
+
102
+ has_content = True
103
+
104
+ def run(self) -> list[nodes.Node]:
105
+ try:
106
+ config = yaml.safe_load("\n".join(self.content)) or {}
107
+ if not isinstance(config, dict):
108
+ raise ValueError("dynamic-command content must be a YAML mapping")
109
+
110
+ base = _as_str(config.get("base"), "base")
111
+ if not base:
112
+ raise ValueError("base is required")
113
+
114
+ groups = config.get("options")
115
+ if not isinstance(groups, list) or not groups:
116
+ raise ValueError("options must be a non-empty list")
117
+
118
+ command_label = _as_str(
119
+ config.get("command_label", "Generated command"), "command_label"
120
+ )
121
+ format_attrs = _format_attrs(config)
122
+ rendered_format_attrs = " ".join(
123
+ f'{escape(name)}="{escape(value, quote=True)}"'
124
+ for name, value in format_attrs.items()
125
+ )
126
+ rendered_groups = "\n".join(_option_group(group) for group in groups)
127
+ except Exception as exc:
128
+ error = self.state_machine.reporter.error(
129
+ f"dynamic-command: {exc}",
130
+ line=self.lineno,
131
+ )
132
+ return [error]
133
+
134
+ html = f"""
135
+ <div class="sdc-card">
136
+ <div data-sdc>
137
+ <div class="sdc-controls">
138
+ {rendered_groups}
139
+ </div>
140
+ <div class="sdc-command">
141
+ <div class="sdc-command-label">{escape(command_label)}</div>
142
+ <pre><code class="language-bash" data-sdc-output data-sdc-base="{escape(base, quote=True)}" {rendered_format_attrs}></code></pre>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ """
147
+ return [nodes.raw("", html, format="html")]
@@ -0,0 +1,105 @@
1
+ .sdc-card {
2
+ background: var(--pst-color-background, #fff);
3
+ border: 1px solid var(--pst-color-border, #d8dee4);
4
+ border-radius: 8px;
5
+ box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
6
+ margin: 1.5rem 0;
7
+ overflow: hidden;
8
+ }
9
+
10
+ .sdc-controls {
11
+ background: color-mix(in srgb, var(--pst-color-surface, #f6f8fa) 96%, var(--pst-color-primary, #0969da) 4%);
12
+ border-bottom: 1px solid var(--pst-color-border, #d8dee4);
13
+ display: grid;
14
+ gap: 0;
15
+ padding: 0.35rem 1rem;
16
+ }
17
+
18
+ .sdc-group {
19
+ align-items: center;
20
+ border-bottom: 1px solid var(--pst-color-border, #d8dee4);
21
+ display: grid;
22
+ gap: 1rem;
23
+ grid-template-columns: minmax(120px, 180px) 1fr;
24
+ padding: 0.65rem 0;
25
+ }
26
+
27
+ .sdc-group:last-child {
28
+ border-bottom: 0;
29
+ }
30
+
31
+ .sdc-label,
32
+ .sdc-command-label {
33
+ color: var(--pst-color-text-muted, #57606a);
34
+ font-size: 0.75rem;
35
+ font-weight: 600;
36
+ letter-spacing: 0;
37
+ line-height: 1.2;
38
+ }
39
+
40
+ .sdc-buttons {
41
+ background: var(--pst-color-background, #fff);
42
+ border: 1px solid var(--pst-color-border, #d8dee4);
43
+ border-radius: 7px;
44
+ display: inline-flex;
45
+ flex-wrap: wrap;
46
+ gap: 0.25rem;
47
+ max-width: 100%;
48
+ padding: 0.25rem;
49
+ width: max-content;
50
+ }
51
+
52
+ .sdc-button {
53
+ background: transparent;
54
+ border: 0;
55
+ border-radius: 5px;
56
+ color: var(--pst-color-text-base, #24292f);
57
+ cursor: pointer;
58
+ font: inherit;
59
+ font-size: 0.9rem;
60
+ line-height: 1.2;
61
+ padding: 0.42rem 0.68rem;
62
+ transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
63
+ }
64
+
65
+ .sdc-button.is-selected {
66
+ background: var(--pst-color-primary, #0969da);
67
+ box-shadow: 0 1px 2px rgb(0 0 0 / 12%);
68
+ color: var(--pst-color-on-primary, #fff);
69
+ }
70
+
71
+ .sdc-command {
72
+ background: var(--pst-color-background, #fff);
73
+ padding: 1rem;
74
+ }
75
+
76
+ .sdc-command-label {
77
+ margin-bottom: 0.5rem;
78
+ }
79
+
80
+ .sdc-command pre {
81
+ border: 1px solid var(--pst-color-border, #d8dee4);
82
+ border-radius: 7px;
83
+ margin: 0;
84
+ overflow-x: auto;
85
+ white-space: pre;
86
+ }
87
+
88
+ .sdc-command code {
89
+ font-size: 0.86rem;
90
+ line-height: 1.45;
91
+ white-space: pre;
92
+ }
93
+
94
+ @media (max-width: 640px) {
95
+ .sdc-group {
96
+ align-items: start;
97
+ gap: 0.45rem;
98
+ grid-template-columns: 1fr;
99
+ }
100
+
101
+ .sdc-buttons {
102
+ width: 100%;
103
+ }
104
+ }
105
+
@@ -0,0 +1,147 @@
1
+ (() => {
2
+ function tokenizeCommand(command) {
3
+ return command.match(/"[^"]*"|'[^']*'|\S+/g) || [];
4
+ }
5
+
6
+ function splitCommandParts(command) {
7
+ const tokens = tokenizeCommand(command);
8
+ const firstOption = tokens.findIndex((token) => token.startsWith("--"));
9
+
10
+ if (firstOption === -1) {
11
+ return {
12
+ prefix: tokens.join(" "),
13
+ options: [],
14
+ };
15
+ }
16
+
17
+ return {
18
+ prefix: tokens.slice(0, firstOption).join(" "),
19
+ options: groupOptionTokens(tokens.slice(firstOption)),
20
+ };
21
+ }
22
+
23
+ function groupOptionTokens(tokens) {
24
+ const groups = [];
25
+ let group = [];
26
+
27
+ tokens.forEach((token) => {
28
+ if (token.startsWith("--") && group.length) {
29
+ groups.push(group.join(" "));
30
+ group = [token];
31
+ return;
32
+ }
33
+
34
+ group.push(token);
35
+ });
36
+
37
+ if (group.length) {
38
+ groups.push(group.join(" "));
39
+ }
40
+
41
+ return groups;
42
+ }
43
+
44
+ function formatCommand(env, command, args, config) {
45
+ if (config.lineBreak === "none") {
46
+ return [...env, command, ...args].filter(Boolean).join(" ");
47
+ }
48
+
49
+ const commandParts = splitCommandParts(command);
50
+ const lines = [
51
+ [...env, commandParts.prefix].filter(Boolean).join(" "),
52
+ ...commandParts.options,
53
+ ...args.flatMap((arg) => groupOptionTokens(tokenizeCommand(arg))),
54
+ ].filter(Boolean);
55
+
56
+ return lines
57
+ .map((line, index) => {
58
+ const continuation = index < lines.length - 1 ? " \\" : "";
59
+ const indent = index === 0 ? "" : config.indent;
60
+ return `${indent}${line}${continuation}`;
61
+ })
62
+ .join("\n");
63
+ }
64
+
65
+ function readState(panel) {
66
+ const keys = Array.from(
67
+ new Set(
68
+ Array.from(panel.querySelectorAll("[data-sdc-option]")).map((option) =>
69
+ option.getAttribute("data-sdc-option")
70
+ )
71
+ )
72
+ );
73
+
74
+ return keys.reduce((state, key) => {
75
+ const selected = panel.querySelector(
76
+ `[data-sdc-option="${key}"][data-sdc-default="true"]`
77
+ );
78
+ const first = panel.querySelector(`[data-sdc-option="${key}"]`);
79
+ state[key] = (selected || first)?.getAttribute("data-sdc-value") || "";
80
+ return state;
81
+ }, {});
82
+ }
83
+
84
+ function updatePanel(panel, state) {
85
+ panel.querySelectorAll("[data-sdc-option]").forEach((option) => {
86
+ const key = option.getAttribute("data-sdc-option");
87
+ const value = option.getAttribute("data-sdc-value");
88
+ const isSelected = state[key] === value;
89
+
90
+ option.classList.toggle("is-selected", isSelected);
91
+ option.setAttribute("aria-pressed", isSelected ? "true" : "false");
92
+ });
93
+
94
+ panel.querySelectorAll("[data-sdc-output]").forEach((output) => {
95
+ const env = [];
96
+ const args = [];
97
+ let command = output.getAttribute("data-sdc-base") || "";
98
+
99
+ Object.entries(state).forEach(([key, value]) => {
100
+ const selected = Array.from(
101
+ panel.querySelectorAll(`[data-sdc-option="${key}"]`)
102
+ ).find((option) => option.getAttribute("data-sdc-value") === value);
103
+
104
+ if (!selected) {
105
+ return;
106
+ }
107
+
108
+ const nextBase = selected.getAttribute("data-sdc-base");
109
+ const nextEnv = selected.getAttribute("data-sdc-env");
110
+ const nextArgs = selected.getAttribute("data-sdc-args");
111
+
112
+ if (nextBase) {
113
+ command = nextBase;
114
+ }
115
+ if (nextEnv) {
116
+ env.push(nextEnv);
117
+ }
118
+ if (nextArgs) {
119
+ args.push(nextArgs);
120
+ }
121
+ });
122
+
123
+ output.textContent = formatCommand(env, command, args, {
124
+ indent: output.getAttribute("data-sdc-indent") || " ",
125
+ lineBreak: output.getAttribute("data-sdc-line-break") || "options",
126
+ });
127
+ });
128
+ }
129
+
130
+ function setupPanel(panel) {
131
+ const state = readState(panel);
132
+
133
+ panel.querySelectorAll("[data-sdc-option]").forEach((option) => {
134
+ option.addEventListener("click", () => {
135
+ state[option.getAttribute("data-sdc-option")] =
136
+ option.getAttribute("data-sdc-value");
137
+ updatePanel(panel, state);
138
+ });
139
+ });
140
+
141
+ updatePanel(panel, state);
142
+ }
143
+
144
+ document.addEventListener("DOMContentLoaded", () => {
145
+ document.querySelectorAll("[data-sdc]").forEach(setupPanel);
146
+ });
147
+ })();
@@ -0,0 +1,289 @@
1
+ import pytest
2
+ from docutils import nodes
3
+ from docutils.core import publish_doctree
4
+ from docutils.parsers.rst import directives
5
+
6
+ import sphinx_dynamic_command_builder as extension
7
+ from sphinx_dynamic_command_builder.directive import (
8
+ DynamicCommandDirective,
9
+ _choice_button,
10
+ _format_attrs,
11
+ _option_group,
12
+ )
13
+
14
+
15
+ directives.register_directive("dynamic-command", DynamicCommandDirective)
16
+
17
+
18
+ def render_dynamic_command(content: str) -> str:
19
+ indented = "\n".join(f" {line}" if line else "" for line in content.splitlines())
20
+ doctree = publish_doctree(f".. dynamic-command::\n\n{indented}\n")
21
+ raw_nodes = list(doctree.findall(nodes.raw))
22
+ assert len(raw_nodes) == 1
23
+ return raw_nodes[0].astext()
24
+
25
+
26
+ def render_dynamic_command_error(content: str) -> str:
27
+ indented = "\n".join(f" {line}" if line else "" for line in content.splitlines())
28
+ doctree = publish_doctree(f".. dynamic-command::\n\n{indented}\n")
29
+ messages = list(doctree.findall(nodes.system_message))
30
+ assert len(messages) == 1
31
+ return messages[0].astext()
32
+
33
+
34
+ def test_directive_registered_importable():
35
+ assert DynamicCommandDirective.has_content is True
36
+
37
+
38
+ def test_format_attrs_defaults_to_option_line_breaks():
39
+ assert _format_attrs({}) == {
40
+ "data-sdc-line-break": "options",
41
+ "data-sdc-indent": " ",
42
+ }
43
+
44
+
45
+ def test_format_attrs_rejects_unknown_line_break_mode():
46
+ with pytest.raises(ValueError, match="format.line_break"):
47
+ _format_attrs({"format": {"line_break": "always"}})
48
+
49
+
50
+ def test_directive_renders_configured_command_builder_html():
51
+ html = render_dynamic_command(
52
+ """
53
+ base: python -m tool --model "a b"
54
+ command_label: Run <now> & "fast"
55
+ format:
56
+ line_break: none
57
+ indent: " "
58
+ options:
59
+ - label: Mode <select>
60
+ key: mode
61
+ default: fast
62
+ choices:
63
+ - label: Fast & safe
64
+ value: fast
65
+ env: CUDA_VISIBLE_DEVICES=0
66
+ args: --batch-size 4 --name "x y"
67
+ - label: Slow "quoted"
68
+ value: slow
69
+ base: python -m slow
70
+ args: --debug
71
+ """.strip()
72
+ )
73
+
74
+ assert 'data-sdc-base="python -m tool --model &quot;a b&quot;"' in html
75
+ assert 'data-sdc-line-break="none"' in html
76
+ assert 'data-sdc-indent=" "' in html
77
+ assert "Run &lt;now&gt; &amp; &quot;fast&quot;" in html
78
+ assert "Mode &lt;select&gt;" in html
79
+ assert 'data-sdc-option="mode"' in html
80
+ assert 'data-sdc-value="fast"' in html
81
+ assert 'data-sdc-default="true"' in html
82
+ assert 'data-sdc-env="CUDA_VISIBLE_DEVICES=0"' in html
83
+ assert 'data-sdc-args="--batch-size 4 --name &quot;x y&quot;"' in html
84
+ assert 'data-sdc-base="python -m slow"' in html
85
+ assert "Slow &quot;quoted&quot;" in html
86
+
87
+
88
+ @pytest.mark.parametrize(
89
+ ("content", "message"),
90
+ [
91
+ ("- item", "dynamic-command content must be a YAML mapping"),
92
+ ("options: []", "base is required"),
93
+ ("base: run\noptions: []", "options must be a non-empty list"),
94
+ (
95
+ """
96
+ base: run
97
+ options:
98
+ - key: mode
99
+ choices:
100
+ - label: Fast
101
+ value: fast
102
+ """.strip(),
103
+ "options.label is required",
104
+ ),
105
+ (
106
+ """
107
+ base: run
108
+ options:
109
+ - label: Mode
110
+ choices:
111
+ - label: Fast
112
+ value: fast
113
+ """.strip(),
114
+ "options.key is required",
115
+ ),
116
+ (
117
+ """
118
+ base: run
119
+ options:
120
+ - label: Mode
121
+ key: mode
122
+ choices: []
123
+ """.strip(),
124
+ "options.mode.choices must be a non-empty list",
125
+ ),
126
+ (
127
+ """
128
+ base: run
129
+ options:
130
+ - label: Mode
131
+ key: mode
132
+ choices:
133
+ - value: fast
134
+ """.strip(),
135
+ "choice.label is required",
136
+ ),
137
+ (
138
+ """
139
+ base: run
140
+ options:
141
+ - label: Mode
142
+ key: mode
143
+ choices:
144
+ - label: Fast
145
+ """.strip(),
146
+ "choice.value is required",
147
+ ),
148
+ ],
149
+ )
150
+ def test_directive_reports_configuration_errors(content, message):
151
+ assert message in render_dynamic_command_error(content)
152
+
153
+
154
+ def test_format_attrs_accepts_explicit_options():
155
+ assert _format_attrs({"format": {"line_break": "none", "indent": " "}}) == {
156
+ "data-sdc-line-break": "none",
157
+ "data-sdc-indent": " ",
158
+ }
159
+
160
+
161
+ @pytest.mark.parametrize(
162
+ ("config", "message"),
163
+ [
164
+ ({"format": []}, "format must be a YAML mapping"),
165
+ ({"format": {"indent": 2}}, "format.indent must be a string"),
166
+ ],
167
+ )
168
+ def test_format_attrs_rejects_invalid_format_config(config, message):
169
+ with pytest.raises(ValueError, match=message):
170
+ _format_attrs(config)
171
+
172
+
173
+ def test_option_group_defaults_to_first_choice():
174
+ html = _option_group(
175
+ {
176
+ "label": "Mode",
177
+ "key": "mode",
178
+ "choices": [
179
+ {"label": "Fast", "value": "fast"},
180
+ {"label": "Slow", "value": "slow"},
181
+ ],
182
+ }
183
+ )
184
+
185
+ assert 'data-sdc-value="fast" data-sdc-default="true"' in html
186
+ assert 'data-sdc-value="slow" data-sdc-default="true"' not in html
187
+
188
+
189
+ def test_choice_button_escapes_attribute_and_label_values():
190
+ html = _choice_button(
191
+ "mode",
192
+ "fast",
193
+ {
194
+ "label": "Fast <safe>",
195
+ "value": "fast",
196
+ "env": 'A="1&2"',
197
+ "args": '--name "x<y>"',
198
+ "base": "run <tool>",
199
+ },
200
+ )
201
+
202
+ assert "Fast &lt;safe&gt;" in html
203
+ assert 'data-sdc-env="A=&quot;1&amp;2&quot;"' in html
204
+ assert 'data-sdc-args="--name &quot;x&lt;y&gt;&quot;"' in html
205
+ assert 'data-sdc-base="run &lt;tool&gt;"' in html
206
+
207
+
208
+ def test_setup_registers_directive_assets_and_build_hook():
209
+ class FakeApp:
210
+ def __init__(self):
211
+ self.directives = []
212
+ self.css_files = []
213
+ self.js_files = []
214
+ self.events = []
215
+
216
+ def add_directive(self, name, directive):
217
+ self.directives.append((name, directive))
218
+
219
+ def add_css_file(self, filename):
220
+ self.css_files.append(filename)
221
+
222
+ def add_js_file(self, filename):
223
+ self.js_files.append(filename)
224
+
225
+ def connect(self, event, callback):
226
+ self.events.append((event, callback))
227
+
228
+ app = FakeApp()
229
+
230
+ metadata = extension.setup(app)
231
+
232
+ assert app.directives == [("dynamic-command", DynamicCommandDirective)]
233
+ assert app.css_files == ["sphinx-dynamic-command-builder.css"]
234
+ assert app.js_files == ["sphinx-dynamic-command-builder.js"]
235
+ assert app.events == [("build-finished", extension._copy_static_assets)]
236
+ assert metadata == {
237
+ "version": extension.__version__,
238
+ "parallel_read_safe": True,
239
+ "parallel_write_safe": True,
240
+ }
241
+
242
+
243
+ def test_copy_static_assets_copies_files_for_html_build(tmp_path, monkeypatch):
244
+ copied = []
245
+
246
+ def fake_copy_asset(src, dst):
247
+ copied.append((src, dst))
248
+
249
+ class FakeBuilder:
250
+ format = "html"
251
+
252
+ class FakeApp:
253
+ builder = FakeBuilder()
254
+ outdir = tmp_path
255
+
256
+ monkeypatch.setattr(extension, "copy_asset", fake_copy_asset)
257
+
258
+ extension._copy_static_assets(FakeApp(), None)
259
+
260
+ assert copied == [
261
+ (
262
+ str(extension.STATIC_DIR / "sphinx-dynamic-command-builder.css"),
263
+ str(tmp_path / "_static"),
264
+ ),
265
+ (
266
+ str(extension.STATIC_DIR / "sphinx-dynamic-command-builder.js"),
267
+ str(tmp_path / "_static"),
268
+ ),
269
+ ]
270
+
271
+
272
+ @pytest.mark.parametrize(("builder_format", "exc"), [("latex", None), ("html", Exception())])
273
+ def test_copy_static_assets_skips_non_html_or_failed_build(
274
+ tmp_path, monkeypatch, builder_format, exc
275
+ ):
276
+ copied = []
277
+
278
+ class FakeBuilder:
279
+ format = builder_format
280
+
281
+ class FakeApp:
282
+ builder = FakeBuilder()
283
+ outdir = tmp_path
284
+
285
+ monkeypatch.setattr(extension, "copy_asset", lambda src, dst: copied.append((src, dst)))
286
+
287
+ extension._copy_static_assets(FakeApp(), exc)
288
+
289
+ assert copied == []