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.
- sphinx_dynamic_command_builder-0.1.0/.gitignore +10 -0
- sphinx_dynamic_command_builder-0.1.0/LICENSE +21 -0
- sphinx_dynamic_command_builder-0.1.0/PKG-INFO +110 -0
- sphinx_dynamic_command_builder-0.1.0/README.md +75 -0
- sphinx_dynamic_command_builder-0.1.0/docs/conf.py +7 -0
- sphinx_dynamic_command_builder-0.1.0/docs/configuration.md +93 -0
- sphinx_dynamic_command_builder-0.1.0/docs/index.md +49 -0
- sphinx_dynamic_command_builder-0.1.0/examples/README.md +14 -0
- sphinx_dynamic_command_builder-0.1.0/examples/_static/example.css +64 -0
- sphinx_dynamic_command_builder-0.1.0/examples/conf.py +20 -0
- sphinx_dynamic_command_builder-0.1.0/examples/index.rst +57 -0
- sphinx_dynamic_command_builder-0.1.0/pyproject.toml +63 -0
- sphinx_dynamic_command_builder-0.1.0/src/sphinx_dynamic_command_builder/__init__.py +33 -0
- sphinx_dynamic_command_builder-0.1.0/src/sphinx_dynamic_command_builder/directive.py +147 -0
- sphinx_dynamic_command_builder-0.1.0/src/sphinx_dynamic_command_builder/static/sphinx-dynamic-command-builder.css +105 -0
- sphinx_dynamic_command_builder-0.1.0/src/sphinx_dynamic_command_builder/static/sphinx-dynamic-command-builder.js +147 -0
- sphinx_dynamic_command_builder-0.1.0/tests/test_directive.py +289 -0
|
@@ -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
|
+
[](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
|
+
[](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,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 "a b""' in html
|
|
75
|
+
assert 'data-sdc-line-break="none"' in html
|
|
76
|
+
assert 'data-sdc-indent=" "' in html
|
|
77
|
+
assert "Run <now> & "fast"" in html
|
|
78
|
+
assert "Mode <select>" 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 "x y""' in html
|
|
84
|
+
assert 'data-sdc-base="python -m slow"' in html
|
|
85
|
+
assert "Slow "quoted"" 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 <safe>" in html
|
|
203
|
+
assert 'data-sdc-env="A="1&2""' in html
|
|
204
|
+
assert 'data-sdc-args="--name "x<y>""' in html
|
|
205
|
+
assert 'data-sdc-base="run <tool>"' 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 == []
|