diff --git a/.issueflows/03-solved-issues/issue1128_original.md b/.issueflows/03-solved-issues/issue1128_original.md new file mode 100644 index 00000000..117f6bc5 --- /dev/null +++ b/.issueflows/03-solved-issues/issue1128_original.md @@ -0,0 +1,9 @@ +# Issue #1128: Docs example widget looks bad + +Source: https://github.com/jepegit/cellpy/issues/1128 + +## Original issue text + +The expanding widgets showing example code do not look nice. There are vertical lines followed by unformatted text (no code highlighting etc). This should be fixed, preferably on docstring level, hypothesising that wrongly formatted docstring is the root cause, or on widget level otherwise + +![image](https://github.com/user-attachments/assets/67a7e3a1-a82f-4cd5-9f70-b5a859f43b94) diff --git a/.issueflows/03-solved-issues/issue1128_plan.md b/.issueflows/03-solved-issues/issue1128_plan.md new file mode 100644 index 00000000..14357c35 --- /dev/null +++ b/.issueflows/03-solved-issues/issue1128_plan.md @@ -0,0 +1,99 @@ +# Issue #1128 — plan: fix the "Example" widget in the API docs + +## Goal + +Make docstring examples render as highlighted `pycon` code in the API +reference instead of nested blockquotes with plain text. Fix at docstring +level (the issue's preferred hypothesis — confirmed as the root cause). + +## Root cause (verified) + +- griffe's Google parser (`griffelib 2.1.0`, + `griffe/_internal/docstrings/google.py`, `_section_kind`) recognises + **`Examples:`** only. **`Example:`** (singular) is not a section title, so + the block falls through to the generic admonition path + (`DocstringSectionAdmonition(kind="example")`). +- mkdocstrings-python renders admonitions as `
` + with the body converted as **markdown**. A line starting with `>>> ` is + three nested markdown blockquotes → the three vertical bars in the + screenshot, and the code is plain paragraph text (prompt stripped). +- Local `zensical build` reproduces it: `site/api/cellpy/index.html` has + `
…` for + `list_templates`. Existing `Examples:` sections on the same pages render + as `language-pycon` highlighted blocks, so no renderer/theme work is + needed. +- Secondary defect visible in the same screenshot: the `Returns:` of + `list_templates` is one wrapped description whose continuation lines sit + at the same indent as the first line, so griffe splits it into four + `dict` items. Continuation lines must be indented deeper than the first. + +## Constraints + +- Docstrings only. No zensical / mkdocstrings template or CSS override + (would mask a format bug and diverge from upstream Google style). +- Match the Google style griffe parses: `Examples:` title, body indented + one level, doctest lines `>>>`/`...`, free text allowed between examples. +- Docs live on `master`, preview with `uv run --group docs zensical build` + ([docs-on-master.md](../04-designs-and-guides/docs-on-master.md)). +- `_old_docs/`, tests and notebooks untouched. + +### Prior art + +- #1015 (`show_source = true` in `zensical.toml`) and #1023 docs passes — + same API pages; coexist. +- #1125 (just closed) touched `cellreader.py` docstrings — rebase-safe, + different lines. +- `.issueflows/00-tools/check_docs_relative_links.py` — run after build. +- 27 docstrings already use `Examples:` correctly (`rg '^\s*Examples:\s*$' + src/cellpy`) — the convention to mirror. +- Graph not needed. + +## Approach + +1. Rename the 11 `Example:` section titles to `Examples:` + (`rg -n '^\s*Example:\s*$' src/cellpy`): + `cli_api.py` (`list_templates`), `ica.py` ×3, `collect/ica.py`, + `collect/dva.py`, `readers/cellreader.py` ×2 (`from_source`, + `fetch_meta`), `parameters/internal_settings.py`, `batch/facade.py` + (`load`), `readers/instruments/base.py` (`get_raw_units`). +2. The two non-doctest examples (`facade.load`, `base.get_raw_units`) use + RST `::` literal blocks. Under `Examples:` the prose stays markdown and + the `::` renders as a literal colon; convert them to fenced + ```` ```python ```` blocks so they highlight. +3. Fix the `list_templates` `Returns:` continuation indent (one description, + not four items). +4. Rebuild docs; assert zero `
` remain under + `site/api/` and the former spots now contain `language-pycon` / + `language-python` blocks. +5. Guard against regression: a small test under `tests/` that scans + `src/cellpy/**/*.py` docstrings for a bare `Example:` section title and + fails with the file:line — cheap, pure stdlib (`ast` + regex), marked + `essential`. Prevents the next copy-paste from reintroducing the bug. + +## Files to touch + +- `src/cellpy/cli_api.py`, `src/cellpy/ica.py`, `src/cellpy/collect/ica.py`, + `src/cellpy/collect/dva.py`, `src/cellpy/readers/cellreader.py`, + `src/cellpy/parameters/internal_settings.py`, `src/cellpy/batch/facade.py`, + `src/cellpy/readers/instruments/base.py` — docstring section titles (and + two fenced blocks, one Returns indent). +- `tests/test_docstring_sections.py` — new guard test. +- `.issueflows/01-current-issues/issue1128_status.md` — new. +- `HISTORY.md` at close. + +## Test strategy + +- `uv run pytest tests/test_docstring_sections.py` (new; fails before, passes + after). +- `MPLBACKEND=Agg uv run pytest -m essential`. +- `uv run --group docs zensical build --clean` → no issues; grep `site/api` + for `
>>` → three nested `
`s (the vertical + bars in the issue screenshot), code loses highlighting and prompts. +- Renamed all 11 `Example:` → `Examples:` (`cli_api.py`, `ica.py` ×3, + `collect/ica.py`, `collect/dva.py`, `readers/cellreader.py` ×2, + `parameters/internal_settings.py`, `batch/facade.py`, + `readers/instruments/base.py`). +- `facade.load` and `base.get_raw_units` prose examples: RST `::` literal + blocks → fenced ```` ```python ```` so they highlight under `Examples:`. +- `list_templates` `Returns:` continuation lines indented → one `dict` + item instead of four. +- New `tests/test_docstring_sections.py` (`essential`): `ast` scan of + `src/cellpy` docstrings, fails on any bare `Example:` title with + `file:line`. Fails on master (lists all 11), passes here. Registry row + added in `04-designs-and-guides/test-registry.md`. +- `HISTORY.md` bullet. + +## Verification + +- `uv run --group docs zensical build --clean` → `No issues found`; + `rg '
>>` prompts turned into nested blockquotes. Renamed to `Examples:`, the + two prose examples use fenced code blocks, the `list_templates` return + description is one item, and a small essential test fails on any new + `Example:` title. (#1128) + * Docs and docstrings made instrument neutral: generic prose in `cellreader` no longer calls raw files "res-files" or cellpy files "hdf5 files"; copy-paste docstrings in the Neware xlsx and Biologic mpr diff --git a/src/cellpy/batch/facade.py b/src/cellpy/batch/facade.py index 5d44d884..0890cf55 100644 --- a/src/cellpy/batch/facade.py +++ b/src/cellpy/batch/facade.py @@ -1112,14 +1112,18 @@ def load( name exactly or the dump raises. Leave it False unless the raw tree is large enough that a per-cell walk hurts. - Example: - First load (leave serial on the wire):: + Examples: + First load (leave serial on the wire): - b = batch.load(name="exp", project="Proj") + ```python + b = batch.load(name="exp", project="Proj") + ``` - Later reopen from saved ``.cellpy`` files:: + Later reopen from saved ``.cellpy`` files: - b = batch.load(name="exp", project="Proj", executor="threads") + ```python + b = batch.load(name="exp", project="Proj", executor="threads") + ``` Returns: Populated `Batch`. diff --git a/src/cellpy/cli_api.py b/src/cellpy/cli_api.py index cd4fe578..001b5121 100644 --- a/src/cellpy/cli_api.py +++ b/src/cellpy/cli_api.py @@ -2031,11 +2031,11 @@ def list_templates() -> dict: Returns: dict: ``default`` (the template used when none is given), ``registered`` - (the GitHub-hosted templates as ``{name: location}``), ``local`` (the - same shape for templates found in the template directory), and - ``templatedir`` (where the local ones are looked up). + (the GitHub-hosted templates as ``{name: location}``), ``local`` (the + same shape for templates found in the template directory), and + ``templatedir`` (where the local ones are looked up). - Example: + Examples: >>> templates = list_templates() >>> templates["default"] in templates["registered"] True diff --git a/src/cellpy/collect/dva.py b/src/cellpy/collect/dva.py index a6adc78c..9d84a2e9 100644 --- a/src/cellpy/collect/dva.py +++ b/src/cellpy/collect/dva.py @@ -29,7 +29,7 @@ def collect_dva(batch: Any, options: Any = None, **overrides) -> Collection: every iteration, so a cell missing a cycle never narrows the request for the cells after it (mirrors `collect_ica`). - Example: + Examples: >>> from cellpy import ica >>> from cellpy.collect import collect_dva >>> opts = ica.DVA_DEFAULTS.replace(capacity_resolution=5.0) diff --git a/src/cellpy/collect/ica.py b/src/cellpy/collect/ica.py index 719459f8..b0574446 100644 --- a/src/cellpy/collect/ica.py +++ b/src/cellpy/collect/ica.py @@ -101,7 +101,7 @@ def collect_ica(batch: Any, options: Any = None, **overrides) -> Collection: every iteration, so a cell missing a cycle never narrows the request for the cells after it. - Example: + Examples: >>> from cellpy import ica >>> from cellpy.collect import collect_ica >>> opts = ica.IcaOptions(voltage_resolution=0.005, voltage_fwhm=0.015) diff --git a/src/cellpy/ica.py b/src/cellpy/ica.py index 948b687d..d5b3f6d5 100644 --- a/src/cellpy/ica.py +++ b/src/cellpy/ica.py @@ -466,7 +466,7 @@ def transform_half_cycle( NullData: If either array is missing, or has one point or fewer. ValueError: If *derivative* is not a known mode. - Example: + Examples: >>> capacity, voltage = c.get_ccap(5, as_frame=False) >>> result = transform_half_cycle(voltage, capacity) >>> result.x, result.y # voltage, dQ/dV @@ -813,7 +813,7 @@ def dqdv( ``capacity``, ``dqdv``. ``frame.attrs`` carries the options used, the resolved cycle mode, and any per-half-cycle failures. - Example: + Examples: >>> frame = dqdv(c, cycles=[1, 2], voltage_resolution=0.005) >>> charge = frame[frame.direction == "charge"] """ @@ -857,7 +857,7 @@ def dvdq( *positions* on the capacity axis, so rescaling the ordinate would only obscure the comparison between cycles. - Example: + Examples: >>> frame = dvdq(c, cycles=1, direction="charge") >>> frame.plot(x="capacity", y="dvdq") """ diff --git a/src/cellpy/parameters/internal_settings.py b/src/cellpy/parameters/internal_settings.py index db01b956..67883128 100644 --- a/src/cellpy/parameters/internal_settings.py +++ b/src/cellpy/parameters/internal_settings.py @@ -347,7 +347,7 @@ def to_frame(self): class BaseHeaders(BaseSettings): """Subclass of BaseSetting including option to add postfixes. - Example: + Examples: >>> header["key_postfix"] # returns "value_postfix" """ diff --git a/src/cellpy/readers/cellreader.py b/src/cellpy/readers/cellreader.py index ed484b4b..7323230e 100644 --- a/src/cellpy/readers/cellreader.py +++ b/src/cellpy/readers/cellreader.py @@ -736,7 +736,7 @@ def from_source(cls, source, key=None, *, kind="cell_name", project=None, **kwar project=project, **kwargs)``: the record's file pointers are opened (``filefinder`` only when it has none) and its metadata applied. - Example: + Examples: >>> c = CellpyCell.from_source("batbase", "SAL_010", kind="tag") """ return get(source=source, key=key, kind=kind, project=project, **kwargs) @@ -2292,7 +2292,7 @@ def fetch_meta( The matching ``MetaRecord`` tuple — possibly empty, in which case nothing was changed. - Example: + Examples: >>> c = cellpy.get("cell_042.res") >>> c.fetch_meta("batbase", kind="tag", key="SAL_010") >>> c.data.meta_common.mass diff --git a/src/cellpy/readers/instruments/base.py b/src/cellpy/readers/instruments/base.py index f6a13767..192bdc00 100644 --- a/src/cellpy/readers/instruments/base.py +++ b/src/cellpy/readers/instruments/base.py @@ -376,17 +376,19 @@ def get_raw_units() -> dict: Returns: dictionary of units (str) - Example: - A minimum viable implementation could look like this:: - - @staticmethod - def get_raw_units(): - raw_units = dict() - raw_units["current"] = "A" - raw_units["charge"] = "Ah" - raw_units["mass"] = "g" - raw_units["voltage"] = "V" - return raw_units + Examples: + A minimum viable implementation could look like this: + + ```python + @staticmethod + def get_raw_units(): + raw_units = dict() + raw_units["current"] = "A" + raw_units["charge"] = "Ah" + raw_units["mass"] = "g" + raw_units["voltage"] = "V" + return raw_units + ``` """ # This is needed for example when converting the capacity to a specific capacity. diff --git a/tests/test_docstring_sections.py b/tests/test_docstring_sections.py new file mode 100644 index 00000000..46edd003 --- /dev/null +++ b/tests/test_docstring_sections.py @@ -0,0 +1,44 @@ +"""Docstring section titles that the API docs can render (#1128). + +griffe's Google-style parser only recognises ``Examples:``. A singular +``Example:`` falls through to a generic admonition whose body is rendered as +markdown, so ``>>>`` prompts become nested blockquotes and the code loses its +highlighting in the API reference. This test fails on the first offender so +the mistake cannot come back through a copy-pasted docstring. +""" + +from __future__ import annotations + +import ast +import pathlib +import re + +import pytest + +SRC = pathlib.Path(__file__).resolve().parents[1] / "src" / "cellpy" +_SINGULAR_EXAMPLE = re.compile(r"^\s*Example:\s*$", re.MULTILINE) + + +def _docstring_nodes(tree: ast.AST): + for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + doc = ast.get_docstring(node, clean=False) + if doc: + yield node, doc + + +def _singular_example_sections() -> list[str]: + hits = [] + for path in sorted(SRC.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node, doc in _docstring_nodes(tree): + if _SINGULAR_EXAMPLE.search(doc): + line = getattr(node, "lineno", 1) + hits.append(f"{path.relative_to(SRC.parent.parent)}:{line}") + return hits + + +@pytest.mark.essential +def test_docstrings_use_plural_examples_section(): + hits = _singular_example_sections() + assert not hits, "Use 'Examples:' (griffe does not parse 'Example:'):\n " + "\n ".join(hits)