diff --git a/.github/workflows/deploy-docs-versioned.yml b/.github/workflows/deploy-docs-versioned.yml new file mode 100644 index 0000000..48022f1 --- /dev/null +++ b/.github/workflows/deploy-docs-versioned.yml @@ -0,0 +1,60 @@ +name: Deploy versioned docs (mike) + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +concurrency: + group: docs-deploy + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + container: + image: cubertgmbh/cuvis_pyil:3.5.3-ubuntu24.04 + steps: + - name: Install build dependencies + run: apt-get update -y && apt-get install -y git doxygen graphviz + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Stage cuvis.h for Doxygen + run: | + mkdir -p docs/_api_sources + cp /usr/include/cuvis.h docs/_api_sources/cuvis.h + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Sync docs deps + run: uv venv --system-site-packages && uv sync --extra docs + + - name: Configure git for mike + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Build docs (strict) + run: uv run mkdocs build --strict + + - name: Get version from tag + id: get_version + run: | + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Deploy docs with mike + run: | + uv run mike deploy --push --update-aliases ${{ steps.get_version.outputs.VERSION }} latest + uv run mike set-default --push latest diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index ebd08d1..f603dd4 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -1,32 +1,34 @@ -name: Deploy docs to GitHub Pages +name: Deploy docs to GitHub Pages (nightly) on: push: branches: [main] - paths: - - docs/** - - tools/** - - scripts/cuvis_sdk_url.py - - mkdocs.yml - - pyproject.toml - - uv.lock - - .github/workflows/deploy-docs.yml - workflow_dispatch: permissions: - contents: read - pages: write - id-token: write + contents: write concurrency: - group: pages + group: docs-deploy cancel-in-progress: false jobs: - build: + deploy: runs-on: ubuntu-latest + container: + image: cubertgmbh/cuvis_pyil:3.5.3-ubuntu24.04 steps: + - name: Install build dependencies + run: apt-get update -y && apt-get install -y git doxygen graphviz + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Stage cuvis.h for Doxygen + run: | + mkdir -p docs/_api_sources + cp /usr/include/cuvis.h docs/_api_sources/cuvis.h - name: Install uv uses: astral-sh/setup-uv@v4 @@ -34,22 +36,15 @@ jobs: enable-cache: true - name: Sync docs deps - run: uv sync --extra docs + run: uv venv --system-site-packages && uv sync --extra docs - - name: Build site - run: uv run mkdocs build --strict + - name: Configure git for mike + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 - with: - path: site + - name: Build docs (strict) + run: uv run mkdocs build --strict - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v4 + - name: Deploy dev (nightly) docs + run: uv run mike deploy --push dev diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 0000000..86330ba --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,42 @@ +name: Docs build check + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: docs-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + container: + image: cubertgmbh/cuvis_pyil:3.5.3-ubuntu24.04 + steps: + - name: Install build dependencies + run: apt-get update -y && apt-get install -y git doxygen graphviz + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Stage cuvis.h for Doxygen + run: | + mkdir -p docs/_api_sources + cp /usr/include/cuvis.h docs/_api_sources/cuvis.h + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Sync docs deps + run: uv venv --system-site-packages && uv sync --extra docs + + - name: Build site (strict) + run: uv run mkdocs build --strict diff --git a/.gitignore b/.gitignore index 3a03e0e..af7d8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,11 @@ _assets/ # uv-managed virtual envs and mkdocs build output. .venv/ site/ + +# mkdoxy-generated API reference pages (regenerated at each mkdocs build) +docs/cuvis_c/ +docs/cuvis_cpp/ + +# cuvis.h is sourced from the installed SDK at build time, not version-controlled. +# Use scripts/fetch-cuvis-header.sh locally or the CI "Extract cuvis.h" step. +docs/_api_sources/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e725aea..fb6f0e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,4 +2,11 @@ ## Unreleased +- **Documentation site migrated into this repo** (from `cuvis.doc`) and published to GitHub Pages. Versioned with `mike`: pushing a `v*.*.*` tag publishes that version aliased to `latest` (`deploy-docs-versioned.yml`), and pushes to `main` publish a `dev` build (`deploy-docs.yml`). C and C++ API reference generated from the headers via `mkdoxy` (Doxygen XML), Python API reference via `mkdocstrings`, example pages generated from the Python notebooks, and an `llms.txt` / `llms-full.txt` index via `mkdocs-llmstxt`. - Rebranded the mkdocs site to follow Cubert CI. Switched the Material `palette` to `primary/accent: custom` and let `docs/stylesheets/extra.css` drive the palette directly across both Material schemes. Headings use Rajdhani via a Google Fonts `@import`; body and code stay on Roboto / Roboto Mono via Material's font loader. Existing `.sdk-installer` widget styles are preserved on top of the brand palette. +- **CI: restored a strict docs build gate.** Added a `Docs build check` workflow (`.github/workflows/docs-check.yml`) that runs `mkdocs build --strict` on pull requests, plus a strict build step ahead of `mike deploy` in both the nightly and versioned deploy workflows, so dead links or missing generated pages fail CI instead of publishing a broken site. +- **CI: serialized docs deploys.** Added a shared `concurrency: { group: docs-deploy, cancel-in-progress: false }` to both deploy workflows so a `main` push and a release-tag push cannot race each other's `git push` to `gh-pages`. +- **Corrected the Examples overview** (`docs/examples/index.md`) to match the generated output: the example pages are Python-only, so the page no longer promises per-example C/C++ language tabs (the C/C++ sources live in the `examples/` submodules). +- **Fixed dropped trailing prose in example-page generation.** `_parse_notebook` (`tools/example_pages.py`) now flushes the final section on leftover prose as well as code, so a notebook ending in a markdown cell keeps its closing text. Added `_parse_notebook` / `multilang_example` tests covering the trailing-markdown case, unknown example names, and missing notebooks. +- **Fixed a Material theme feature typo** in `mkdocs.yml` (`content.code.annotation` → `content.code.annotate`) so code annotations render. +- **llms.txt index:** added the Home page (`index.md`) to the `mkdocs-llmstxt` sections and clarified the description — the generated index covers the guide and Python API, with the full C/C++ reference browsable on the site (the generated mkdoxy pages are intentionally left out so the strict build has no plugin-order dependency). diff --git a/docs/api/python.md b/docs/api/python.md new file mode 100644 index 0000000..815c682 --- /dev/null +++ b/docs/api/python.md @@ -0,0 +1,67 @@ +# Python API Reference + +The Python wrapper mirrors the C and C++ API concepts. Signatures shown here +are derived from static analysis of the source; see the C/C++ API reference +for full parameter-level documentation. + +## General + +::: cuvis.General + options: + members: [init, shutdown, version, set_log_level] + show_source: false + +## SessionFile + +::: cuvis.SessionFile + options: + show_source: false + +## Measurement + +::: cuvis.Measurement + options: + show_source: false + +## ProcessingContext + +::: cuvis.ProcessingContext + options: + show_source: false + +## AcquisitionContext + +::: cuvis.AcquisitionContext + options: + show_source: false + +## Worker + +::: cuvis.Worker + options: + show_source: false + +## Viewer + +::: cuvis.Viewer + options: + show_source: false + +## Calibration + +::: cuvis.Calibration + options: + show_source: false + +## Export + +::: cuvis.Export + options: + show_source: false + +## Types & Enums + +::: cuvis.cuvis_types + options: + show_source: false + members_order: alphabetical diff --git a/docs/examples/example_1_take_snapshot.md b/docs/examples/example_1_take_snapshot.md new file mode 100644 index 0000000..c8247c3 --- /dev/null +++ b/docs/examples/example_1_take_snapshot.md @@ -0,0 +1 @@ +{{ multilang_example("Example_1_Take_Snapshot") }} diff --git a/docs/examples/example_2_load_measurement.md b/docs/examples/example_2_load_measurement.md new file mode 100644 index 0000000..4d09efa --- /dev/null +++ b/docs/examples/example_2_load_measurement.md @@ -0,0 +1 @@ +{{ multilang_example("Example_2_Load_Measurement") }} diff --git a/docs/examples/example_3_reprocess.md b/docs/examples/example_3_reprocess.md new file mode 100644 index 0000000..9bb093e --- /dev/null +++ b/docs/examples/example_3_reprocess.md @@ -0,0 +1 @@ +{{ multilang_example("Example_3_Reprocess") }} diff --git a/docs/examples/example_4_exporters.md b/docs/examples/example_4_exporters.md new file mode 100644 index 0000000..4a3ce71 --- /dev/null +++ b/docs/examples/example_4_exporters.md @@ -0,0 +1 @@ +{{ multilang_example("Example_4_Exporters") }} diff --git a/docs/examples/example_5_record_video.md b/docs/examples/example_5_record_video.md new file mode 100644 index 0000000..1e3fb0f --- /dev/null +++ b/docs/examples/example_5_record_video.md @@ -0,0 +1 @@ +{{ multilang_example("Example_5_Record_Video") }} diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..f18f18d --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,22 @@ +# Examples + +The following examples demonstrate the core workflows of the Cuvis SDK. +The pages below show the **Python** implementation, generated from the runnable +Jupyter notebooks. C and C++ implementations of the same examples live in the +SDK's `examples/` directory (`cuvis.c.examples` and `cuvis.cpp.examples`). + +!!! note "Prerequisites" + All examples require: + + - A Cuvis SDK installation + - Camera settings files (provided with your camera and the SDK) + - For examples 2–5: a recorded measurement file (`.cu3s`) or the + [demo dataset](https://cloud.cubert-gmbh.de/s/SDKSampleData) + +| Example | Description | +|---|---| +| [1 – Take Snapshot](example_1_take_snapshot.md) | Connect to a camera and capture a single measurement | +| [2 – Load Measurement](example_2_load_measurement.md) | Load a recorded `.cu3s` file and inspect its data | +| [3 – Reprocess](example_3_reprocess.md) | Re-apply spectral processing to a stored measurement | +| [4 – Exporters](example_4_exporters.md) | Export measurements to common file formats | +| [5 – Record Video](example_5_record_video.md) | Continuously record a video stream of measurements | diff --git a/examples/cuvis.c.examples b/examples/cuvis.c.examples index a29ca30..ac6ac10 160000 --- a/examples/cuvis.c.examples +++ b/examples/cuvis.c.examples @@ -1 +1 @@ -Subproject commit a29ca30b9d33ce84f579c669149a195a9c936100 +Subproject commit ac6ac10da674fe4514eb35505c5fa9f0a18422f6 diff --git a/examples/cuvis.cpp.examples b/examples/cuvis.cpp.examples index 33bef88..1c5ea9c 160000 --- a/examples/cuvis.cpp.examples +++ b/examples/cuvis.cpp.examples @@ -1 +1 @@ -Subproject commit 33bef883fda48ce6f7136d50e18eee76ff5cf8de +Subproject commit 1c5ea9c479e33f8e9b762e86689fe3586fb2e4da diff --git a/examples/cuvis.python.examples b/examples/cuvis.python.examples index 652890c..bb4df87 160000 --- a/examples/cuvis.python.examples +++ b/examples/cuvis.python.examples @@ -1 +1 @@ -Subproject commit 652890cb9381b38f26dd3e977762505f01fafb57 +Subproject commit bb4df875064cf023b7e1e06a534ee90b8d25f398 diff --git a/mkdocs.yml b/mkdocs.yml index 5fd18bc..62aa307 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,9 +29,13 @@ theme: features: - navigation.instant - navigation.tracking + - navigation.indexes - navigation.top + - search.suggest + - search.highlight - content.tabs.link - content.code.copy + - content.code.annotate markdown_extensions: - admonition @@ -42,11 +46,68 @@ markdown_extensions: - pymdownx.superfences - pymdownx.highlight - pymdownx.inlinehilite + - pymdownx.details + +extra: + version: + provider: mike plugins: - search + - llmstxt: + markdown_description: | + Cuvis SDK is the official SDK for Cubert hyperspectral cameras, providing + C, C++, and Python APIs for camera control, data acquisition, and + hyperspectral data processing. This index covers the guide and the Python + API; the full C and C++ API reference is browsable on the documentation site. + full_output: llms-full.txt + sections: + Overview: + - index.md + Installation: + - installation.md + Examples: + - examples/*.md + API Reference: + - api/python.md + Reference: + - reference.md - macros: module_name: tools/docs_macros + - mkdoxy: + projects: + cuvis_c: + src-dirs: docs/_api_sources + full-doc: true + doxy-cfg: + FILE_PATTERNS: "*.h" + OPTIMIZE_OUTPUT_FOR_C: "YES" + EXTRACT_ALL: "YES" + GENERATE_HTML: "NO" + GENERATE_XML: "YES" + PROJECT_NAME: "Cuvis C API" + cuvis_cpp: + src-dirs: cuvis.cpp/interface + full-doc: true + doxy-cfg: + FILE_PATTERNS: "*.hpp" + EXTRACT_ALL: "YES" + GENERATE_HTML: "NO" + GENERATE_XML: "YES" + PROJECT_NAME: "Cuvis C++ API" + - mkdocstrings: + handlers: + python: + paths: [cuvis.python] + options: + allow_inspection: false + show_source: false + show_bases: true + show_root_heading: true + show_if_no_docstring: true + docstring_style: sphinx + extensions: + - tools/griffe_copydoc.py:CopydocExtension extra_javascript: - javascripts/sdk-installer.js @@ -57,4 +118,15 @@ extra_css: nav: - Home: index.md - Installation: installation.md + - Examples: + - Overview: examples/index.md + - 1 – Take Snapshot: examples/example_1_take_snapshot.md + - 2 – Load Measurement: examples/example_2_load_measurement.md + - 3 – Reprocess: examples/example_3_reprocess.md + - 4 – Exporters: examples/example_4_exporters.md + - 5 – Record Video: examples/example_5_record_video.md + - API Reference: + - C API: cuvis_c/modules.md + - C++ API: cuvis_cpp/annotated.md + - Python API: api/python.md - Reference: reference.md diff --git a/pyproject.toml b/pyproject.toml index fd6c34e..ad06875 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,14 @@ dependencies = [] [project.optional-dependencies] docs = [ + "urllib3>=2.7.0", "mkdocs>=1.5.0", "mkdocs-material>=9.5.0", "mkdocs-macros-plugin>=1.0.0", "mkdocs-llmstxt>=0.2", + "mkdoxy>=1.2.4", + "mkdocstrings[python]>=0.25.0", + "mike>=2.0.0", ] test = ["pytest>=7"] diff --git a/scripts/tests/test_example_pages.py b/scripts/tests/test_example_pages.py new file mode 100644 index 0000000..823f1fd --- /dev/null +++ b/scripts/tests/test_example_pages.py @@ -0,0 +1,99 @@ +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools")) + +import example_pages +from example_pages import _normalize_markdown_lists, _parse_notebook, multilang_example + + +def _write_notebook(path: Path, cells: list[tuple[str, str]]) -> Path: + """Write a minimal .ipynb with the given ``(cell_type, source)`` cells.""" + nb = {"cells": [{"cell_type": ctype, "source": src} for ctype, src in cells]} + path.write_text(json.dumps(nb), encoding="utf-8") + return path + + +def test_adds_blank_line_before_bullet_list(): + prose = "**Used principles:**\n - *AcquisitionContext* for camera control" + + assert _normalize_markdown_lists(prose) == ( + "**Used principles:**\n" + "\n" + " - *AcquisitionContext* for camera control" + ) + + +def test_adds_blank_line_before_ordered_list(): + prose = "**Step-by-Step outline:**\n 1. Import and initialize Cuvis SDK" + + assert _normalize_markdown_lists(prose) == ( + "**Step-by-Step outline:**\n" + "\n" + " 1. Import and initialize Cuvis SDK" + ) + + +def test_does_not_duplicate_existing_blank_line(): + prose = "**Used principles:**\n\n - *SessionFile* as camera calibration file" + + assert _normalize_markdown_lists(prose) == prose + + +def test_nested_list_items_stay_attached_to_parent_list(): + prose = "- parent\n - child" + + assert _normalize_markdown_lists(prose) == prose + + +def test_fenced_code_content_is_untouched(): + prose = "Example:\n```\nnot a list\n - keep this inside the fence\n```\nAfter" + + assert _normalize_markdown_lists(prose) == prose + + +def test_parse_notebook_groups_prose_with_following_code(tmp_path): + nb = _write_notebook( + tmp_path / "nb.ipynb", + [("markdown", "# Intro"), ("code", "x = 1"), + ("markdown", "## Step"), ("code", "y = 2")], + ) + + assert _parse_notebook(nb) == [("# Intro", "x = 1"), ("## Step", "y = 2")] + + +def test_parse_notebook_keeps_trailing_markdown_without_code(tmp_path): + nb = _write_notebook( + tmp_path / "nb.ipynb", + [("markdown", "# Intro"), ("code", "x = 1"), ("markdown", "## Conclusion")], + ) + + # A notebook ending in a markdown cell must not lose its closing prose. + assert _parse_notebook(nb) == [("# Intro", "x = 1"), ("## Conclusion", "")] + + +def test_parse_notebook_skips_empty_cells(tmp_path): + nb = _write_notebook( + tmp_path / "nb.ipynb", + [("markdown", ""), ("code", " "), ("markdown", "# Title"), ("code", "z = 3")], + ) + + assert _parse_notebook(nb) == [("# Title", "z = 3")] + + +def test_multilang_example_unknown_name_returns_failure_admonition(): + out = multilang_example("Not_A_Real_Example") + + assert out.startswith("!!! failure") + assert "Not_A_Real_Example" in out + + +def test_multilang_example_missing_notebook_returns_warning(tmp_path, monkeypatch): + monkeypatch.setattr(example_pages, "_EXAMPLES_BASE", tmp_path) + monkeypatch.setattr(example_pages, "_EXAMPLES", {"Ghost": "does_not_exist.ipynb"}) + + out = multilang_example("Ghost") + + assert out.startswith("!!! warning") diff --git a/tools/docs_macros.py b/tools/docs_macros.py index dd044a6..e66954d 100644 --- a/tools/docs_macros.py +++ b/tools/docs_macros.py @@ -9,6 +9,10 @@ package); we add `scripts/` to `sys.path` so the import below resolves. Edits to the script are visible in the next `uv run mkdocs build` with no intermediate install step. + +Also registers `multilang_example(name)` which generates the multi-language +tabbed example pages from the Jupyter notebooks and C/C++ source files in +``examples/``. Implementation is in ``tools/example_pages.py``. """ from __future__ import annotations @@ -16,9 +20,11 @@ import sys from pathlib import Path -_SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts" -if str(_SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(_SCRIPTS_DIR)) +_TOOLS_DIR = Path(__file__).resolve().parent +_SCRIPTS_DIR = _TOOLS_DIR.parent / "scripts" +for _p in (_TOOLS_DIR, _SCRIPTS_DIR): + if str(_p) not in sys.path: + sys.path.insert(0, str(_p)) def define_env(env): @@ -29,6 +35,7 @@ def define_env(env): sdk_url, sdk_urls, ) + from example_pages import multilang_example def _safe_install_command(*args, **kwargs): try: @@ -43,3 +50,4 @@ def _safe_install_command(*args, **kwargs): env.macro(sdk_urls, "cuvis_sdk_urls") env.macro(_safe_install_command, "cuvis_install_command") env.macro(list_release_metadata, "cuvis_sdk_metadata") + env.macro(multilang_example) diff --git a/tools/example_pages.py b/tools/example_pages.py new file mode 100644 index 0000000..37eb97e --- /dev/null +++ b/tools/example_pages.py @@ -0,0 +1,165 @@ +"""Generate Python-only MkDocs pages from Cuvis SDK Jupyter notebook examples. + +Parses Python Jupyter notebooks and emits prose + fenced Python code blocks +for each section. Invoked via the ``multilang_example`` macro registered in +docs_macros.py. + +TODO: Re-add C and C++ code tabs once a reliable section-alignment algorithm +is available. See git history for the removed LCS-based implementation. +""" + +from __future__ import annotations + +import json +import re +import textwrap +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_EXAMPLES_BASE = _ROOT / "examples" + +# Canonical name → notebook path relative to _EXAMPLES_BASE +_EXAMPLES: dict[str, str] = { + "Example_1_Take_Snapshot": "cuvis.python.examples/Example_1_Take_Snapshot.ipynb", + "Example_2_Load_Measurement": "cuvis.python.examples/Example_2_Load_Measurement.ipynb", + "Example_3_Reprocess": "cuvis.python.examples/Example_3_Reprocess.ipynb", + "Example_4_Exporters": "cuvis.python.examples/Example_4_Exporters.ipynb", + "Example_5_Record_Video": "cuvis.python.examples/Example_5_Record_Video.ipynb", +} + + +# --------------------------------------------------------------------------- +# Notebook parsing +# --------------------------------------------------------------------------- + +def _parse_notebook(nb_path: Path) -> list[tuple[str, str]]: + """Return list of (prose, code) sections from a Jupyter notebook. + + A new section begins at each markdown cell. All code cells that follow + (until the next markdown cell) are joined as the section's code block. + Cells with no source are skipped. + """ + with open(nb_path, encoding="utf-8") as f: + nb = json.load(f) + + sections: list[tuple[str, str]] = [] + pending_prose: list[str] = [] + pending_code: list[str] = [] + + for cell in nb.get("cells", []): + src = "".join(cell.get("source", [])).strip() + if not src: + continue + ctype = cell.get("cell_type", "") + + if ctype == "markdown": + if pending_code: + sections.append(("\n\n".join(pending_prose), "\n\n".join(pending_code))) + pending_prose = [] + pending_code = [] + pending_prose.append(src) + elif ctype == "code": + pending_code.append(src) + + if pending_prose or pending_code: + sections.append(("\n\n".join(pending_prose), "\n\n".join(pending_code))) + + return sections + + +# --------------------------------------------------------------------------- +# Output formatting +# --------------------------------------------------------------------------- + +_FENCE_RE = re.compile(r"^(?P {0,3})(?P`{3,}|~{3,})") +_LIST_RE = re.compile(r"^\s{0,3}(?:[-+*]\s+|\d+[.)]\s+)") + + +def _is_list_line(line: str) -> bool: + return bool(_LIST_RE.match(line)) + + +def _normalize_markdown_lists(prose: str) -> str: + """Insert paragraph/list spacing required by Python-Markdown. + + Jupyter accepts a paragraph immediately followed by an indented list item. + MkDocs/Python-Markdown treats that as paragraph continuation unless a blank + line separates the paragraph from the list. + """ + lines = prose.splitlines() + normalized: list[str] = [] + fence_char = "" + fence_len = 0 + + for line in lines: + fence = _FENCE_RE.match(line) + if fence and not fence_char: + marker = fence.group("fence") + fence_char = marker[0] + fence_len = len(marker) + elif fence and fence_char: + marker = fence.group("fence") + if marker[0] == fence_char and len(marker) >= fence_len: + fence_char = "" + fence_len = 0 + + if ( + not fence_char + and _is_list_line(line) + and normalized + and normalized[-1].strip() + and not _is_list_line(normalized[-1]) + ): + normalized.append("") + + normalized.append(line) + + return "\n".join(normalized) + + +def _format_section(prose: str, py_code: str) -> str: + """Combine prose and a fenced Python code block into one page section.""" + parts: list[str] = [] + + if prose.strip(): + parts.append(_normalize_markdown_lists(prose.strip())) + + if py_code.strip(): + code = textwrap.dedent(py_code).strip() + parts.append(f"```python\n{code}\n```") + + return "\n\n".join(parts) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def multilang_example(name: str) -> str: + """Return full MkDocs markdown for an example page (Python only). + + *name* must be one of the keys in ``_EXAMPLES`` (e.g. ``"Example_2_Load_Measurement"``). + A missing notebook produces a warning admonition rather than raising. + + TODO: Re-add C and C++ code tabs once a reliable section-alignment + algorithm is in place. The previous LCS-based approach produced + incorrect matches for structural mismatches (e.g. per-mode Python + sections vs. a single loop in C++). See git history for the removed + implementation. + """ + nb_rel = _EXAMPLES.get(name) + if not nb_rel: + known = ", ".join(f"`{k}`" for k in _EXAMPLES) + return f"!!! failure\n Unknown example `{name}`. Known examples: {known}\n" + + nb_path = _EXAMPLES_BASE / nb_rel + if not nb_path.exists(): + return f"!!! warning\n Notebook not found: `{nb_path}`\n" + + py_sections = _parse_notebook(nb_path) + page_sections = [ + _format_section(prose, py_code) + for prose, py_code in py_sections + if prose.strip() or py_code.strip() + ] + return "\n\n---\n\n".join(page_sections) diff --git a/tools/griffe_copydoc.py b/tools/griffe_copydoc.py new file mode 100644 index 0000000..347e886 --- /dev/null +++ b/tools/griffe_copydoc.py @@ -0,0 +1,95 @@ +"""Griffe extension: fill @copydoc docstrings by reading directly from cuvis_il. + +griffe uses static AST analysis when source files are present, which cannot see +docstrings assigned at runtime via the @copydoc decorator. This extension: + +- For regular functions/methods: parses griffe's Decorator.value strings. +- For @property methods: uses the on_attribute_instance hook, which still has + the original ast.FunctionDef node whose decorator_list contains @copydoc. + (Attribute objects in griffe have no decorators field; Function does.) + +In both cases the cuvis_il function name is extracted via regex, cuvis_il is +imported directly (bypassing cuvis/__init__.py and its sys.exit guard), and the +docstring is attached with an explicit sphinx parser so it renders correctly. +""" +from __future__ import annotations + +import ast +import re +from typing import Any + +import griffe + +_IL_FN = re.compile(r"cuvis_il\.(\w+)") + + +class CopydocExtension(griffe.Extension): + def __init__(self) -> None: + self._il: Any = None + + def _load_il(self) -> Any: + if self._il is not None: + return self._il + try: + from cuvis_il import cuvis_il as _il # package form (some installs) + self._il = _il + except ImportError: + try: + import cuvis_il as _il # flat-module form (Linux container) + self._il = _il + except Exception: + pass + return self._il + + @staticmethod + def _description_only(doc: str) -> str: + """Keep only the prose description; strip sphinx field directives (:param: etc.).""" + lines = doc.splitlines() + out = [] + for line in lines: + if line.strip().startswith(":"): + break + out.append(line) + while out and not out[-1].strip(): + out.pop() + return "\n".join(out).strip() + + def _doc_from_decorator_strs(self, strs: list[str]) -> str | None: + il = self._load_il() + if il is None: + return None + for s in strs: + if "copydoc" not in s: + continue + m = _IL_FN.search(s) + if m: + fn = getattr(il, m.group(1), None) + if fn and fn.__doc__: + return self._description_only(fn.__doc__) + return None + + def on_function(self, *, func: griffe.Function, **kwargs: Any) -> None: + try: + if func.docstring is not None: + return + strs = [str(d.value) for d in func.decorators] + doc = self._doc_from_decorator_strs(strs) + if doc: + func.docstring = griffe.Docstring(doc, parent=func, parser="sphinx") + except Exception: + pass + + def on_attribute_instance( + self, *, node: Any, attr: griffe.Attribute, agent: Any, **kwargs: Any + ) -> None: + # Attribute has no decorators field; the ast.FunctionDef node does. + # attr.labels already includes "property" when this hook fires. + try: + if attr.docstring is not None or "property" not in attr.labels: + return + strs = [ast.unparse(d) for d in getattr(node, "decorator_list", [])] + doc = self._doc_from_decorator_strs(strs) + if doc: + attr.docstring = griffe.Docstring(doc, parent=attr, parser="sphinx") + except Exception: + pass diff --git a/uv.lock b/uv.lock index 503c381..b5bc1dd 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version < '3.11'", +] [[package]] name = "babel" @@ -179,10 +183,14 @@ source = { virtual = "." } [package.optional-dependencies] docs = [ + { name = "mike" }, { name = "mkdocs" }, { name = "mkdocs-llmstxt" }, { name = "mkdocs-macros-plugin" }, { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "mkdoxy" }, + { name = "urllib3" }, ] test = [ { name = "pytest" }, @@ -190,11 +198,15 @@ test = [ [package.metadata] requires-dist = [ + { name = "mike", marker = "extra == 'docs'", specifier = ">=2.0.0" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.5.0" }, { name = "mkdocs-llmstxt", marker = "extra == 'docs'", specifier = ">=0.2" }, { name = "mkdocs-macros-plugin", marker = "extra == 'docs'", specifier = ">=1.0.0" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5.0" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.25.0" }, + { name = "mkdoxy", marker = "extra == 'docs'", specifier = ">=1.2.4" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=7" }, + { name = "urllib3", marker = "extra == 'docs'", specifier = ">=2.7.0" }, ] provides-extras = ["docs", "test"] @@ -203,7 +215,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -222,6 +234,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + [[package]] name = "hjson" version = "3.1.0" @@ -424,6 +445,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] +[[package]] +name = "mike" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "mkdocs" }, + { name = "pyparsing" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "verspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/47/fa87e9d56bef16cdfe34b059a437e8c6f7ec6f1b9c378871c3cf95ebea9c/mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e", size = 38450, upload-time = "2026-04-14T04:59:03.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040", size = 34026, upload-time = "2026-04-14T04:59:02.602Z" }, +] + [[package]] name = "mkdocs" version = "1.6.1" @@ -448,6 +486,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + [[package]] name = "mkdocs-get-deps" version = "0.2.2" @@ -529,6 +581,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] +[[package]] +name = "mkdocstrings" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, +] + +[[package]] +name = "mkdoxy" +version = "1.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/14/253c06c0a28b2288e35351a19c83473b11700bf0e82e16bbf668dc0ba45c/mkdoxy-1.2.8.tar.gz", hash = "sha256:95129608a332096ca52a7620c024ed33125e830508e5be0639a32766d9e44228", size = 653739, upload-time = "2025-08-29T18:05:04.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/82/dd8e3798b2bb75fd10a8f524b2bf8765d2cd396f21912333abffdd1e6666/mkdoxy-1.2.8-py3-none-any.whl", hash = "sha256:6bcc60a5409fb2b8f845d5ed69aa010f35569ea8ae2208172e1580f0d375b4a1", size = 44816, upload-time = "2025-08-29T18:05:03.262Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -596,6 +697,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -821,11 +931,20 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "verspec" +version = "0.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" }, ] [[package]]