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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .issueflows/03-solved-issues/issue1128_original.md
Original file line number Diff line number Diff line change
@@ -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)
99 changes: 99 additions & 0 deletions .issueflows/03-solved-issues/issue1128_plan.md
Original file line number Diff line number Diff line change
@@ -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 `<details class="example">`
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
`<details class="example"><blockquote><blockquote><blockquote>…` 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 `<details class="example">` 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 `<details class="example"` → 0 hits.
- `uv run .issueflows/00-tools/check_docs_relative_links.py`.

## Open questions

1. Guard test: keep (recommended, ~25 lines, `essential`) or skip and rely
on review?
2. Fix the `Returns:` wrap in `list_templates` here (recommended — same
screenshot, one-line indent) or leave for a separate sweep of all
`Returns:` blocks?
45 changes: 45 additions & 0 deletions .issueflows/03-solved-issues/issue1128_status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Issue #1128 — status

- [x] Done

Branch: `cursor/1128-docs-example-widget-0881`. Plan accepted 2026-10-01 with
defaults (guard test kept; `Returns:` indent fixed here).

## What's done

- Root cause verified in `griffelib 2.1.0`
(`griffe/_internal/docstrings/google.py::_section_kind`): only
`Examples:` is a section title. `Example:` becomes a generic admonition
rendered as markdown; `>>>` → three nested `<blockquote>`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 '<details class="example"' site/api` → 0 files (was 4 pages).
- `site/api/cellpy/index.html`: `list_templates` now has one `Returns`
item and a `language-pycon` block; `site/api/batch/index.html`:
`Batch.load` examples are `language-python` blocks.
- Screenshot of the rebuilt page: `/opt/cursor/artifacts/issue1128_after_list_templates.png`.
- `uv run .issueflows/00-tools/check_docs_relative_links.py` → all resolve.
- `MPLBACKEND=Agg uv run pytest -m essential` → 982 passed, 74 skipped;
the 2 `test_filefinder.py::test_find_by_project_*` failures are the same
VM-only ones seen on `origin/master` (master CI green).

## Remaining work

- None. Other section-title or wrap defects in docstrings (e.g. multi-line
`Returns:` elsewhere) were not swept; the guard only covers `Example:`.
1 change: 1 addition & 0 deletions .issueflows/04-designs-and-guides/test-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ current issue**. `/iflow-doctor` may audit the whole suite against this table.
| tests/test_source_file_hints.py::test_differing_size_falls_back_to_stat | yes | yes | hint mismatch → stat | #1124 | proves the spy sees stat |
| tests/test_source_file_hints.py::test_record_without_stats_behaves_as_today | yes | yes | no-hint parity | #1124 | today's behaviour kept |
| tests/test_source_file_hints.py (other 6) | no | | mtime-only, bad mtime, force, save/load, re-fetch, batch refresh | #1124 | uses testdata .res |
| tests/test_docstring_sections.py::test_docstrings_use_plural_examples_section | yes | yes | docstring `Examples:` titles under `src/cellpy` (griffe parses only the plural) | #1128 | stdlib `ast` scan, <1 s; stops the API-docs blockquote regression |

**Columns**

Expand Down
8 changes: 8 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

* API reference: docstring examples render as highlighted code again.
Eleven docstrings used a singular `Example:` section title, which griffe
does not recognise, so the block became a markdown admonition and the
`>>>` 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
Expand Down
14 changes: 9 additions & 5 deletions src/cellpy/batch/facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
8 changes: 4 additions & 4 deletions src/cellpy/cli_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/cellpy/collect/dva.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/cellpy/collect/ica.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions src/cellpy/ica.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
"""
Expand Down Expand Up @@ -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")
"""
Expand Down
2 changes: 1 addition & 1 deletion src/cellpy/parameters/internal_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
"""

Expand Down
4 changes: 2 additions & 2 deletions src/cellpy/readers/cellreader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
24 changes: 13 additions & 11 deletions src/cellpy/readers/instruments/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions tests/test_docstring_sections.py
Original file line number Diff line number Diff line change
@@ -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)
Loading