Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 35 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,9 @@ Sphinx extension to embed [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)
pip install sphinx-pyrepl-web
```

For development:

```bash
pip install -e ".[test,docs]"
```

## Usage

Add the extension to the target project's `conf.py`:
Add the extension to the target project's `conf.py` module:

```python
extensions = [
Expand Down Expand Up @@ -46,68 +40,53 @@ Embed a REPL with the `py-repl` directive:

### Directive options

All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes, with the exception of `silent`:
All options drive [pyrepl-web](https://github.com/chrizzFTD/pyrepl-web)'s attributes:

| Option | Description |
|--------|-------------|
| `:theme:` | Color theme (`catppuccin-mocha`, `catppuccin-latte`) |
| `:packages:` | Comma-separated PyPI packages to preload |
| `:repl-title:` | Title in the REPL header |
| `:src:` | Path to a Python startup script |
| Option | Description |
|--------|----------------------------------------------------------------|
| `:theme:` | Color theme (`catppuccin-mocha`, `catppuccin-latte`) |
| `:packages:` | Comma-separated PyPI packages, URLs or relative wheel paths |
| `:repl-title:` | Title in the REPL header |
| `:src:` | Path to a Python startup script |
| `:replay:` | Replay `:src:` with interactive prompts instead of silent load |
| `:silent:` | Keep `:src:` silent even when combined with a directive body |
| `:no-header:` | Hide the header bar |
| `:no-buttons:` | Hide copy/clear buttons |
| `:readonly:` | Disable input |
| `:no-banner:` | Hide the Python version banner |
| `:no-header:` | Hide the header bar |
| `:no-buttons:` | Hide copy/clear buttons |
| `:readonly:` | Disable input |
| `:no-banner:` | Hide the Python version banner |

Python code within the `.. py-repl::` directive is written to `_static/pyrepl/` at build time and emitted as `replay-src`.
### Sphinx options

Optional Sphinx config:
Enable [doctest style examples](https://docs.python.org/3/library/doctest.html) conversion into pre-configured interactive REPLs with:

```python
pyrepl_js = "../pyrepl.js" # default; path to the pyrepl-web loader script
pyrepl_doctest_blocks = False # default; see Docstring conversion below
pyrepl_autodoc_bootstrap = True # default; silent :src: bootstrap for autodoc REPLs
```
`pyrepl_doctest_blocks`:

### Docstring conversion
| Value | Outcome |
|-------------------|---------------------------------------|
| `False` (default) | Don't convert doctest blocks |
| `"autodoc"` | Convert doctest blocks from `autodoc` |
| `"all"` | Convert all doctest blocks |

Converting doctest examples from docstrings into interactive REPLs is opt-in with `sphinx.ext.autodoc`:
`pyrepl_autodoc_packages`:

| Value | Outcome |
|-------------------------|--------------------------------------------------------------------|
| `None` (default) | Replay doctest input without preloading packages |
| Wheel path / PyPI names | Install the package and import the documented object before replay |

### Local wheels

Unreleased package wheels can be available in the REPL by building them under Sphinx's `html_static_path`.

All options combined:

```python
# conf.py
extensions = [
"sphinx.ext.autodoc",
"sphinx_pyrepl_web",
]
pyrepl_doctest_blocks = "autodoc"
```

| | `pyrepl_doctest_blocks` options |
|-------------------|-------------------------------------|
| `False` (default) | Disable autodoc conversion |
| `"autodoc"` | Convert doctests found by autodoc |
| `"all"` | Transform every doctest block found |
html_static_path = ["_static"]


| | `pyrepl_autodoc_bootstrap` options |
|------------------|------------------------------------------------------------------------------|
| `True` (default) | Bootstrap REPL: in-tree modules via silent `:src:`, packages via `packages=` |
| `False` | Replay doctest input only; documented names are not pre-defined |

## Updating pyrepl-web

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:

```bash
python scripts/vendor_repl.py
```

The `grill` branch is used by default. Use the `branch` argument to specify a different one:

```bash
python scripts/vendor_repl.py --branch custom/feature-branch
pyrepl_doctest_blocks = "autodoc"
pyrepl_autodoc_packages = "_static/wheels/my_package-1.0.0-py3-none-any.whl"
```

This requires [git](https://git-scm.com/) and [Bun](https://bun.sh/).
Binary file not shown.
8 changes: 3 additions & 5 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
from datetime import date
import sys
from pathlib import Path

from sphinx_pyrepl_web import __version__

sys.path.insert(0, str(Path(__file__).parent / "_static"))

project = "sphinx-pyrepl-web"
version = __version__
author = "Christian López Barrón"
Expand All @@ -18,7 +14,9 @@
"sphinx_pyrepl_web",
]
pyrepl_doctest_blocks = "autodoc"
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
pyrepl_autodoc_packages = "_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl"
html_static_path = ["_static"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "example.rst"]

html_sidebars = {
"**": [
Expand Down
85 changes: 85 additions & 0 deletions docs/development.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
Development
===========

Setup
-----

Clone the repository, then install in editable mode with test and docs dependencies:

.. code-block:: bash

pip install -e ".[test,docs]"

The ``[docs]`` extra pulls in doc build dependencies and the ``pyrepl_test_pkg``
fixture used in the examples.

Build and preview docs
----------------------

Build HTML output from the ``docs/`` directory:

.. code-block:: bash

python -m sphinx -b html docs docs/_build

REPLs load JavaScript, replay scripts, and wheels with ``fetch``, so they do not
work when opening ``index.html`` directly from disk (``file://`` URLs). Serve the
build output over HTTP instead:

.. code-block:: bash

python -m http.server --directory docs/_build

Then open http://localhost:8000/ in a browser.

Build-time behavior
-------------------

Python code within a ``.. py-repl::`` directive is written to ``_static/pyrepl/``
at build time and emitted as ``replay-src``.

File paths identifiers in ``:packages:``, ``:src:``, ``replay-src``, and
``pyrepl_autodoc_packages`` are rewritten to page-relative URLs so REPLs work on
nested pages (for example ``docs/api/...``). PyPI package names, absolute URLs,
and paths written as root-absolute (``/_static/...``) are left unchanged.

``pyrepl_js`` (default: ``"../pyrepl.js"``) sets the loader script Sphinx injects
on REPL pages. The extension vendors and copies `pyrepl-web <https://github.com/chrizzFTD/pyrepl-web>`_ automatically;
override this only when pointing at a custom loader path or CDN.

Static wheels
-------------

Wheel packages must be `Pyodide <https://pyodide.org/en/stable/>`_ compatible (pure-python packages work out of the box).
For ``CPython`` extensions, visit `pyodide-build <https://pyodide-build.readthedocs.io/en/latest/>`_.

Wheels under ``_static/`` are copied into the HTML output when ``_static`` is
listed in ``html_static_path``. At runtime, `pyrepl-web <https://github.com/chrizzFTD/pyrepl-web>`_
resolves site-relative wheel paths to absolute URLs before calling
``micropip.install()``.

Paths must use the wheel's actual PyPI-compliant filename (for example
``myext-1.2.3-py3-none-any.whl``). ``micropip`` rejects other names.

Ensure the web server serves ``.whl`` files with MIME type ``application/zip``
(Read the Docs does this by default).

Updating pyrepl-web
-------------------

This extension vendors JavaScript from
`pyrepl-web <https://github.com/chrizzFTD/pyrepl-web>`_'s fork for easier
distribution. To refresh the vendored assets:

.. code-block:: bash

python scripts/vendor_repl.py

The ``grill`` branch is used by default. Pass ``--branch`` to vendor from another
branch:

.. code-block:: bash

python scripts/vendor_repl.py --branch custom/feature-branch

This requires `git <https://git-scm.com/>`_ and `Bun <https://bun.sh/>`_.
47 changes: 39 additions & 8 deletions docs/example.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Rendered result:
Replay session
--------------

Inline directive content should follow Doctest-style (``>>>`` / ``...``) and is used as replay prompts.
Inline content should follow `doctest-style <https://docs.python.org/3/library/doctest.html>`_ (``>>>`` / ``...``) and is used as replay prompts.

.. code-block:: rst

Expand Down Expand Up @@ -79,7 +79,7 @@ Inline directive content should follow Doctest-style (``>>>`` / ``...``) and is
...
>>> Foo()

Combine a silent bootstrap file with a visible replay body:
Combine a startup script with a visible replay body:

.. code-block:: rst

Expand Down Expand Up @@ -120,24 +120,55 @@ Rendered result:
:no-header:
:no-banner:

Local packages
--------------

Use static local wheel packages on ``.. py-repl::`` directives:

.. code-block:: rst

.. py-repl::
:packages: _static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl
:no-header:
:no-banner:

>>> import pyrepl_test_pkg
>>> pyrepl_test_pkg.ping()

.. py-repl::
:packages: _static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl
:no-header:
:no-banner:

>>> import pyrepl_test_pkg
>>> pyrepl_test_pkg.ping()


Autodoc
-------

The documented module's source is loaded in advance before replay, so
module members are available in the REPL namespace. Modules under the Sphinx
source tree use silent ``:src:``; installed packages use ``packages=``.
Use ``pyrepl_doctest_blocks = "autodoc"`` to turn docstrings from ``autodoc`` into interactive REPL examples.

Set ``pyrepl_autodoc_packages`` to install the documented package and automatically import the documented object before replay:

.. code-block:: python

# conf.py
html_static_path = ["_static"]
pyrepl_doctest_blocks = "autodoc"
pyrepl_autodoc_packages = "_static/wheels/pyrepl_test_pkg-1.0.0-py3-none-any.whl"

Source module:

.. literalinclude:: _static/autodoc_demo.py
.. literalinclude:: ../tests/fixtures/pyrepl_test_pkg/pyrepl_test_pkg/demo.py
:language: python

RST content:

.. code-block:: rst

.. autofunction:: autodoc_demo.example_generator
.. autofunction:: pyrepl_test_pkg.demo.example_generator

Rendered result:

.. autofunction:: autodoc_demo.example_generator
.. autofunction:: pyrepl_test_pkg.demo.example_generator
2 changes: 2 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@

.. include:: example.rst

.. include:: development.rst

.. toctree::
:maxdepth: 2
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ test = [
]
docs = [
"myst-parser",
"pyrepl_test_pkg @ file:./tests/fixtures/pyrepl_test_pkg",
]

[tool.pytest.ini_options]
Expand Down
59 changes: 59 additions & 0 deletions scripts/build_test_pkg_wheel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Build pyrepl_test_pkg wheel and copy it into docs and test fixture paths."""

from __future__ import annotations

import argparse
import shutil
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
PKG_DIR = ROOT / "tests" / "fixtures" / "pyrepl_test_pkg"
WHEEL_NAME = "pyrepl_test_pkg-1.0.0-py3-none-any.whl"
DEST_DIRS = (
ROOT / "tests" / "fixtures" / "wheels",
ROOT / "docs" / "_static" / "wheels",
)


def run(cmd: list[str], *, cwd: Path) -> None:
print(f"+ {' '.join(cmd)}")
subprocess.run(cmd, cwd=cwd, check=True)


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--keep-dist",
action="store_true",
help="Leave the build dist/ directory in the fixture package tree",
)
args = parser.parse_args()

dist_dir = PKG_DIR / "dist"
if dist_dir.exists():
shutil.rmtree(dist_dir)

run([sys.executable, "-m", "pip", "wheel", ".", "-w", "dist"], cwd=PKG_DIR)

built = dist_dir / WHEEL_NAME
if not built.is_file():
wheels = sorted(dist_dir.glob("*.whl"))
if len(wheels) != 1:
sys.exit(f"expected one wheel in {dist_dir}, found: {wheels!r}")
built = wheels[0]

for dest_dir in DEST_DIRS:
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / WHEEL_NAME
shutil.copy2(built, dest)
print(f"copied {built.name} -> {dest}")

if not args.keep_dist:
shutil.rmtree(dist_dir)


if __name__ == "__main__":
main()
Loading
Loading