sphinx-pyrepl-web 0.1.0__tar.gz → 0.2.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_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/PKG-INFO +43 -17
- sphinx_pyrepl_web-0.2.0/README.md +113 -0
- sphinx_pyrepl_web-0.2.0/docs/_static/autodoc_demo.py +12 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/docs/conf.py +7 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/docs/example.rst +47 -10
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/pyproject.toml +5 -0
- sphinx_pyrepl_web-0.2.0/sphinx_pyrepl_web/__init__.py +316 -0
- sphinx_pyrepl_web-0.2.0/tests/test_autodoc_bootstrap.py +187 -0
- sphinx_pyrepl_web-0.2.0/tests/test_autodoc_doctest.py +158 -0
- sphinx_pyrepl_web-0.2.0/tests/test_autodoc_include.py +100 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/tests/test_build_replay.py +48 -0
- sphinx_pyrepl_web-0.2.0/tests/test_doctest_to_replay_source.py +55 -0
- sphinx_pyrepl_web-0.1.0/README.md +0 -87
- sphinx_pyrepl_web-0.1.0/sphinx_pyrepl_web/__init__.py +0 -180
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/.github/workflows/python-package.yml +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/.gitignore +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/.readthedocs.yml +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/LICENSE +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/docs/_static/replay_demo.py +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/docs/_static/setup.py +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/docs/index.rst +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/scripts/vendor_repl.py +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/sphinx_pyrepl_web/pyrepl/chunk-94xkfg72.js +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/sphinx_pyrepl_web/pyrepl/chunk-e4mhg83d.js +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/sphinx_pyrepl_web/pyrepl/chunk-ftjk4vft.js +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/sphinx_pyrepl_web/pyrepl/chunk-jfxv8vy0.js +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/sphinx_pyrepl_web/pyrepl/chunk-vx91qfkd.js +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/sphinx_pyrepl_web/pyrepl/chunk-x20ze186.js +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/sphinx_pyrepl_web/pyrepl/pyrepl.esm.js +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/sphinx_pyrepl_web/pyrepl/pyrepl.js +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/sphinx_pyrepl_web/pyrepl/version.txt +0 -0
- {sphinx_pyrepl_web-0.1.0 → sphinx_pyrepl_web-0.2.0}/tests/test_basic.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sphinx-pyrepl-web
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A Sphinx extension for embedding pyrepl-web Python REPLs in documentation.
|
|
5
5
|
Keywords: sphinx,pyrepl,pyodide,repl
|
|
6
6
|
Author-email: Christian López Barrón <chris.gfz@gmail.com>
|
|
@@ -74,30 +74,56 @@ Embed a REPL with the `py-repl` directive:
|
|
|
74
74
|
|
|
75
75
|
### Directive options
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes, with the exception of `silent`:
|
|
78
78
|
|
|
79
|
-
| Option | Description |
|
|
80
|
-
|
|
81
|
-
| `:theme:` | Color theme (`catppuccin-mocha`, `catppuccin-latte`) |
|
|
82
|
-
| `:packages:` | Comma-separated PyPI packages to preload |
|
|
83
|
-
| `:repl-title:` | Title in the REPL header |
|
|
84
|
-
| `:src:` | Path to a Python startup script |
|
|
85
|
-
| `:replay:` | Replay `:src:` with interactive prompts instead of silent load |
|
|
86
|
-
| `:silent:` | Keep `:src:` silent even when combined with a directive body |
|
|
87
|
-
| `:
|
|
88
|
-
| `:no-
|
|
89
|
-
| `:
|
|
90
|
-
| `:
|
|
91
|
-
| `:no-banner:` | Hide the Python version banner | ✅ |
|
|
79
|
+
| Option | Description |
|
|
80
|
+
|--------|-------------|
|
|
81
|
+
| `:theme:` | Color theme (`catppuccin-mocha`, `catppuccin-latte`) |
|
|
82
|
+
| `:packages:` | Comma-separated PyPI packages to preload |
|
|
83
|
+
| `:repl-title:` | Title in the REPL header |
|
|
84
|
+
| `:src:` | Path to a Python startup script |
|
|
85
|
+
| `:replay:` | Replay `:src:` with interactive prompts instead of silent load |
|
|
86
|
+
| `:silent:` | Keep `:src:` silent even when combined with a directive body |
|
|
87
|
+
| `:no-header:` | Hide the header bar |
|
|
88
|
+
| `:no-buttons:` | Hide copy/clear buttons |
|
|
89
|
+
| `:readonly:` | Disable input |
|
|
90
|
+
| `:no-banner:` | Hide the Python version banner |
|
|
92
91
|
|
|
93
|
-
|
|
92
|
+
Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`.
|
|
94
93
|
|
|
95
94
|
Optional Sphinx config:
|
|
96
95
|
|
|
97
96
|
```python
|
|
98
97
|
pyrepl_js = "../pyrepl.js" # default; path to the pyrepl-web loader script
|
|
98
|
+
pyrepl_doctest_blocks = False # default; see Docstring conversion below
|
|
99
|
+
pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc REPLs
|
|
99
100
|
```
|
|
100
101
|
|
|
102
|
+
### Docstring conversion
|
|
103
|
+
|
|
104
|
+
Converting doctest examples from docstrings into interactive REPLs is opt-in with `sphinx.ext.autodoc`:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
# conf.py
|
|
108
|
+
extensions = [
|
|
109
|
+
"sphinx.ext.autodoc",
|
|
110
|
+
"sphinx_pyrepl_web",
|
|
111
|
+
]
|
|
112
|
+
pyrepl_doctest_blocks = "autodoc"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| | `pyrepl_doctest_blocks` options |
|
|
116
|
+
|-------------------|-------------------------------------|
|
|
117
|
+
| `False` (default) | Disable autodoc conversion |
|
|
118
|
+
| `"autodoc"` | Convert doctests found by autodoc |
|
|
119
|
+
| `"all"` | Transform every doctest block found |
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
| | `pyrepl_autodoc_bootstrap` options |
|
|
123
|
+
|------------------|------------------------------------------------------------------------------|
|
|
124
|
+
| `True` (default) | Bootstrap REPL: in-tree modules via silent `:src:`, packages via `packages=` |
|
|
125
|
+
| `False` | Replay doctest input only; documented names are not pre-defined |
|
|
126
|
+
|
|
101
127
|
## Updating pyrepl-web
|
|
102
128
|
|
|
103
129
|
Since [chrizzFTD/pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) is a fork, this sphinx extension vendors the JavaScript assets for easier distribution. To update them, run:
|
|
@@ -109,7 +135,7 @@ python scripts/vendor_repl.py
|
|
|
109
135
|
The `grill` branch is used by default. Use the `branch` argument to specify a different one:
|
|
110
136
|
|
|
111
137
|
```bash
|
|
112
|
-
python scripts/vendor_repl.py --branch
|
|
138
|
+
python scripts/vendor_repl.py --branch custom/feature-branch
|
|
113
139
|
```
|
|
114
140
|
|
|
115
141
|
This requires [git](https://git-scm.com/) and [Bun](https://bun.sh/).
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# sphinx-pyrepl-web
|
|
2
|
+
|
|
3
|
+
Sphinx extension to embed [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) in documentation.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install sphinx-pyrepl-web
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For development:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e ".[test,docs]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Add the extension to the target project's `conf.py`:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
extensions = [
|
|
23
|
+
"sphinx_pyrepl_web",
|
|
24
|
+
]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Embed a REPL with the `py-repl` directive:
|
|
28
|
+
|
|
29
|
+
```rst
|
|
30
|
+
.. py-repl::
|
|
31
|
+
|
|
32
|
+
.. py-repl::
|
|
33
|
+
:theme: catppuccin-latte
|
|
34
|
+
:no-header:
|
|
35
|
+
|
|
36
|
+
.. py-repl::
|
|
37
|
+
:src: setup.py
|
|
38
|
+
:packages: numpy
|
|
39
|
+
|
|
40
|
+
.. py-repl::
|
|
41
|
+
:no-header:
|
|
42
|
+
|
|
43
|
+
>>> import math
|
|
44
|
+
>>> math.sqrt(16)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Directive options
|
|
48
|
+
|
|
49
|
+
All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes, with the exception of `silent`:
|
|
50
|
+
|
|
51
|
+
| Option | Description |
|
|
52
|
+
|--------|-------------|
|
|
53
|
+
| `:theme:` | Color theme (`catppuccin-mocha`, `catppuccin-latte`) |
|
|
54
|
+
| `:packages:` | Comma-separated PyPI packages to preload |
|
|
55
|
+
| `:repl-title:` | Title in the REPL header |
|
|
56
|
+
| `:src:` | Path to a Python startup script |
|
|
57
|
+
| `:replay:` | Replay `:src:` with interactive prompts instead of silent load |
|
|
58
|
+
| `:silent:` | Keep `:src:` silent even when combined with a directive body |
|
|
59
|
+
| `:no-header:` | Hide the header bar |
|
|
60
|
+
| `:no-buttons:` | Hide copy/clear buttons |
|
|
61
|
+
| `:readonly:` | Disable input |
|
|
62
|
+
| `:no-banner:` | Hide the Python version banner |
|
|
63
|
+
|
|
64
|
+
Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`.
|
|
65
|
+
|
|
66
|
+
Optional Sphinx config:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
pyrepl_js = "../pyrepl.js" # default; path to the pyrepl-web loader script
|
|
70
|
+
pyrepl_doctest_blocks = False # default; see Docstring conversion below
|
|
71
|
+
pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc REPLs
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Docstring conversion
|
|
75
|
+
|
|
76
|
+
Converting doctest examples from docstrings into interactive REPLs is opt-in with `sphinx.ext.autodoc`:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# conf.py
|
|
80
|
+
extensions = [
|
|
81
|
+
"sphinx.ext.autodoc",
|
|
82
|
+
"sphinx_pyrepl_web",
|
|
83
|
+
]
|
|
84
|
+
pyrepl_doctest_blocks = "autodoc"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| | `pyrepl_doctest_blocks` options |
|
|
88
|
+
|-------------------|-------------------------------------|
|
|
89
|
+
| `False` (default) | Disable autodoc conversion |
|
|
90
|
+
| `"autodoc"` | Convert doctests found by autodoc |
|
|
91
|
+
| `"all"` | Transform every doctest block found |
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
| | `pyrepl_autodoc_bootstrap` options |
|
|
95
|
+
|------------------|------------------------------------------------------------------------------|
|
|
96
|
+
| `True` (default) | Bootstrap REPL: in-tree modules via silent `:src:`, packages via `packages=` |
|
|
97
|
+
| `False` | Replay doctest input only; documented names are not pre-defined |
|
|
98
|
+
|
|
99
|
+
## Updating pyrepl-web
|
|
100
|
+
|
|
101
|
+
Since [chrizzFTD/pyrepl-web](https://github.com/chrizzFTD/pyrepl-web) is a fork, this sphinx extension vendors the JavaScript assets for easier distribution. To update them, run:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python scripts/vendor_repl.py
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The `grill` branch is used by default. Use the `branch` argument to specify a different one:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python scripts/vendor_repl.py --branch custom/feature-branch
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
This requires [git](https://git-scm.com/) and [Bun](https://bun.sh/).
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
from datetime import date
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
2
4
|
|
|
3
5
|
from sphinx_pyrepl_web import __version__
|
|
4
6
|
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).parent / "_static"))
|
|
8
|
+
|
|
5
9
|
project = "sphinx-pyrepl-web"
|
|
6
10
|
version = __version__
|
|
7
11
|
author = "Christian López Barrón"
|
|
@@ -9,8 +13,11 @@ copyright = f"{date.today().year}, {author}"
|
|
|
9
13
|
|
|
10
14
|
extensions = [
|
|
11
15
|
"myst_parser",
|
|
16
|
+
"sphinx.ext.autodoc",
|
|
17
|
+
"sphinx.ext.napoleon",
|
|
12
18
|
"sphinx_pyrepl_web",
|
|
13
19
|
]
|
|
20
|
+
pyrepl_doctest_blocks = "autodoc"
|
|
14
21
|
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
|
15
22
|
|
|
16
23
|
html_sidebars = {
|
|
@@ -31,24 +31,27 @@ Startup script
|
|
|
31
31
|
The ``:src:`` option loads a Python script into the REPL namespace. If the script
|
|
32
32
|
defines a ``setup()`` function, its output is shown when the REPL starts.
|
|
33
33
|
|
|
34
|
+
Startup script:
|
|
35
|
+
|
|
36
|
+
.. literalinclude:: _static/setup.py
|
|
37
|
+
:language: python
|
|
38
|
+
|
|
39
|
+
RST content:
|
|
40
|
+
|
|
34
41
|
.. code-block:: rst
|
|
35
42
|
|
|
36
43
|
.. py-repl::
|
|
37
44
|
:src: _static/setup.py
|
|
38
45
|
|
|
46
|
+
Rendered result:
|
|
47
|
+
|
|
39
48
|
.. py-repl::
|
|
40
49
|
:src: _static/setup.py
|
|
41
50
|
|
|
42
|
-
The startup script:
|
|
43
|
-
|
|
44
|
-
.. literalinclude:: _static/setup.py
|
|
45
|
-
:language: python
|
|
46
|
-
|
|
47
51
|
Replay session
|
|
48
52
|
--------------
|
|
49
53
|
|
|
50
|
-
Inline directive content
|
|
51
|
-
and live output. Doctest-style ``>>>`` prefixes are stripped automatically.
|
|
54
|
+
Inline directive content should follow Doctest-style (``>>>`` / ``...``) and is used as replay prompts.
|
|
52
55
|
|
|
53
56
|
.. code-block:: rst
|
|
54
57
|
|
|
@@ -59,6 +62,10 @@ and live output. Doctest-style ``>>>`` prefixes are stripped automatically.
|
|
|
59
62
|
>>> x = 2 + 2
|
|
60
63
|
>>> print(f"{x=}")
|
|
61
64
|
>>> x * 10
|
|
65
|
+
>>> class Foo:
|
|
66
|
+
... x = 1
|
|
67
|
+
...
|
|
68
|
+
>>> Foo()
|
|
62
69
|
|
|
63
70
|
.. py-repl::
|
|
64
71
|
:no-header:
|
|
@@ -67,6 +74,10 @@ and live output. Doctest-style ``>>>`` prefixes are stripped automatically.
|
|
|
67
74
|
>>> x = 2 + 2
|
|
68
75
|
>>> print(f"{x=}")
|
|
69
76
|
>>> x * 10
|
|
77
|
+
>>> class Foo:
|
|
78
|
+
... x = 1
|
|
79
|
+
...
|
|
80
|
+
>>> Foo()
|
|
70
81
|
|
|
71
82
|
Combine a silent bootstrap file with a visible replay body:
|
|
72
83
|
|
|
@@ -84,7 +95,14 @@ Combine a silent bootstrap file with a visible replay body:
|
|
|
84
95
|
|
|
85
96
|
>>> print(message)
|
|
86
97
|
|
|
87
|
-
Use ``:replay:`` on ``:src:`` to
|
|
98
|
+
Use ``:replay:`` on ``:src:`` to source a file as replay.
|
|
99
|
+
|
|
100
|
+
Source script:
|
|
101
|
+
|
|
102
|
+
.. literalinclude:: _static/replay_demo.py
|
|
103
|
+
:language: python
|
|
104
|
+
|
|
105
|
+
RST content:
|
|
88
106
|
|
|
89
107
|
.. code-block:: rst
|
|
90
108
|
|
|
@@ -94,13 +112,32 @@ Use ``:replay:`` on ``:src:`` to replay a file with prompts instead of silent lo
|
|
|
94
112
|
:no-header:
|
|
95
113
|
:no-banner:
|
|
96
114
|
|
|
115
|
+
Rendered result:
|
|
116
|
+
|
|
97
117
|
.. py-repl::
|
|
98
118
|
:src: _static/replay_demo.py
|
|
99
119
|
:replay:
|
|
100
120
|
:no-header:
|
|
101
121
|
:no-banner:
|
|
102
122
|
|
|
103
|
-
|
|
123
|
+
Autodoc
|
|
124
|
+
-------
|
|
104
125
|
|
|
105
|
-
|
|
126
|
+
The documented module's source is loaded in advance before replay, so
|
|
127
|
+
module members are available in the REPL namespace. Modules under the Sphinx
|
|
128
|
+
source tree use silent ``:src:``; installed packages use ``packages=``.
|
|
129
|
+
|
|
130
|
+
Source module:
|
|
131
|
+
|
|
132
|
+
.. literalinclude:: _static/autodoc_demo.py
|
|
106
133
|
:language: python
|
|
134
|
+
|
|
135
|
+
RST content:
|
|
136
|
+
|
|
137
|
+
.. code-block:: rst
|
|
138
|
+
|
|
139
|
+
.. autofunction:: autodoc_demo.example_generator
|
|
140
|
+
|
|
141
|
+
Rendered result:
|
|
142
|
+
|
|
143
|
+
.. autofunction:: autodoc_demo.example_generator
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""A Sphinx extension for embedding pyrepl-web Python REPLs in documentation."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
from doctest import DocTestParser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from docutils import nodes
|
|
13
|
+
from docutils.parsers.rst import directives
|
|
14
|
+
from sphinx import addnodes
|
|
15
|
+
from sphinx.application import Sphinx
|
|
16
|
+
from sphinx.util import logging
|
|
17
|
+
from sphinx.util.docutils import SphinxDirective
|
|
18
|
+
from sphinx.util.fileutil import copy_asset_file
|
|
19
|
+
|
|
20
|
+
PYREPL_DIR = Path(__file__).parent / "pyrepl"
|
|
21
|
+
STARTUP_FILES_KEY = "pyrepl-startup-files"
|
|
22
|
+
REPLAY_FILES_KEY = "pyrepl-replay-files"
|
|
23
|
+
_DOCTEST_PARSER = DocTestParser()
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def setup(app: Sphinx):
|
|
28
|
+
"""Setup the extension."""
|
|
29
|
+
app.add_config_value("pyrepl_js", "../pyrepl.js", "env")
|
|
30
|
+
app.add_config_value("pyrepl_doctest_blocks", False, "env")
|
|
31
|
+
app.add_config_value("pyrepl_autodoc_bootstrap", True, "env")
|
|
32
|
+
app.add_directive("py-repl", PyRepl)
|
|
33
|
+
app.connect("doctree-read", doctree_read)
|
|
34
|
+
app.connect("doctree-read", transform_doctest_blocks)
|
|
35
|
+
app.connect("html-page-context", add_html_context)
|
|
36
|
+
app.connect("env-updated", copy_asset_files)
|
|
37
|
+
return {"version": __version__, "parallel_read_safe": True}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def doctest_to_replay_source(text_or_lines: str | list[str]) -> str:
|
|
41
|
+
"""Convert doctest-formatted text into executable replay script source."""
|
|
42
|
+
text = (
|
|
43
|
+
"\n".join(text_or_lines)
|
|
44
|
+
if isinstance(text_or_lines, list)
|
|
45
|
+
else text_or_lines
|
|
46
|
+
)
|
|
47
|
+
examples = _DOCTEST_PARSER.get_examples(text)
|
|
48
|
+
if not examples:
|
|
49
|
+
return ""
|
|
50
|
+
parts = [example.source.rstrip("\n") for example in examples]
|
|
51
|
+
return "\n\n".join(parts) + "\n"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _next_replay_counter(replay_files: dict[str, str]) -> int:
|
|
55
|
+
return len(replay_files) + 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def register_autodoc_repl(
|
|
59
|
+
env,
|
|
60
|
+
docname: str,
|
|
61
|
+
replay_text: str,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Record a replay script in env metadata and return its replay-src path."""
|
|
64
|
+
replay_files = json.loads(
|
|
65
|
+
env.metadata[docname].setdefault(REPLAY_FILES_KEY, "{}")
|
|
66
|
+
)
|
|
67
|
+
counter = _next_replay_counter(replay_files)
|
|
68
|
+
replay_name = f"{docname.replace('/', '-')}-{counter}.py"
|
|
69
|
+
replay_files[replay_name] = replay_text
|
|
70
|
+
env.metadata[docname][REPLAY_FILES_KEY] = json.dumps(replay_files)
|
|
71
|
+
return f"_static/pyrepl/{replay_name}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def register_startup_file(env, docname: str, path: Path) -> str:
|
|
75
|
+
"""Track a startup script under srcdir for copying into HTML output."""
|
|
76
|
+
env.note_dependency(path)
|
|
77
|
+
rel_src = path.relative_to(Path(env.srcdir)).as_posix()
|
|
78
|
+
startup_files = json.loads(
|
|
79
|
+
env.metadata[docname].setdefault(STARTUP_FILES_KEY, "[]")
|
|
80
|
+
)
|
|
81
|
+
abs_path = str(path.resolve())
|
|
82
|
+
if abs_path not in startup_files:
|
|
83
|
+
startup_files.append(abs_path)
|
|
84
|
+
env.metadata[docname][STARTUP_FILES_KEY] = json.dumps(startup_files)
|
|
85
|
+
return rel_src
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def make_pyrepl_raw(
|
|
89
|
+
replay_src: str,
|
|
90
|
+
src: str | None = None,
|
|
91
|
+
packages: str | None = None,
|
|
92
|
+
) -> nodes.raw:
|
|
93
|
+
"""Build a raw HTML node for an autodoc doctest replay widget."""
|
|
94
|
+
attrs = ["no-header", "no-banner", f'replay-src="{replay_src}"']
|
|
95
|
+
if packages:
|
|
96
|
+
attrs.insert(0, f'packages="{packages}"')
|
|
97
|
+
if src:
|
|
98
|
+
attrs.insert(0, f'src="{src}"')
|
|
99
|
+
attr_str = " ".join(attrs)
|
|
100
|
+
return nodes.raw("", f"<py-repl {attr_str}></py-repl>\n", format="html")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _find_autodoc_desc(node: nodes.Node) -> addnodes.desc | None:
|
|
104
|
+
"""Return the enclosing autodoc desc node, if any."""
|
|
105
|
+
current = node.parent
|
|
106
|
+
while current is not None:
|
|
107
|
+
if isinstance(current, addnodes.desc):
|
|
108
|
+
return current
|
|
109
|
+
current = current.parent
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_autodoc_bootstrap(
|
|
114
|
+
app: Sphinx, env, docname: str, desc: addnodes.desc
|
|
115
|
+
) -> tuple[str | None, str | None]:
|
|
116
|
+
"""Return (startup src path, packages) for autodoc REPLs."""
|
|
117
|
+
if not app.config.pyrepl_autodoc_bootstrap:
|
|
118
|
+
return None, None
|
|
119
|
+
|
|
120
|
+
sig = desc.next_node(addnodes.desc_signature)
|
|
121
|
+
if sig is None:
|
|
122
|
+
return None, None
|
|
123
|
+
|
|
124
|
+
module_name = sig.get("module")
|
|
125
|
+
fullname = sig.get("fullname")
|
|
126
|
+
if not module_name:
|
|
127
|
+
return None, None
|
|
128
|
+
|
|
129
|
+
target = f"{module_name}.{fullname}" if fullname else module_name
|
|
130
|
+
try:
|
|
131
|
+
mod = sys.modules.get(module_name)
|
|
132
|
+
if mod is None:
|
|
133
|
+
mod = importlib.import_module(module_name)
|
|
134
|
+
obj = mod
|
|
135
|
+
if fullname:
|
|
136
|
+
for part in fullname.split("."):
|
|
137
|
+
obj = getattr(obj, part)
|
|
138
|
+
mod_obj = inspect.getmodule(obj) or mod
|
|
139
|
+
source_path = Path(inspect.getfile(mod_obj)).resolve()
|
|
140
|
+
srcdir = Path(env.srcdir).resolve()
|
|
141
|
+
try:
|
|
142
|
+
source_path.relative_to(srcdir)
|
|
143
|
+
return register_startup_file(env, docname, source_path), None
|
|
144
|
+
except ValueError:
|
|
145
|
+
return None, module_name.split(".")[0]
|
|
146
|
+
except (AttributeError, ImportError, OSError, TypeError) as exc:
|
|
147
|
+
logger.error(
|
|
148
|
+
"Could not bootstrap autodoc REPL for %s: %s",
|
|
149
|
+
target,
|
|
150
|
+
exc,
|
|
151
|
+
)
|
|
152
|
+
return None, None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _inside_autodoc_desc(node: nodes.Node) -> bool:
|
|
156
|
+
"""Return True if *node* is nested inside an autodoc desc entry."""
|
|
157
|
+
return _find_autodoc_desc(node) is not None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def transform_doctest_blocks(app: Sphinx, doctree: nodes.document):
|
|
161
|
+
"""Replace doctest blocks with interactive py-repl widgets."""
|
|
162
|
+
scope = app.config.pyrepl_doctest_blocks
|
|
163
|
+
if not scope:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
env = app.env
|
|
167
|
+
docname = env.docname
|
|
168
|
+
replaced = False
|
|
169
|
+
for node in doctree.findall(nodes.doctest_block):
|
|
170
|
+
if scope == "autodoc" and not _inside_autodoc_desc(node):
|
|
171
|
+
continue
|
|
172
|
+
source = doctest_to_replay_source(node.astext())
|
|
173
|
+
if not source.strip():
|
|
174
|
+
continue
|
|
175
|
+
bootstrap_src = None
|
|
176
|
+
packages = None
|
|
177
|
+
desc = _find_autodoc_desc(node)
|
|
178
|
+
if desc is not None:
|
|
179
|
+
bootstrap_src, packages = _resolve_autodoc_bootstrap(
|
|
180
|
+
app, env, docname, desc
|
|
181
|
+
)
|
|
182
|
+
replay_src = register_autodoc_repl(env, docname, source)
|
|
183
|
+
node.replace_self(make_pyrepl_raw(replay_src, bootstrap_src, packages))
|
|
184
|
+
replaced = True
|
|
185
|
+
|
|
186
|
+
if replaced:
|
|
187
|
+
env.metadata[docname]["pyrepl"] = True
|
|
188
|
+
doctree["pyrepl"] = True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class PyRepl(SphinxDirective):
|
|
192
|
+
"""Embed a pyrepl-web ``<py-repl>`` element."""
|
|
193
|
+
|
|
194
|
+
has_content = True
|
|
195
|
+
option_spec = {
|
|
196
|
+
"theme": directives.unchanged,
|
|
197
|
+
"packages": directives.unchanged,
|
|
198
|
+
"repl-title": directives.unchanged,
|
|
199
|
+
"src": directives.path,
|
|
200
|
+
"no-header": directives.flag,
|
|
201
|
+
"no-buttons": directives.flag,
|
|
202
|
+
"readonly": directives.flag,
|
|
203
|
+
"no-banner": directives.flag,
|
|
204
|
+
"replay": directives.flag,
|
|
205
|
+
"silent": directives.flag,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def run(self):
|
|
209
|
+
env = self.env
|
|
210
|
+
attrs: list[str] = []
|
|
211
|
+
|
|
212
|
+
for option, attr in (
|
|
213
|
+
("theme", "theme"),
|
|
214
|
+
("packages", "packages"),
|
|
215
|
+
("repl-title", "repl-title"),
|
|
216
|
+
):
|
|
217
|
+
if option in self.options:
|
|
218
|
+
value = self.options[option]
|
|
219
|
+
attrs.append(f'{attr}="{value}"')
|
|
220
|
+
|
|
221
|
+
for flag in ("no-header", "no-buttons", "readonly", "no-banner"):
|
|
222
|
+
if flag in self.options:
|
|
223
|
+
attrs.append(flag)
|
|
224
|
+
|
|
225
|
+
has_body = bool(self.content)
|
|
226
|
+
force_replay = "replay" in self.options
|
|
227
|
+
force_silent = "silent" in self.options
|
|
228
|
+
|
|
229
|
+
if "src" in self.options:
|
|
230
|
+
_, abs_path = self.env.relfn2path(self.options["src"])
|
|
231
|
+
path = Path(abs_path)
|
|
232
|
+
try:
|
|
233
|
+
path.read_text(encoding="utf-8")
|
|
234
|
+
except OSError as exc:
|
|
235
|
+
raise self.error(f"Could not read file: {exc}") from exc
|
|
236
|
+
self.env.note_dependency(path)
|
|
237
|
+
rel_src = path.relative_to(Path(self.env.srcdir)).as_posix()
|
|
238
|
+
startup_files = json.loads(
|
|
239
|
+
self.env.metadata[self.env.docname].setdefault(
|
|
240
|
+
STARTUP_FILES_KEY, "[]"
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
startup_files.append(str(path))
|
|
244
|
+
self.env.metadata[self.env.docname][STARTUP_FILES_KEY] = json.dumps(
|
|
245
|
+
startup_files
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if force_replay and not has_body:
|
|
249
|
+
attrs.append(f'src="{rel_src}"')
|
|
250
|
+
attrs.append("replay")
|
|
251
|
+
elif not (force_silent and not has_body):
|
|
252
|
+
attrs.append(f'src="{rel_src}"')
|
|
253
|
+
|
|
254
|
+
if has_body:
|
|
255
|
+
body_text = doctest_to_replay_source(list(self.content))
|
|
256
|
+
replay_src = register_autodoc_repl(env, env.docname, body_text)
|
|
257
|
+
attrs.append(f'replay-src="{replay_src}"')
|
|
258
|
+
|
|
259
|
+
self.env.metadata[self.env.docname]["pyrepl"] = True
|
|
260
|
+
attr_str = (" " + " ".join(attrs)) if attrs else ""
|
|
261
|
+
return [nodes.raw("", f"<py-repl{attr_str}></py-repl>\n", format="html")]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def doctree_read(app: Sphinx, doctree: nodes.document):
|
|
265
|
+
"""Mark pages that use the py-repl directive."""
|
|
266
|
+
if app.env.metadata[app.env.docname].get("pyrepl"):
|
|
267
|
+
doctree["pyrepl"] = True
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def add_html_context(
|
|
271
|
+
app: Sphinx, pagename: str, templatename: str, context, doctree: nodes.document
|
|
272
|
+
):
|
|
273
|
+
"""Load pyrepl-web JavaScript on pages that contain a REPL."""
|
|
274
|
+
if doctree and "pyrepl" in doctree:
|
|
275
|
+
app.add_js_file(app.config.pyrepl_js)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def copy_asset_files(app, _):
|
|
279
|
+
"""Copy vendored pyrepl assets and startup scripts into HTML output."""
|
|
280
|
+
if app.builder.format != "html":
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
outdir = Path(app.builder.outdir)
|
|
284
|
+
if PYREPL_DIR.is_dir():
|
|
285
|
+
for asset in PYREPL_DIR.iterdir():
|
|
286
|
+
if asset.is_file():
|
|
287
|
+
copy_asset_file(str(asset.resolve()), str(outdir.resolve()))
|
|
288
|
+
|
|
289
|
+
replay_dest = outdir / "_static" / "pyrepl"
|
|
290
|
+
for docname, metadata in app.env.metadata.items():
|
|
291
|
+
raw = metadata.get(REPLAY_FILES_KEY)
|
|
292
|
+
if not raw:
|
|
293
|
+
continue
|
|
294
|
+
replay_files = json.loads(raw)
|
|
295
|
+
if not replay_files:
|
|
296
|
+
continue
|
|
297
|
+
replay_dest.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
for name, content in replay_files.items():
|
|
299
|
+
(replay_dest / name).write_text(content, encoding="utf-8")
|
|
300
|
+
|
|
301
|
+
srcdir = Path(app.builder.srcdir)
|
|
302
|
+
copied = set()
|
|
303
|
+
for docname, metadata in app.env.metadata.items():
|
|
304
|
+
raw = metadata.get(STARTUP_FILES_KEY)
|
|
305
|
+
if not raw:
|
|
306
|
+
continue
|
|
307
|
+
for abs_path in json.loads(raw):
|
|
308
|
+
path = Path(abs_path)
|
|
309
|
+
key = str(path.resolve())
|
|
310
|
+
if key in copied:
|
|
311
|
+
continue
|
|
312
|
+
copied.add(key)
|
|
313
|
+
rel = path.relative_to(srcdir)
|
|
314
|
+
dest = outdir / rel
|
|
315
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
copy_asset_file(str(path.resolve()), str(dest.resolve()))
|