From bdc80e679e6e56392cfc75bf34987e79145bf08c Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Mon, 18 May 2026 14:49:28 +0200 Subject: [PATCH 01/10] Added documentation for examples to mkdocs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `.github/workflows/deploy-docs.yml`: two-job pipeline (build with `uv run mkdocs build --strict` → upload Pages artifact → deploy via `actions/deploy-pages@v4`). Triggers on push to main with path filters plus `workflow_dispatch`. - Add `site_url: https://cubert-hyperspectral.github.io/cuvis.sdk/` to `mkdocs.yml` so Material emits the correct sitemap and absolute OG/canonical links. To go live: Settings → Pages → Source → "GitHub Actions", then push any docs change or run the workflow manually. --- .github/workflows/deploy-docs.yml | 9 + .gitignore | 4 + docs/api/python.md | 67 +++++ docs/examples/example_1_take_snapshot.md | 1 + docs/examples/example_2_load_measurement.md | 1 + docs/examples/example_3_reprocess.md | 1 + docs/examples/example_4_exporters.md | 1 + docs/examples/example_5_record_video.md | 1 + docs/examples/index.md | 23 ++ examples/cuvis.c.examples | 2 +- examples/cuvis.cpp.examples | 2 +- examples/cuvis.python.examples | 2 +- mkdocs.yml | 45 +++ pyproject.toml | 3 + tools/docs_macros.py | 14 +- tools/example_pages.py | 308 ++++++++++++++++++++ uv.lock | 84 +++++- 17 files changed, 559 insertions(+), 9 deletions(-) create mode 100644 docs/api/python.md create mode 100644 docs/examples/example_1_take_snapshot.md create mode 100644 docs/examples/example_2_load_measurement.md create mode 100644 docs/examples/example_3_reprocess.md create mode 100644 docs/examples/example_4_exporters.md create mode 100644 docs/examples/example_5_record_video.md create mode 100644 docs/examples/index.md create mode 100644 tools/example_pages.py diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index ebd08d1..1e8634b 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -7,6 +7,10 @@ on: - docs/** - tools/** - scripts/cuvis_sdk_url.py + - examples/cuvis.python.examples/** + - examples/cuvis.c.examples/** + - examples/cuvis.cpp.examples/** + - cuvis.cpp/interface/** - mkdocs.yml - pyproject.toml - uv.lock @@ -27,6 +31,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Doxygen + run: sudo apt-get install -y doxygen graphviz - name: Install uv uses: astral-sh/setup-uv@v4 diff --git a/.gitignore b/.gitignore index 3a03e0e..5f4c485 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,7 @@ _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/ 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..cf1379b --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,23 @@ +# Examples + +The following examples demonstrate the core workflows of the Cuvis SDK. +Each example is available in Python (Jupyter notebook), C, and C++. + +Use the language tabs within each example to switch between implementations. +Clicking a tab (e.g. **C++**) syncs all code blocks on the page to that language. + +!!! 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..b55c16c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,6 +30,8 @@ theme: - navigation.instant - navigation.tracking - navigation.top + - navigation.tabs + - navigation.tabs.sticky - content.tabs.link - content.code.copy @@ -42,11 +44,43 @@ markdown_extensions: - pymdownx.superfences - pymdownx.highlight - pymdownx.inlinehilite + - pymdownx.details plugins: - search - 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 + docstring_style: google extra_javascript: - javascripts/sdk-installer.js @@ -57,4 +91,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..f68d598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,13 @@ 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", ] test = ["pytest>=7"] 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..f477254 --- /dev/null +++ b/tools/example_pages.py @@ -0,0 +1,308 @@ +"""Generate multi-language tabbed MkDocs pages from Cuvis SDK example sources. + +Parses Python Jupyter notebooks and C/C++ source files, aligns sections by +keyword similarity between notebook markdown cells and C block comments, then +emits a pymdownx.tabbed page with interleaved prose and language-switched code +blocks. Invoked via the ``multilang_example`` macro registered in docs_macros.py. +""" + +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 → paths relative to _EXAMPLES_BASE +_EXAMPLES: dict[str, dict[str, str]] = { + "Example_1_Take_Snapshot": { + "nb": "cuvis.python.examples/Example_1_Take_Snapshot.ipynb", + "c": "cuvis.c.examples/Example_1_Take_Snapshot/main.c", + "cpp": "cuvis.cpp.examples/Example_1_Take_Snapshot_cpp/main.cpp", + }, + "Example_2_Load_Measurement": { + "nb": "cuvis.python.examples/Example_2_Load_Measurement.ipynb", + "c": "cuvis.c.examples/Example_2_Load_Measurement/main.c", + "cpp": "cuvis.cpp.examples/Example_2_Load_Measurement_cpp/main.cpp", + }, + "Example_3_Reprocess": { + "nb": "cuvis.python.examples/Example_3_Reprocess.ipynb", + "c": "cuvis.c.examples/Example_3_Reprocess/main.c", + "cpp": "cuvis.cpp.examples/Example_3_Reprocess_cpp/main.cpp", + }, + "Example_4_Exporters": { + "nb": "cuvis.python.examples/Example_4_Exporters.ipynb", + "c": "cuvis.c.examples/Example_4_Exporters/main.c", + "cpp": "cuvis.cpp.examples/Example_4_Exporters_cpp/main.cpp", + }, + "Example_5_Record_Video": { + "nb": "cuvis.python.examples/Example_5_Record_Video.ipynb", + "c": "cuvis.c.examples/Example_5_Record_Video/main.c", + "cpp": "cuvis.cpp.examples/Example_5_Record_Video_cpp/main.cpp", + }, +} + +_STOPWORDS = frozenset( + "this that with from have will more than some into used about also only " + "note please both each when then here just your also after before first " + "last which their there these those".split() +) + + +# --------------------------------------------------------------------------- +# 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_code: + sections.append(("\n\n".join(pending_prose), "\n\n".join(pending_code))) + + return sections + + +# --------------------------------------------------------------------------- +# C / C++ parsing +# --------------------------------------------------------------------------- + +def _clean_block_comment(raw: str) -> str: + """Strip leading `*` chars from each line of a C block comment interior.""" + lines = [re.sub(r"^\s*\*\s?", "", line) for line in raw.splitlines()] + return "\n".join(lines).strip() + + +def _strip_unbalanced_brace(code: str) -> str: + """Remove one trailing `}` when closing braces outnumber opening ones. + + Handles the C++ pattern where main() wraps its body in an extra ``{ }`` + block: after stripping main()'s own closing brace, the extra brace shows + up as the last character of the final code section. + """ + if code.count("}") > code.count("{"): + idx = code.rfind("}") + code = (code[:idx] + code[idx + 1 :]).strip() + return code + + +def _parse_c_source(src_path: Path) -> list[tuple[str, str]]: + """Return list of (comment_text, following_code) pairs from a C/C++ file. + + Extracts the body of ``main()``, then splits on ``/* … */`` block comments. + Each comment + the code that immediately follows it becomes one pair. + """ + with open(src_path, encoding="utf-8", errors="replace") as f: + content = f.read() + + # Locate main() and take its body + main_m = re.search(r"int\s+main\s*\([^)]*\)\s*\{", content) + if not main_m: + return [] + + body = content[main_m.end() :] + # Strip the outermost closing `}` of main() + last_brace = body.rfind("}") + if last_brace >= 0: + body = body[:last_brace] + + # Split on /* … */ block comments; capturing group yields alternating + # [code, comment, code, comment, …] + parts = re.split(r"/\*(.*?)\*/", body, flags=re.DOTALL) + + pairs: list[tuple[str, str]] = [] + for i in range(1, len(parts), 2): + comment = _clean_block_comment(parts[i]) + raw_code = parts[i + 1] if i + 1 < len(parts) else "" + code = textwrap.dedent(raw_code).strip() + code = _strip_unbalanced_brace(code) + + if comment or code: + pairs.append((comment, code)) + + return pairs + + +# --------------------------------------------------------------------------- +# Section matching +# --------------------------------------------------------------------------- + +def _keywords(text: str) -> frozenset[str]: + """Return set of significant lowercase words (≥4 chars) from *text*.""" + clean = re.sub(r"[#*_`\[\]()!]", " ", text) + words = re.findall(r"\b[a-z]{4,}\b", clean.lower()) + return frozenset(w for w in words if w not in _STOPWORDS) + + +def _section_heading(prose: str) -> str: + """Extract the most specific heading line from markdown prose.""" + for pattern in (r"#{4}\s+(.+)$", r"#{3}\s+(.+)$", r"#{2}\s+(.+)$", r"#{1}\s+(.+)$"): + m = re.search(pattern, prose, re.MULTILINE) + if m: + return m.group(1) + return prose.split("\n")[0] + + +def _section_score(py_prose: str, c_comment: str) -> int: + """Score how well a C comment matches a Python prose section. + + The first line of the C comment (the section title) is weighted 3× vs the + full-body keyword overlap, so "SessionFile" / "Measurement" headings match + their counterparts precisely instead of tying on incidental body mentions. + """ + py_heading_kw = _keywords(_section_heading(py_prose)) + if not py_heading_kw: + return 0 + + c_first_line_kw = _keywords(c_comment.split("\n")[0]) + c_body_kw = _keywords(c_comment[:400]) + + primary = len(py_heading_kw & c_first_line_kw) * 3 + secondary = len(py_heading_kw & c_body_kw) + return primary + secondary + + +def _match_c_sections( + py_sections: list[tuple[str, str]], + c_pairs: list[tuple[str, str]], +) -> dict[int, str]: + """Map each C pair to the best-matching Python section index. + + Matching is keyword-based and order-preserving: once a C pair is matched + to Python section *k*, the next C pair can only match section *k* or later. + Unmatched C pairs (score = 0) are folded into the most-recently matched + Python section so their code isn't lost. + + Returns ``{py_index: combined_c_code}``. + """ + result: dict[int, list[str]] = {i: [] for i in range(len(py_sections))} + last_matched = 0 + + for c_comment, c_code in c_pairs: + if not c_code: + continue + + best_idx: int | None = None + best_score = 0 + + # Only search at or after the last matched position (preserves order) + for py_idx in range(last_matched, len(py_sections)): + py_prose, _ = py_sections[py_idx] + if not py_prose.strip(): + continue + score = _section_score(py_prose, c_comment) + if score > best_score: + best_score = score + best_idx = py_idx + + if best_idx is not None and best_score > 0: + result[best_idx].append(c_code) + last_matched = best_idx + else: + # No keyword match — append to the currently active Python section + result[last_matched].append(c_code) + + return {k: "\n\n".join(v) for k, v in result.items() if v} + + +# --------------------------------------------------------------------------- +# Output formatting +# --------------------------------------------------------------------------- + +def _code_tab(label: str, lang: str, code: str) -> str: + """Return a single pymdownx.tabbed tab block, or empty string if no code.""" + if not code.strip(): + return "" + fence = f"```{lang}\n{code.strip()}\n```" + # pymdownx.tabbed requires 4-space indent for content + indented = "\n".join(" " + line for line in fence.splitlines()) + return f'=== "{label}"\n\n{indented}\n' + + +def _format_section(prose: str, py_code: str, c_code: str, cpp_code: str) -> str: + """Combine prose and a three-language tab block into one page section.""" + parts: list[str] = [] + + if prose.strip(): + parts.append(prose.strip()) + + tabs = [ + _code_tab("Python", "python", py_code), + _code_tab("C", "c", c_code), + _code_tab("C++", "cpp", cpp_code), + ] + non_empty_tabs = [t for t in tabs if t] + if non_empty_tabs: + parts.append("\n\n".join(non_empty_tabs)) + + return "\n\n".join(parts) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def multilang_example(name: str) -> str: + """Return full MkDocs markdown for a multi-language example page. + + *name* must be one of the keys in ``_EXAMPLES`` (e.g. ``"Example_2_Load_Measurement"``). + Missing source files produce a warning admonition rather than raising. + """ + info = _EXAMPLES.get(name) + if not info: + known = ", ".join(f"`{k}`" for k in _EXAMPLES) + return f"!!! failure\n Unknown example `{name}`. Known examples: {known}\n" + + nb_path = _EXAMPLES_BASE / info["nb"] + c_path = _EXAMPLES_BASE / info["c"] + cpp_path = _EXAMPLES_BASE / info["cpp"] + + missing = [str(p) for p in (nb_path, c_path, cpp_path) if not p.exists()] + if missing: + files = "\n".join(f" - `{p}`" for p in missing) + return f"!!! warning\n Source files not found:\n{files}\n" + + py_sections = _parse_notebook(nb_path) + c_pairs = _parse_c_source(c_path) + cpp_pairs = _parse_c_source(cpp_path) + + c_map = _match_c_sections(py_sections, c_pairs) + cpp_map = _match_c_sections(py_sections, cpp_pairs) + + page_sections: list[str] = [] + for i, (prose, py_code) in enumerate(py_sections): + c_code = c_map.get(i, "") + cpp_code = cpp_map.get(i, "") + section = _format_section(prose, py_code, c_code, cpp_code) + if section.strip(): + page_sections.append(section) + + return "\n\n---\n\n".join(page_sections) diff --git a/uv.lock b/uv.lock index 503c381..64fd0f1 100644 --- a/uv.lock +++ b/uv.lock @@ -183,6 +183,9 @@ docs = [ { name = "mkdocs-llmstxt" }, { name = "mkdocs-macros-plugin" }, { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "mkdoxy" }, + { name = "urllib3" }, ] test = [ { name = "pytest" }, @@ -194,7 +197,10 @@ requires-dist = [ { 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"] @@ -222,6 +228,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" @@ -448,6 +463,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 +558,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" @@ -821,11 +899,11 @@ 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/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/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/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/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]] From 749af97eee555b1bc47cbd5fc4686381d5c24a4f Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Tue, 26 May 2026 09:01:02 +0200 Subject: [PATCH 02/10] adding python examples to doc (for now python only) --- tools/example_pages.py | 264 ++++++----------------------------------- uv.lock | 6 +- 2 files changed, 42 insertions(+), 228 deletions(-) diff --git a/tools/example_pages.py b/tools/example_pages.py index f477254..c6748ad 100644 --- a/tools/example_pages.py +++ b/tools/example_pages.py @@ -1,56 +1,31 @@ -"""Generate multi-language tabbed MkDocs pages from Cuvis SDK example sources. +"""Generate Python-only MkDocs pages from Cuvis SDK Jupyter notebook examples. -Parses Python Jupyter notebooks and C/C++ source files, aligns sections by -keyword similarity between notebook markdown cells and C block comments, then -emits a pymdownx.tabbed page with interleaved prose and language-switched code -blocks. Invoked via the ``multilang_example`` macro registered in docs_macros.py. +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 → paths relative to _EXAMPLES_BASE -_EXAMPLES: dict[str, dict[str, str]] = { - "Example_1_Take_Snapshot": { - "nb": "cuvis.python.examples/Example_1_Take_Snapshot.ipynb", - "c": "cuvis.c.examples/Example_1_Take_Snapshot/main.c", - "cpp": "cuvis.cpp.examples/Example_1_Take_Snapshot_cpp/main.cpp", - }, - "Example_2_Load_Measurement": { - "nb": "cuvis.python.examples/Example_2_Load_Measurement.ipynb", - "c": "cuvis.c.examples/Example_2_Load_Measurement/main.c", - "cpp": "cuvis.cpp.examples/Example_2_Load_Measurement_cpp/main.cpp", - }, - "Example_3_Reprocess": { - "nb": "cuvis.python.examples/Example_3_Reprocess.ipynb", - "c": "cuvis.c.examples/Example_3_Reprocess/main.c", - "cpp": "cuvis.cpp.examples/Example_3_Reprocess_cpp/main.cpp", - }, - "Example_4_Exporters": { - "nb": "cuvis.python.examples/Example_4_Exporters.ipynb", - "c": "cuvis.c.examples/Example_4_Exporters/main.c", - "cpp": "cuvis.cpp.examples/Example_4_Exporters_cpp/main.cpp", - }, - "Example_5_Record_Video": { - "nb": "cuvis.python.examples/Example_5_Record_Video.ipynb", - "c": "cuvis.c.examples/Example_5_Record_Video/main.c", - "cpp": "cuvis.cpp.examples/Example_5_Record_Video_cpp/main.cpp", - }, +# 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", } -_STOPWORDS = frozenset( - "this that with from have will more than some into used about also only " - "note please both each when then here just your also after before first " - "last which their there these those".split() -) - # --------------------------------------------------------------------------- # Notebook parsing @@ -91,177 +66,20 @@ def _parse_notebook(nb_path: Path) -> list[tuple[str, str]]: return sections -# --------------------------------------------------------------------------- -# C / C++ parsing -# --------------------------------------------------------------------------- - -def _clean_block_comment(raw: str) -> str: - """Strip leading `*` chars from each line of a C block comment interior.""" - lines = [re.sub(r"^\s*\*\s?", "", line) for line in raw.splitlines()] - return "\n".join(lines).strip() - - -def _strip_unbalanced_brace(code: str) -> str: - """Remove one trailing `}` when closing braces outnumber opening ones. - - Handles the C++ pattern where main() wraps its body in an extra ``{ }`` - block: after stripping main()'s own closing brace, the extra brace shows - up as the last character of the final code section. - """ - if code.count("}") > code.count("{"): - idx = code.rfind("}") - code = (code[:idx] + code[idx + 1 :]).strip() - return code - - -def _parse_c_source(src_path: Path) -> list[tuple[str, str]]: - """Return list of (comment_text, following_code) pairs from a C/C++ file. - - Extracts the body of ``main()``, then splits on ``/* … */`` block comments. - Each comment + the code that immediately follows it becomes one pair. - """ - with open(src_path, encoding="utf-8", errors="replace") as f: - content = f.read() - - # Locate main() and take its body - main_m = re.search(r"int\s+main\s*\([^)]*\)\s*\{", content) - if not main_m: - return [] - - body = content[main_m.end() :] - # Strip the outermost closing `}` of main() - last_brace = body.rfind("}") - if last_brace >= 0: - body = body[:last_brace] - - # Split on /* … */ block comments; capturing group yields alternating - # [code, comment, code, comment, …] - parts = re.split(r"/\*(.*?)\*/", body, flags=re.DOTALL) - - pairs: list[tuple[str, str]] = [] - for i in range(1, len(parts), 2): - comment = _clean_block_comment(parts[i]) - raw_code = parts[i + 1] if i + 1 < len(parts) else "" - code = textwrap.dedent(raw_code).strip() - code = _strip_unbalanced_brace(code) - - if comment or code: - pairs.append((comment, code)) - - return pairs - - -# --------------------------------------------------------------------------- -# Section matching -# --------------------------------------------------------------------------- - -def _keywords(text: str) -> frozenset[str]: - """Return set of significant lowercase words (≥4 chars) from *text*.""" - clean = re.sub(r"[#*_`\[\]()!]", " ", text) - words = re.findall(r"\b[a-z]{4,}\b", clean.lower()) - return frozenset(w for w in words if w not in _STOPWORDS) - - -def _section_heading(prose: str) -> str: - """Extract the most specific heading line from markdown prose.""" - for pattern in (r"#{4}\s+(.+)$", r"#{3}\s+(.+)$", r"#{2}\s+(.+)$", r"#{1}\s+(.+)$"): - m = re.search(pattern, prose, re.MULTILINE) - if m: - return m.group(1) - return prose.split("\n")[0] - - -def _section_score(py_prose: str, c_comment: str) -> int: - """Score how well a C comment matches a Python prose section. - - The first line of the C comment (the section title) is weighted 3× vs the - full-body keyword overlap, so "SessionFile" / "Measurement" headings match - their counterparts precisely instead of tying on incidental body mentions. - """ - py_heading_kw = _keywords(_section_heading(py_prose)) - if not py_heading_kw: - return 0 - - c_first_line_kw = _keywords(c_comment.split("\n")[0]) - c_body_kw = _keywords(c_comment[:400]) - - primary = len(py_heading_kw & c_first_line_kw) * 3 - secondary = len(py_heading_kw & c_body_kw) - return primary + secondary - - -def _match_c_sections( - py_sections: list[tuple[str, str]], - c_pairs: list[tuple[str, str]], -) -> dict[int, str]: - """Map each C pair to the best-matching Python section index. - - Matching is keyword-based and order-preserving: once a C pair is matched - to Python section *k*, the next C pair can only match section *k* or later. - Unmatched C pairs (score = 0) are folded into the most-recently matched - Python section so their code isn't lost. - - Returns ``{py_index: combined_c_code}``. - """ - result: dict[int, list[str]] = {i: [] for i in range(len(py_sections))} - last_matched = 0 - - for c_comment, c_code in c_pairs: - if not c_code: - continue - - best_idx: int | None = None - best_score = 0 - - # Only search at or after the last matched position (preserves order) - for py_idx in range(last_matched, len(py_sections)): - py_prose, _ = py_sections[py_idx] - if not py_prose.strip(): - continue - score = _section_score(py_prose, c_comment) - if score > best_score: - best_score = score - best_idx = py_idx - - if best_idx is not None and best_score > 0: - result[best_idx].append(c_code) - last_matched = best_idx - else: - # No keyword match — append to the currently active Python section - result[last_matched].append(c_code) - - return {k: "\n\n".join(v) for k, v in result.items() if v} - - # --------------------------------------------------------------------------- # Output formatting # --------------------------------------------------------------------------- -def _code_tab(label: str, lang: str, code: str) -> str: - """Return a single pymdownx.tabbed tab block, or empty string if no code.""" - if not code.strip(): - return "" - fence = f"```{lang}\n{code.strip()}\n```" - # pymdownx.tabbed requires 4-space indent for content - indented = "\n".join(" " + line for line in fence.splitlines()) - return f'=== "{label}"\n\n{indented}\n' - - -def _format_section(prose: str, py_code: str, c_code: str, cpp_code: str) -> str: - """Combine prose and a three-language tab block into one page section.""" +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(prose.strip()) - tabs = [ - _code_tab("Python", "python", py_code), - _code_tab("C", "c", c_code), - _code_tab("C++", "cpp", cpp_code), - ] - non_empty_tabs = [t for t in tabs if t] - if non_empty_tabs: - parts.append("\n\n".join(non_empty_tabs)) + if py_code.strip(): + code = textwrap.dedent(py_code).strip() + parts.append(f"```python\n{code}\n```") return "\n\n".join(parts) @@ -271,38 +89,30 @@ def _format_section(prose: str, py_code: str, c_code: str, cpp_code: str) -> str # --------------------------------------------------------------------------- def multilang_example(name: str) -> str: - """Return full MkDocs markdown for a multi-language example page. + """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"``). - Missing source files produce a warning admonition rather than raising. + 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. """ - info = _EXAMPLES.get(name) - if not info: + 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 / info["nb"] - c_path = _EXAMPLES_BASE / info["c"] - cpp_path = _EXAMPLES_BASE / info["cpp"] - - missing = [str(p) for p in (nb_path, c_path, cpp_path) if not p.exists()] - if missing: - files = "\n".join(f" - `{p}`" for p in missing) - return f"!!! warning\n Source files not found:\n{files}\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) - c_pairs = _parse_c_source(c_path) - cpp_pairs = _parse_c_source(cpp_path) - - c_map = _match_c_sections(py_sections, c_pairs) - cpp_map = _match_c_sections(py_sections, cpp_pairs) - - page_sections: list[str] = [] - for i, (prose, py_code) in enumerate(py_sections): - c_code = c_map.get(i, "") - cpp_code = cpp_map.get(i, "") - section = _format_section(prose, py_code, c_code, cpp_code) - if section.strip(): - page_sections.append(section) - + 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/uv.lock b/uv.lock index 64fd0f1..b238bd0 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" @@ -209,7 +213,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 = [ From 6400066c38ac7bdda32cec534fdbe252e56e46f5 Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Tue, 26 May 2026 11:19:01 +0200 Subject: [PATCH 03/10] only docs deploy for master branch and one for version tags --- .github/workflows/deploy-docs-versioned.yml | 53 ++++++++++++++++++ .github/workflows/deploy-docs.yml | 59 +++++++-------------- .gitignore | 4 ++ mkdocs.yml | 25 ++++++++- pyproject.toml | 1 + scripts/fetch-cuvis-header.sh | 22 ++++++++ uv.lock | 37 +++++++++++++ 7 files changed, 159 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/deploy-docs-versioned.yml create mode 100644 scripts/fetch-cuvis-header.sh diff --git a/.github/workflows/deploy-docs-versioned.yml b/.github/workflows/deploy-docs-versioned.yml new file mode 100644 index 0000000..0f8a21b --- /dev/null +++ b/.github/workflows/deploy-docs-versioned.yml @@ -0,0 +1,53 @@ +name: Deploy versioned docs (mike) + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + container: + image: cubertgmbh/cuvis_pyil:3.5.0-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 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: 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 1e8634b..79d2a52 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -1,41 +1,30 @@ -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 - - examples/cuvis.python.examples/** - - examples/cuvis.c.examples/** - - examples/cuvis.cpp.examples/** - - cuvis.cpp/interface/** - - mkdocs.yml - - pyproject.toml - - uv.lock - - .github/workflows/deploy-docs.yml - workflow_dispatch: permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false + contents: write jobs: - build: + deploy: runs-on: ubuntu-latest + container: + image: cubertgmbh/cuvis_pyil:3.5.0-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: Install Doxygen - run: sudo apt-get install -y doxygen graphviz + - 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 @@ -45,20 +34,10 @@ jobs: - name: Sync docs deps run: uv sync --extra docs - - name: Build site - run: uv run mkdocs build --strict - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 - with: - path: site + - name: Configure git for mike + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" - 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/.gitignore b/.gitignore index 5f4c485..af7d8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,7 @@ 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/mkdocs.yml b/mkdocs.yml index b55c16c..6e34f49 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,11 +29,13 @@ theme: features: - navigation.instant - navigation.tracking + - navigation.indexes - navigation.top - - navigation.tabs - - navigation.tabs.sticky + - search.suggest + - search.highlight - content.tabs.link - content.code.copy + - content.code.annotation markdown_extensions: - admonition @@ -46,8 +48,27 @@ markdown_extensions: - pymdownx.inlinehilite - pymdownx.details +extra: + version: + provider: mike + plugins: - search + - llmstxt: + markdown_description: | + Cuvis SDK is the official SDK for Cubert hyperspectral cameras. + It provides C, C++, and Python APIs for camera control, data acquisition, + and hyperspectral data processing. + full_output: llms-full.txt + sections: + Installation: + - installation.md + Examples: + - examples/*.md + API Reference: + - api/python.md + Reference: + - reference.md - macros: module_name: tools/docs_macros - mkdoxy: diff --git a/pyproject.toml b/pyproject.toml index f68d598..ad06875 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ docs = [ "mkdocs-llmstxt>=0.2", "mkdoxy>=1.2.4", "mkdocstrings[python]>=0.25.0", + "mike>=2.0.0", ] test = ["pytest>=7"] diff --git a/scripts/fetch-cuvis-header.sh b/scripts/fetch-cuvis-header.sh new file mode 100644 index 0000000..3e46504 --- /dev/null +++ b/scripts/fetch-cuvis-header.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Extracts cuvis.h from the SDK Docker image into docs/_api_sources/. +# Use this to set up the header for a partial local build (Doxygen C API only). +# +# Usage: +# bash scripts/fetch-cuvis-header.sh +# bash scripts/fetch-cuvis-header.sh cubertgmbh/cuvis_pyil:3.5.0-ubuntu24.04 +# +# For a full local doc build (Python API requires live import of the SDK): +# docker run --rm -v "$PWD:/workspace" -w /workspace \ +# cubertgmbh/cuvis_pyil:3.5.0-ubuntu24.04 \ +# bash -c "apt-get update -y -q && apt-get install -y -q doxygen graphviz && \ +# mkdir -p docs/_api_sources && cp /usr/include/cuvis.h docs/_api_sources/ && \ +# pip install uv && uv sync --extra docs && uv run mkdocs build --strict" +set -euo pipefail + +IMAGE="${1:-cubertgmbh/cuvis_pyil:3.5.0-ubuntu24.04}" +mkdir -p docs/_api_sources +cid=$(docker create "$IMAGE") +docker cp "${cid}:/usr/include/cuvis.h" docs/_api_sources/cuvis.h +docker rm "${cid}" +echo "cuvis.h extracted from ${IMAGE} → docs/_api_sources/cuvis.h" diff --git a/uv.lock b/uv.lock index b238bd0..b5bc1dd 100644 --- a/uv.lock +++ b/uv.lock @@ -183,6 +183,7 @@ source = { virtual = "." } [package.optional-dependencies] docs = [ + { name = "mike" }, { name = "mkdocs" }, { name = "mkdocs-llmstxt" }, { name = "mkdocs-macros-plugin" }, @@ -197,6 +198,7 @@ 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" }, @@ -443,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" @@ -678,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" @@ -910,6 +938,15 @@ 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/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/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]] name = "watchdog" version = "6.0.0" From fe4878c5af1e50b0ad9a4bf6fc7f269ef9ea9c1d Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Tue, 26 May 2026 13:54:29 +0200 Subject: [PATCH 04/10] correctly normalizing markdown lists --- scripts/tests/test_example_pages.py | 45 ++++++++++++++++++++++++++ tools/example_pages.py | 49 ++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 scripts/tests/test_example_pages.py diff --git a/scripts/tests/test_example_pages.py b/scripts/tests/test_example_pages.py new file mode 100644 index 0000000..4c1e06d --- /dev/null +++ b/scripts/tests/test_example_pages.py @@ -0,0 +1,45 @@ +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools")) + +from example_pages import _normalize_markdown_lists + + +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 diff --git a/tools/example_pages.py b/tools/example_pages.py index c6748ad..82c6b97 100644 --- a/tools/example_pages.py +++ b/tools/example_pages.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import re import textwrap from pathlib import Path @@ -70,12 +71,58 @@ def _parse_notebook(nb_path: Path) -> list[tuple[str, str]]: # 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(prose.strip()) + parts.append(_normalize_markdown_lists(prose.strip())) if py_code.strip(): code = textwrap.dedent(py_code).strip() From bf84bb0a43e83c841226794b446bc90d401730e2 Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Thu, 28 May 2026 10:06:11 +0200 Subject: [PATCH 05/10] removing unneeded file --- scripts/fetch-cuvis-header.sh | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 scripts/fetch-cuvis-header.sh diff --git a/scripts/fetch-cuvis-header.sh b/scripts/fetch-cuvis-header.sh deleted file mode 100644 index 3e46504..0000000 --- a/scripts/fetch-cuvis-header.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Extracts cuvis.h from the SDK Docker image into docs/_api_sources/. -# Use this to set up the header for a partial local build (Doxygen C API only). -# -# Usage: -# bash scripts/fetch-cuvis-header.sh -# bash scripts/fetch-cuvis-header.sh cubertgmbh/cuvis_pyil:3.5.0-ubuntu24.04 -# -# For a full local doc build (Python API requires live import of the SDK): -# docker run --rm -v "$PWD:/workspace" -w /workspace \ -# cubertgmbh/cuvis_pyil:3.5.0-ubuntu24.04 \ -# bash -c "apt-get update -y -q && apt-get install -y -q doxygen graphviz && \ -# mkdir -p docs/_api_sources && cp /usr/include/cuvis.h docs/_api_sources/ && \ -# pip install uv && uv sync --extra docs && uv run mkdocs build --strict" -set -euo pipefail - -IMAGE="${1:-cubertgmbh/cuvis_pyil:3.5.0-ubuntu24.04}" -mkdir -p docs/_api_sources -cid=$(docker create "$IMAGE") -docker cp "${cid}:/usr/include/cuvis.h" docs/_api_sources/cuvis.h -docker rm "${cid}" -echo "cuvis.h extracted from ${IMAGE} → docs/_api_sources/cuvis.h" From 93ae9669cdb20c58c6a8a3af44424ec8d95f1c06 Mon Sep 17 00:00:00 2001 From: Nima Ghorbani Date: Wed, 3 Jun 2026 10:31:23 +0200 Subject: [PATCH 06/10] docs: harden docs CI and fix example-page generation - Add a strict mkdocs build check on PRs and before each mike deploy so broken links or missing generated pages fail CI instead of publishing a broken site. - Serialize nightly and versioned deploys with a shared concurrency group to avoid racing pushes to gh-pages. - Correct the Examples overview: the generated example pages are Python-only. - Keep trailing prose in notebook-derived pages (_parse_notebook now flushes a final markdown-only section) and add tests covering it. - Fix mkdocs feature flag typo: content.code.annotation -> content.code.annotate. --- .github/workflows/deploy-docs-versioned.yml | 7 +++ .github/workflows/deploy-docs.yml | 7 +++ .github/workflows/docs-check.yml | 42 ++++++++++++++++ CHANGELOG.md | 5 ++ docs/examples/index.md | 7 ++- mkdocs.yml | 2 +- scripts/tests/test_example_pages.py | 56 ++++++++++++++++++++- tools/example_pages.py | 2 +- 8 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/docs-check.yml diff --git a/.github/workflows/deploy-docs-versioned.yml b/.github/workflows/deploy-docs-versioned.yml index 0f8a21b..7e0c06a 100644 --- a/.github/workflows/deploy-docs-versioned.yml +++ b/.github/workflows/deploy-docs-versioned.yml @@ -8,6 +8,10 @@ on: permissions: contents: write +concurrency: + group: docs-deploy + cancel-in-progress: false + jobs: deploy: runs-on: ubuntu-latest @@ -40,6 +44,9 @@ jobs: 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: | diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 79d2a52..fcbaba0 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -7,6 +7,10 @@ on: permissions: contents: write +concurrency: + group: docs-deploy + cancel-in-progress: false + jobs: deploy: runs-on: ubuntu-latest @@ -39,5 +43,8 @@ jobs: 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: 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..79115ea --- /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.0-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 sync --extra docs + + - name: Build site (strict) + run: uv run mkdocs build --strict diff --git a/CHANGELOG.md b/CHANGELOG.md index e725aea..a4c10d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,3 +3,8 @@ ## Unreleased - 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. diff --git a/docs/examples/index.md b/docs/examples/index.md index cf1379b..f18f18d 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -1,10 +1,9 @@ # Examples The following examples demonstrate the core workflows of the Cuvis SDK. -Each example is available in Python (Jupyter notebook), C, and C++. - -Use the language tabs within each example to switch between implementations. -Clicking a tab (e.g. **C++**) syncs all code blocks on the page to that language. +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: diff --git a/mkdocs.yml b/mkdocs.yml index 6e34f49..2df0514 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,7 +35,7 @@ theme: - search.highlight - content.tabs.link - content.code.copy - - content.code.annotation + - content.code.annotate markdown_extensions: - admonition diff --git a/scripts/tests/test_example_pages.py b/scripts/tests/test_example_pages.py index 4c1e06d..823f1fd 100644 --- a/scripts/tests/test_example_pages.py +++ b/scripts/tests/test_example_pages.py @@ -1,10 +1,19 @@ +import json import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "tools")) -from example_pages import _normalize_markdown_lists +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(): @@ -43,3 +52,48 @@ 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/example_pages.py b/tools/example_pages.py index 82c6b97..37eb97e 100644 --- a/tools/example_pages.py +++ b/tools/example_pages.py @@ -61,7 +61,7 @@ def _parse_notebook(nb_path: Path) -> list[tuple[str, str]]: elif ctype == "code": pending_code.append(src) - if pending_code: + if pending_prose or pending_code: sections.append(("\n\n".join(pending_prose), "\n\n".join(pending_code))) return sections From 3523bc813128d027e2fa48d7490ae4c2502a587d Mon Sep 17 00:00:00 2001 From: Nima Ghorbani Date: Wed, 3 Jun 2026 10:46:52 +0200 Subject: [PATCH 07/10] docs: include Home page in llms.txt index and clarify scope Add index.md to the mkdocs-llmstxt sections so the overview page is in the generated llms.txt / llms-full.txt, and reword the description to say the index covers the guide and Python API (the C/C++ reference stays browsable on the site). The generated mkdoxy pages are deliberately not listed so the strict build keeps no dependency on plugin ordering. --- CHANGELOG.md | 1 + mkdocs.yml | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c10d7..f0ef358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,3 +8,4 @@ - **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/mkdocs.yml b/mkdocs.yml index 2df0514..6b17324 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,11 +56,14 @@ plugins: - search - llmstxt: markdown_description: | - Cuvis SDK is the official SDK for Cubert hyperspectral cameras. - It provides C, C++, and Python APIs for camera control, data acquisition, - and hyperspectral data processing. + 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: From 1ebecdd35d884f6e27df7533b4edc2e104a8f622 Mon Sep 17 00:00:00 2001 From: Nima Ghorbani Date: Wed, 3 Jun 2026 10:50:19 +0200 Subject: [PATCH 08/10] docs(changelog): record the docs-site migration and versioned-docs setup The Unreleased section logged the rebrand and the CI/review fixes but not the headline change this branch makes: migrating the docs site into the repo with mike versioning, mkdoxy/mkdocstrings API reference, notebook example pages, and the llms.txt index. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ef358..fb6f0e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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`. From 0b0440a7daa92b44748a31d538503ca56af38aff Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Wed, 3 Jun 2026 12:28:42 +0200 Subject: [PATCH 09/10] docs: adding helper function to correctly copy into the doc strings from cuvis-il wip --- .github/workflows/deploy-docs-versioned.yml | 2 +- .github/workflows/deploy-docs.yml | 2 +- .github/workflows/docs-check.yml | 2 +- mkdocs.yml | 5 +- tools/griffe_copydoc.py | 95 +++++++++++++++++++++ 5 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 tools/griffe_copydoc.py diff --git a/.github/workflows/deploy-docs-versioned.yml b/.github/workflows/deploy-docs-versioned.yml index 7e0c06a..3404bcc 100644 --- a/.github/workflows/deploy-docs-versioned.yml +++ b/.github/workflows/deploy-docs-versioned.yml @@ -37,7 +37,7 @@ jobs: enable-cache: true - name: Sync docs deps - run: uv sync --extra docs + run: uv venv --system-site-packages && uv sync --extra docs - name: Configure git for mike run: | diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index fcbaba0..918bee2 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -36,7 +36,7 @@ jobs: enable-cache: true - name: Sync docs deps - run: uv sync --extra docs + run: uv venv --system-site-packages && uv sync --extra docs - name: Configure git for mike run: | diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 79115ea..b0df99e 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -36,7 +36,7 @@ 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 (strict) run: uv run mkdocs build --strict diff --git a/mkdocs.yml b/mkdocs.yml index 6b17324..62aa307 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -104,7 +104,10 @@ plugins: show_source: false show_bases: true show_root_heading: true - docstring_style: google + show_if_no_docstring: true + docstring_style: sphinx + extensions: + - tools/griffe_copydoc.py:CopydocExtension extra_javascript: - javascripts/sdk-installer.js 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 From fc45bf5b6d714ba67be421558d330caca7414982 Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Wed, 3 Jun 2026 12:43:49 +0200 Subject: [PATCH 10/10] docs: updating version of used docker containers --- .github/workflows/deploy-docs-versioned.yml | 2 +- .github/workflows/deploy-docs.yml | 2 +- .github/workflows/docs-check.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-docs-versioned.yml b/.github/workflows/deploy-docs-versioned.yml index 3404bcc..48022f1 100644 --- a/.github/workflows/deploy-docs-versioned.yml +++ b/.github/workflows/deploy-docs-versioned.yml @@ -16,7 +16,7 @@ jobs: deploy: runs-on: ubuntu-latest container: - image: cubertgmbh/cuvis_pyil:3.5.0-ubuntu24.04 + 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 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 918bee2..f603dd4 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -15,7 +15,7 @@ jobs: deploy: runs-on: ubuntu-latest container: - image: cubertgmbh/cuvis_pyil:3.5.0-ubuntu24.04 + 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 diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index b0df99e..86330ba 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -15,7 +15,7 @@ jobs: build: runs-on: ubuntu-latest container: - image: cubertgmbh/cuvis_pyil:3.5.0-ubuntu24.04 + 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