docs(v2.0.0-alpha.3): inline plot injection across the entire doc tree

Add scripts/inject_doc_plots.py that scans every .md and .rst page under
docs/source/, executes each Python code-block in an isolated namespace
with a non-interactive matplotlib backend, captures every figure
produced, and inserts an inline image directive immediately after the
code-block.  Markers AUTO-PLOT-BEGIN/END make the injection idempotent
on re-runs.  Blocks that fail to execute or produce no figure are left
untouched.

Add a transparent __getattr__ fallback in python/optimizr/__init__.py
that forwards any unresolved top-level attribute to the compiled _core
extension.  This lets all v1.x and v2.0 doc samples that use
'from optimizr import X' (estimate_ou_params_py, linear_bsde_constant_coeffs,
mmd_gaussian, ...) execute as written.

Augment the OU Parameter Estimation example
(docs/source/algorithms/optimal_control.md) with a two-panel
visualization (simulated path plus empirical/theoretical autocorrelation).

Net effect: 14 doc pages now display matplotlib plots inline directly
under the code that produced them -- including the OU page, point
processes, Grid Search, HMM, MCMC, plus the 8 v2.0 RST pages.
This commit is contained in:
ThotDjehuty
2026-05-12 13:05:14 +02:00
parent d8682f61e5
commit cce31055c1
43 changed files with 537 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+20
View File
@@ -40,6 +40,16 @@ Generic interacting-agent simulator (`consensus_dynamics`) — linear bounded-co
ax.set_title('Bounded-confidence consensus, α = 0.3')
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__agent_based/block_03_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/agent_based/plot_01.png
:align: center
:width: 80%
@@ -56,6 +66,16 @@ Generic interacting-agent simulator (`consensus_dynamics`) — linear bounded-co
ax.set_title('Convergence rate vs averaging weight α'); ax.legend(); ax.grid(alpha=0.3)
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__agent_based/block_04_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/agent_based/plot_02.png
:align: center
:width: 80%
+20
View File
@@ -48,6 +48,16 @@ With $a(t) \equiv -\rho$, $b = c = 0$ and $Y_T = 1$ the analytic deterministic s
ax.legend(); ax.grid(alpha=0.3)
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__bsde/block_03_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/bsde/plot_01.png
:align: center
:width: 80%
@@ -76,6 +86,16 @@ CrankNicolson is second-order in `Δt`.
ax.set_title('CrankNicolson convergence'); ax.grid(which='both', alpha=0.3); ax.legend()
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__bsde/block_05_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/bsde/plot_02.png
:align: center
:width: 80%
@@ -32,6 +32,16 @@ Maximum-Mean-Discrepancy distance with Gaussian kernel (`mmd_gaussian`). Self-d
ax.set_title('Gaussian-kernel MMD vs translation (σ = 1)')
ax.grid(alpha=0.3); fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__generative_calibration_hooks/block_03_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/generative_calibration_hooks/plot_01.png
:align: center
:width: 80%
@@ -49,6 +59,16 @@ Bandwidth dependence
ax.set_title('MMD as a function of kernel bandwidth')
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__generative_calibration_hooks/block_04_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/generative_calibration_hooks/plot_02.png
:align: center
:width: 80%
+7
View File
@@ -305,6 +305,13 @@ plt.ylabel('Learning Rate')
plt.title('Grid Search: Loss Surface')
plt.savefig('grid_search_heatmap.png', dpi=150)
```
<!-- AUTO-PLOT-BEGIN -->
![Generated plot](../_static/auto/algorithms__grid_search/block_08_fig_01.png)
<!-- AUTO-PLOT-END -->
**Output:**
+7
View File
@@ -447,6 +447,13 @@ axes[1].set_yticklabels(['Bull', 'Bear'])
plt.tight_layout()
plt.savefig('hmm_states.png', dpi=150)
```
<!-- AUTO-PLOT-BEGIN -->
![Generated plot](../_static/auto/algorithms__hmm/block_05_fig_01.png)
<!-- AUTO-PLOT-END -->
**Output:**
+20
View File
@@ -41,6 +41,16 @@ Interacting-particle Euler scheme for $dX_t = θ(\bar X_t - X_t) dt + σ dW_t$ (
ax.set_title('Mean-reverting McKeanVlasov — 200 particles')
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__mckean_vlasov/block_03_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/mckean_vlasov/plot_01.png
:align: center
:width: 80%
@@ -54,6 +64,16 @@ Interacting-particle Euler scheme for $dX_t = θ(\bar X_t - X_t) dt + σ dW_t$ (
ax.set_title('Marginal density at t = 0 and t = T')
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__mckean_vlasov/block_04_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/mckean_vlasov/plot_02.png
:align: center
:width: 80%
+7
View File
@@ -295,6 +295,13 @@ axes[1, 1].set_title('Posterior: σ')
plt.tight_layout()
plt.savefig('mcmc_posterior.png', dpi=150)
```
<!-- AUTO-PLOT-BEGIN -->
![Generated plot](../_static/auto/algorithms__mcmc/block_03_fig_01.png)
<!-- AUTO-PLOT-END -->
---
+36 -1
View File
@@ -261,15 +261,17 @@ Estimates Ornstein-Uhlenbeck process parameters from time series data.
```python
from optimizr import estimate_ou_params_py
import numpy as np
import matplotlib.pyplot as plt
# Simulate OU process (for testing)
dt = 1/252 # Daily data
T = 1000
kappa_true, theta_true, sigma_true = 3.0, 0.0, 0.2
rng = np.random.default_rng(0)
spread = [0.0]
for _ in range(T-1):
dx = kappa_true * (theta_true - spread[-1]) * dt + \
sigma_true * np.sqrt(dt) * np.random.randn()
sigma_true * np.sqrt(dt) * rng.standard_normal()
spread.append(spread[-1] + dx)
spread = np.array(spread)
@@ -280,7 +282,36 @@ kappa, theta, sigma, half_life = estimate_ou_params_py(spread, dt=dt)
print(f"True: κ={kappa_true:.2f}, θ={theta_true:.3f}, σ={sigma_true:.3f}")
print(f"Estimated: κ={kappa:.2f}, θ={theta:.3f}, σ={sigma:.3f}")
print(f"Half-life: {half_life:.1f} periods ({half_life*252:.1f} days)")
# Visualise the simulated path together with the estimated mean-reversion
# level and the decay envelope implied by the fitted half-life.
t_axis = np.arange(len(spread)) * dt * 252 # in days
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].plot(t_axis, spread, lw=0.7, label="simulated path")
axes[0].axhline(theta_true, color="k", ls=":", label="true θ")
axes[0].axhline(theta, color="red", ls="--", label="estimated θ")
axes[0].set_xlabel("days"); axes[0].set_ylabel("spread")
axes[0].set_title("OU simulation vs estimated long-run mean")
axes[0].legend(); axes[0].grid(alpha=0.3)
# Empirical autocorrelation vs theoretical exp(-κ τ).
lags = np.arange(0, 60)
x = spread - spread.mean()
acf = np.array([
(x[: len(x) - k] @ x[k:]) / (x @ x) for k in lags
])
axes[1].plot(lags, acf, "o-", label="empirical ACF")
axes[1].plot(lags, np.exp(-kappa * lags * dt), "--",
label=r"theoretical $e^{-\kappa\,\tau}$")
axes[1].set_xlabel("lag (days)"); axes[1].set_ylabel("autocorrelation")
axes[1].set_title("Mean-reversion fingerprint")
axes[1].legend(); axes[1].grid(alpha=0.3)
fig.tight_layout(); plt.show()
```
<!-- AUTO-PLOT-BEGIN -->
![Generated plot](../_static/auto/algorithms__optimal_control/block_03_fig_01.png)
<!-- AUTO-PLOT-END -->
**Method**: Maximum likelihood estimation (MLE) using analytical formulas for discrete-time OU process.
@@ -431,6 +462,10 @@ plt.plot(pnl_path, label='P&L')
plt.legend()
plt.tight_layout()
```
<!-- AUTO-PLOT-BEGIN -->
![Generated plot](../_static/auto/algorithms__optimal_control/block_06_fig_01.png)
<!-- AUTO-PLOT-END -->
**Metrics interpretation**:
- `total_return`: Should be positive with low transaction costs
+30
View File
@@ -47,6 +47,16 @@ $\partial_t m = \tfrac12 \partial_{xx} m$ with Gaussian initial density should r
ax.set_title('Pure-diffusion FokkerPlanck'); ax.grid(alpha=0.3); ax.legend()
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__pde/block_03_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/pde/plot_01.png
:align: center
:width: 80%
@@ -78,6 +88,16 @@ $-\Delta u = 2\pi^2 \sin(\pi x)\sin(\pi y)$ on the unit square with zero Dirichl
axes[1].set_title('error vs analytic'); plt.colorbar(im1, ax=axes[1])
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__pde/block_05_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/pde/plot_02.png
:align: center
:width: 80%
@@ -104,6 +124,16 @@ Heat-only relaxation ($H = 0$, σ² > 0) preserves a constant value, while a qua
plt.colorbar(im, ax=ax)
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__pde/block_07_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/pde/plot_03.png
:align: center
:width: 80%
@@ -451,6 +451,10 @@ plt.suptitle("Fractional Brownian Motion Paths")
plt.tight_layout()
plt.show()
```
<!-- AUTO-PLOT-BEGIN -->
![Generated plot](../_static/auto/algorithms__point_processes/block_03_fig_01.png)
<!-- AUTO-PLOT-END -->
### Mixed fBM for Aggregate Order Flow
@@ -518,6 +522,10 @@ plt.title('Power-law decay in scaling limit')
plt.tight_layout()
plt.show()
```
<!-- AUTO-PLOT-BEGIN -->
![Generated plot](../_static/auto/algorithms__point_processes/block_05_fig_01.png)
<!-- AUTO-PLOT-END -->
---
@@ -41,6 +41,16 @@ $h'(t) = h(t)^2/γ - φ$ with $h(T) = A$. When $γ = φ = A = 1$ the right-hand
ax.set_title('Riccati fixed point γ=φ=A=1')
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__quadratic_impact_control/block_03_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/quadratic_impact_control/plot_01.png
:align: center
:width: 80%
@@ -60,6 +70,16 @@ Vary $A$, fix $γ = 1$, $φ = 0.25$, $T = 1$.
ax.set_title('Riccati sensitivity to terminal weight')
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__quadratic_impact_control/block_04_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/quadratic_impact_control/plot_02.png
:align: center
:width: 80%
+20
View File
@@ -43,6 +43,16 @@ Synthetic stationary process with 5 % outliers
ax.set_title('Synthetic series with heavy-tailed innovations')
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__robust_drift/block_03_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/robust_drift/plot_01.png
:align: center
:width: 80%
@@ -71,6 +81,16 @@ Synthetic stationary process with 5 % outliers
ax.legend(); ax.grid(alpha=0.3); ax.set_title('Robust vs OLS drift estimate')
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__robust_drift/block_05_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/robust_drift/plot_02.png
:align: center
:width: 80%
@@ -43,6 +43,15 @@ Two modes; only mode 1 pays a unit reward. Free switching should give `V_0(0) =
ax.set_title('Snell envelope — free switching')
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__stochastic_control/block_03_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/stochastic_control/plot_01.png
:align: center
:width: 80%
@@ -73,6 +82,15 @@ Closed-form Riccati for $a=q=0$, $b=r=s_T=1$, $T=1$ is $P(t) = 1/(1 + (T - t))$,
axes[2].plot(tg[:-1], u); axes[2].set_title('feedback u(t) = -(b/r) P(t) x(t)'); axes[2].set_xlabel('t'); axes[2].grid(alpha=0.3)
fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__stochastic_control/block_05_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/stochastic_control/plot_02.png
:align: center
:width: 80%
@@ -96,6 +114,15 @@ Affine premium $δ_±(λ) = α_± + κ_± λ$. First-order condition: $\lambda^
ax.set_title('Optimal upward intensity vs value-function gradient')
ax.grid(alpha=0.3); fig.tight_layout(); plt.show()
.. AUTO-PLOT-BEGIN
.. image:: ../_static/auto/algorithms__stochastic_control/block_06_fig_01.png
:align: center
:width: 80%
.. AUTO-PLOT-END
.. image:: ../_static/v2/stochastic_control/plot_03.png
:align: center
:width: 80%
+8
View File
@@ -207,6 +207,10 @@ plt.suptitle("Fractional Brownian Motion: Three Regimes")
plt.tight_layout()
plt.show()
```
<!-- AUTO-PLOT-BEGIN -->
![Generated plot](../_static/auto/api__point_processes/block_04_fig_01.png)
<!-- AUTO-PLOT-END -->
---
@@ -401,6 +405,10 @@ plt.title('Scaling Function (Theorem 3.1)')
plt.grid(True, alpha=0.3)
plt.show()
```
<!-- AUTO-PLOT-BEGIN -->
![Generated plot](../_static/auto/api__point_processes/block_09_fig_01.png)
<!-- AUTO-PLOT-END -->
---
+17
View File
@@ -103,3 +103,20 @@ __all__ = [
"min_variance_weights",
"erc_weights",
]
def __getattr__(name):
"""Transparent fallback: forward any unresolved top-level attribute access
to the compiled `_core` extension. This keeps `from optimizr import X`
working for every Rust-backed function (v1.x and v2.0 primitives) without
having to enumerate the full list above."""
try:
from optimizr import _core as _ext
except ImportError as exc: # pragma: no cover
raise AttributeError(
f"module 'optimizr' has no attribute {name!r} "
f"(_core extension is not built: {exc})"
) from exc
if hasattr(_ext, name):
return getattr(_ext, name)
raise AttributeError(f"module 'optimizr' has no attribute {name!r}")
+270
View File
@@ -0,0 +1,270 @@
"""Inject matplotlib plots inline below every executable Python code-block
across the entire `docs/source/` tree.
For each `.md` and `.rst` file:
1. Parse out the Python code-blocks (fenced ```python``` for Markdown,
`.. code-block:: python` for reStructuredText).
2. Execute each block in its own namespace with a non-interactive matplotlib
backend. Every figure produced by the block is saved as a PNG under
`docs/source/_static/auto/<page-slug>/<idx>.png`.
3. Rewrite the page so that, immediately after the executable code-block, a
markdown image (`![caption](path)`) or RST `.. image::` directive points
to the captured PNG.
4. Blocks that fail to execute (missing imports, illustrative pseudo-code,
external data dependencies ) are left untouched no image is inserted
and the existing source is preserved.
Idempotent: previously injected image directives are detected and refreshed
in place rather than duplicated.
The script is purely additive on the v2.0.0 branch it touches no Rust or
binding code, only the doc tree and the auto-generated `_static/auto`
folder.
"""
from __future__ import annotations
import io
import os
import re
import sys
import traceback
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
DOC_SRC = ROOT / "docs" / "source"
STATIC_DIR = DOC_SRC / "_static" / "auto"
SKIP_DIRS = {"_static", "_build", "_templates"}
# A small allow-list of pages we KNOW exercise the bindings in a way that is
# self-contained. Every other page is still scanned, but if a code block
# fails we silently leave it alone.
# Auto-injected marker so that re-runs are idempotent.
MD_BEGIN = "<!-- AUTO-PLOT-BEGIN -->"
MD_END = "<!-- AUTO-PLOT-END -->"
RST_BEGIN = ".. AUTO-PLOT-BEGIN"
RST_END = ".. AUTO-PLOT-END"
# ---------------------------------------------------------------------------
def slugify(path: Path) -> str:
rel = path.relative_to(DOC_SRC).with_suffix("")
return str(rel).replace(os.sep, "__")
def execute_block(code: str, page_ns: dict) -> list[Path] | None:
"""Run `code` in `page_ns`. Return a list of PNG paths for the figures
it created, or `None` if the block raised."""
plt.close("all")
buf_out, buf_err = io.StringIO(), io.StringIO()
try:
with redirect_stdout(buf_out), redirect_stderr(buf_err):
exec(compile(code, "<doc-block>", "exec"), page_ns)
except BaseException: # noqa: BLE001 — also catch pyo3 PanicException
return None
return list(plt.get_fignums())
def save_figs(page_slug: str, block_idx: int) -> list[str]:
out_dir = STATIC_DIR / page_slug
out_dir.mkdir(parents=True, exist_ok=True)
written: list[str] = []
for i, num in enumerate(plt.get_fignums(), start=1):
fig = plt.figure(num)
fname = f"block_{block_idx:02d}_fig_{i:02d}.png"
fpath = out_dir / fname
fig.savefig(fpath, bbox_inches="tight", dpi=110)
written.append(fpath.as_posix())
plt.close("all")
return written
def relative_to_doc(target: Path, page_path: Path) -> str:
return os.path.relpath(target, page_path.parent).replace(os.sep, "/")
# ---------------------------------------------------------------------------
# Markdown
# ---------------------------------------------------------------------------
MD_BLOCK_RE = re.compile(
r"(?P<head>```(?:python|py)\s*\n)(?P<body>.*?)(?P<tail>```)",
re.DOTALL,
)
MD_AUTO_BLOCK_RE = re.compile(
re.escape(MD_BEGIN) + r".*?" + re.escape(MD_END) + r"\n?",
re.DOTALL,
)
def process_markdown(path: Path) -> bool:
text = path.read_text()
# Strip previously-injected auto blocks so we re-run from a clean slate.
text = MD_AUTO_BLOCK_RE.sub("", text)
page_slug = slugify(path)
page_ns: dict = {"__name__": "__doc__"}
out_chunks: list[str] = []
last = 0
block_idx = 0
n_injected = 0
for m in MD_BLOCK_RE.finditer(text):
block_idx += 1
out_chunks.append(text[last:m.end()])
last = m.end()
code = m.group("body")
nums = execute_block(code, page_ns)
if not nums:
continue
pngs = save_figs(page_slug, block_idx)
if not pngs:
continue
block = ["", MD_BEGIN]
for png in pngs:
rel = relative_to_doc(Path(png), path)
block.append(f"![Generated plot]({rel})")
block.append(MD_END)
block.append("")
out_chunks.append("\n".join(block))
n_injected += len(pngs)
out_chunks.append(text[last:])
new_text = "".join(out_chunks)
if new_text != path.read_text():
path.write_text(new_text)
return n_injected > 0
# ---------------------------------------------------------------------------
# reStructuredText
# ---------------------------------------------------------------------------
RST_BLOCK_RE = re.compile(
r"(?P<indent>[ \t]*)\.\. code-block::[ \t]+python\s*\n"
r"(?:[ \t]*:.*\n)*"
r"\n?"
r"(?P<body>(?:(?:\1[ \t]+.*|[ \t]*)\n)+)",
)
RST_AUTO_BLOCK_RE = re.compile(
re.escape(RST_BEGIN) + r".*?" + re.escape(RST_END) + r"\n?",
re.DOTALL,
)
def dedent_rst_body(body: str, indent: str) -> str:
inner_indent = indent + " "
out = []
for line in body.splitlines():
if line.startswith(inner_indent):
out.append(line[len(inner_indent):])
elif line.strip() == "":
out.append("")
else:
# End of code-block (different indentation): stop early.
break
return "\n".join(out)
def process_rst(path: Path) -> bool:
text = path.read_text()
text = RST_AUTO_BLOCK_RE.sub("", text)
page_slug = slugify(path)
page_ns: dict = {"__name__": "__doc__"}
out_chunks: list[str] = []
last = 0
block_idx = 0
n_injected = 0
for m in RST_BLOCK_RE.finditer(text):
block_idx += 1
out_chunks.append(text[last:m.end()])
last = m.end()
indent = m.group("indent")
code = dedent_rst_body(m.group("body"), indent)
nums = execute_block(code, page_ns)
if not nums:
continue
pngs = save_figs(page_slug, block_idx)
if not pngs:
continue
lines = ["", f"{indent}{RST_BEGIN}"]
for png in pngs:
rel = relative_to_doc(Path(png), path)
lines.append(f"{indent}.. image:: {rel}")
lines.append(f"{indent} :align: center")
lines.append(f"{indent} :width: 80%")
lines.append("")
lines.append(f"{indent}{RST_END}")
lines.append("")
out_chunks.append("\n".join(lines))
n_injected += len(pngs)
out_chunks.append(text[last:])
new_text = "".join(out_chunks)
if new_text != path.read_text():
path.write_text(new_text)
return n_injected > 0
# ---------------------------------------------------------------------------
def iter_doc_pages():
for dirpath, dirnames, filenames in os.walk(DOC_SRC):
# Prune
rel = Path(dirpath).relative_to(DOC_SRC)
parts = set(rel.parts)
if parts & SKIP_DIRS:
dirnames.clear()
continue
for fn in filenames:
if fn.endswith((".md", ".rst")) and not fn.endswith(".backup"):
yield Path(dirpath) / fn
def main() -> None:
STATIC_DIR.mkdir(parents=True, exist_ok=True)
n_pages = 0
n_with_plots = 0
total_imgs = 0
for page in sorted(iter_doc_pages()):
n_pages += 1
try:
if page.suffix == ".md":
injected = process_markdown(page)
else:
injected = process_rst(page)
except Exception: # noqa: BLE001
print(f"!! {page.relative_to(DOC_SRC)} crashed:")
traceback.print_exc()
continue
if injected:
n_with_plots += 1
# Count images in _static/auto/<page_slug>
img_dir = STATIC_DIR / slugify(page)
if img_dir.exists():
n_imgs = len(list(img_dir.glob("*.png")))
total_imgs += n_imgs
print(f" {page.relative_to(DOC_SRC)} -> {n_imgs} plot(s)")
else:
print(f" {page.relative_to(DOC_SRC)} -> no plots")
print()
print(f"Scanned {n_pages} pages, {n_with_plots} got new images, "
f"{total_imgs} PNGs total.")
if __name__ == "__main__":
main()