Skip to content

Commit 8fe8f1d

Browse files
Collect Notebooks and Cells with pytest (#7)
1 parent f17af75 commit 8fe8f1d

21 files changed

+593
-153
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Pytest plugin can collect jupyter notebooks and cells which use the `%%ipytest` magic
1213
- Published documentation for `CollectionTree`.
1314

1415
## [0.0.2] - 2025-02-12

docs/parser.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Parser (internal)
2+
3+
The parser will likely be deprecated and merged into the plug-in classes in the future
4+
5+
::: pytest_ipynb2._parser

docs/plugin.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Pytest plugin
2+
3+
::: pytest_ipynb2.plugin

docs/python-api.md

Lines changed: 0 additions & 3 deletions
This file was deleted.

justfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ check:
4848
@- just test
4949
@- just type-check
5050

51+
# format with ruff
52+
format:
53+
uv run ruff format .
54+
5155
#run coverage analysis on python code
5256
cov:
5357
uv run pytest --cov --cov-report html:pycov --cov-report term --cov-context=test

mkdocs.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ watch:
77

88
nav:
99
- index.md
10-
- python-api.md
10+
- plugin.md
1111
- test_helpers.md
12+
- parser.md
1213

1314
theme:
1415
name: "material"

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"--doctest-mdcodeblocks",
8282
"--doctest-glob=*.pyi",
8383
"--doctest-glob=*.md",
84+
"--ignore=tests/assets",
8485
]
8586
testpaths = ["tests", "pytest_ipynb2"]
8687

pytest_ipynb2/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
11
"""Pytest plugin to run tests in Jupyter Notebooks."""
2-
3-
from ._ipynb_parser import Notebook

pytest_ipynb2/_ipynb_parser.py renamed to pytest_ipynb2/_parser.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
import nbformat
77

88

9+
class Cell: ...
10+
11+
912
class Notebook:
1013
"""
1114
An ipython Notebook.
12-
15+
1316
- constructor from Path
1417
- methods to get various cell types
1518
- a `test` cell type identified by the `%%ipytest` cell magic.
@@ -35,4 +38,4 @@ def gettestcells(self) -> dict[int, str]:
3538
cellnr: "".join(cell["source"][1:]).strip()
3639
for cellnr, cell in enumerate(self.contents["cells"])
3740
if cell["cell_type"] == "code" and cell["source"][0].startswith(r"%%ipytest")
38-
}
41+
}

pytest_ipynb2/plugin.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Pytest plugin to collect jupyter Notebooks.
3+
4+
- Identifies all cells which use the `%%ipytest` magic
5+
- adds the notebook, cell and any test functions to the collection tree
6+
- relies on pytest logic and configuration to identify test functions.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import importlib
12+
from typing import TYPE_CHECKING
13+
14+
import pytest
15+
16+
if TYPE_CHECKING:
17+
from collections.abc import Generator
18+
from pathlib import Path
19+
from types import ModuleType
20+
21+
from ._parser import Notebook as _ParsedNotebook
22+
23+
ipynb2_notebook = pytest.StashKey[_ParsedNotebook]()
24+
25+
26+
class Notebook(pytest.File):
27+
"""A collector for jupyter notebooks."""
28+
29+
def collect(self) -> Generator[Cell, None, None]:
30+
"""Yield `Cell`s for all cells which contain tests."""
31+
parsed = _ParsedNotebook(self.path)
32+
for testcellid in parsed.gettestcells():
33+
cell = Cell.from_parent(
34+
parent=self,
35+
name=str(testcellid),
36+
nodeid=str(testcellid),
37+
path=self.path,
38+
)
39+
cell.stash[ipynb2_notebook] = parsed
40+
yield cell
41+
42+
43+
class Cell(pytest.Module):
44+
"""
45+
A collector for jupyter notebook cells.
46+
47+
`pytest` will recognise these cells as `pytest.Module`s and use standard collection on them as it would any other
48+
python module.
49+
"""
50+
51+
def _getobj(self) -> ModuleType:
52+
notebook = self.stash[ipynb2_notebook]
53+
cellsource = notebook.gettestcells()[int(self.nodeid)]
54+
cellspec = importlib.util.spec_from_loader(f"Cell{self.name}", loader=None)
55+
cell = importlib.util.module_from_spec(cellspec)
56+
exec(cellsource, cell.__dict__) # noqa: S102
57+
return cell
58+
59+
60+
def pytest_collect_file(file_path: Path, parent: pytest.Collector) -> Notebook | None:
61+
"""Hook implementation to collect jupyter notebooks."""
62+
if file_path.suffix == ".ipynb":
63+
return Notebook.from_parent(parent=parent, path=file_path)
64+
return None

pytest_ipynb2/pytester_helpers.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010

1111
if TYPE_CHECKING:
1212
from contextlib import suppress
13+
from pathlib import Path
1314

14-
with suppress(ImportError): # not type-checking on python < 3.11
15+
with suppress(ImportError): # not type-checking on python < 3.11
1516
from typing import Self
1617

18+
1719
class CollectionTree:
1820
"""
1921
A (top-down) tree of pytest collection Nodes.
@@ -25,25 +27,29 @@ class CollectionTree:
2527
"""
2628

2729
@classmethod
30+
#TODO(MusicalNinjaDad): #8 Refactor CollectionTree.from_items to be easier to understand.
2831
def from_items(cls, items: list[pytest.Item]) -> Self:
2932
"""Create a CollectionTree from a list of collection items, as returned by `pytester.genitems()`."""
33+
3034
def _from_item(item: pytest.Item | Self) -> Self:
3135
return item if isinstance(item, cls) else cls(node=item, children=None)
32-
33-
items = [_from_item(item) for item in items]
3436

3537
def _get_parents_as_CollectionTrees(items: list[Self]) -> list[Self]: # noqa: N802
3638
"""Returns a list of CollectionTree items for the parents of those provided."""
3739
parents = (item.node.parent for item in items)
3840
items_byparent = {
3941
parent: [item for item in items if item.node.parent == parent]
4042
for parent in parents
41-
}
42-
return [
43-
cls(node = parent, children = list(children))
44-
for parent, children in items_byparent.items()
45-
]
46-
43+
} # fmt: skip
44+
return [cls(node=parent, children=list(children)) for parent, children in items_byparent.items()]
45+
46+
if not items:
47+
# If we don't specifically handle this here, then `all([])` returns `True` later and wierd stuff happens
48+
msg = "Items list is empty."
49+
raise ValueError(msg)
50+
51+
items = [_from_item(item) for item in items]
52+
4753
if all(isinstance(item.node, pytest.Session) for item in items):
4854
assert len(items) == 1, "There should only ever be one session node." # noqa: S101
4955
return next(iter(items))
@@ -93,7 +99,7 @@ def from_dict(cls, tree: dict[tuple[str, type], dict | None]) -> Self:
9399
cls.from_dict({childnode: grandchildren})
94100
for childnode, grandchildren in children.items()
95101
],
96-
)
102+
) # fmt:skip
97103

98104
return cls(node=node, children=None)
99105

@@ -155,3 +161,26 @@ def __repr__(self) -> str:
155161
else:
156162
children_repr = indent("\n".join(repr(child).rstrip() for child in self.children), " ")
157163
return f"{self.node!r}\n{children_repr}\n"
164+
165+
166+
@dataclass
167+
class CollectedDir:
168+
"""
169+
The various elements required to test directory collection.
170+
171+
- `pytester_instance`: pytest.Pytester
172+
- `dir_node`: pytest.Dir
173+
- `items`: list[pytest.Item]
174+
"""
175+
176+
pytester_instance: pytest.Pytester
177+
dir_node: pytest.Dir
178+
items: list[pytest.Item]
179+
180+
181+
@dataclass
182+
class ExampleDir:
183+
"""The various elements to set up a pytester instance."""
184+
185+
files: list[Path]
186+
conftest: str = ""

tests/assets/notebook.ipynb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"x = 1\n",
1919
"y = 2\n",
2020
"\n",
21-
"x + y\n"
21+
"x + y"
2222
]
2323
},
2424
{
@@ -36,8 +36,9 @@
3636
"source": [
3737
"# Define a function\n",
3838
"\n",
39+
"\n",
3940
"def adder(a, b):\n",
40-
" return a+b"
41+
" return a + b"
4142
]
4243
},
4344
{
@@ -48,8 +49,10 @@
4849
"source": [
4950
"%%ipytest\n",
5051
"\n",
52+
"\n",
5153
"def test_adder():\n",
52-
" assert adder(1,2) == 3\n",
54+
" assert adder(1, 2) == 3\n",
55+
"\n",
5356
"\n",
5457
"def test_globals():\n",
5558
" assert x == 1"
@@ -82,7 +85,7 @@
8285
"name": "python",
8386
"nbconvert_exporter": "python",
8487
"pygments_lexer": "ipython3",
85-
"version": "3.13.1"
88+
"version": "3.13.2"
8689
}
8790
},
8891
"nbformat": 4,

tests/assets/notebook.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#ruff: noqa
1+
# ruff: noqa
22
# This cell sets some global variables
33

44
x = 1
@@ -8,14 +8,18 @@
88

99
# Define a function
1010

11+
1112
def adder(a, b):
12-
return a+b
13+
return a + b
14+
1315

1416
def test_adder():
15-
assert adder(1,2) == 3
17+
assert adder(1, 2) == 3
18+
1619

1720
def test_globals():
1821
assert x == 1
1922

23+
2024
def another_function(*args):
21-
return args
25+
return args

0 commit comments

Comments
 (0)