Skip to content

Commit 200c68b

Browse files
Improve test execution times (#31)
1 parent efc5b5b commit 200c68b

File tree

8 files changed

+200
-132
lines changed

8 files changed

+200
-132
lines changed

CHANGELOG.md

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

1010
### Changed
1111

12-
- Consolidated and simplified tests
12+
- Consolidated, simplified tests [#29][pr-29] & made them faster [#31][pr-31]
13+
14+
[pr-29]: https://github.com/MusicalNinjaDad/pytest-ipynb2/pull/29
15+
[pr-31]: https://github.com/MusicalNinjaDad/pytest-ipynb2/pull/31
1316

1417
## [0.2.1] - 2025-02-19
1518

pytest_ipynb2/_pytester_helpers.py

Lines changed: 106 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,31 @@
22

33
from __future__ import annotations
44

5+
import sys
56
from dataclasses import dataclass, field
7+
from functools import cached_property
68
from textwrap import indent
79
from typing import TYPE_CHECKING, Protocol
10+
from warnings import warn
811

912
import nbformat
1013
import pytest
1114

1215
if TYPE_CHECKING:
1316
from contextlib import suppress
1417
from pathlib import Path
18+
from types import FunctionType
19+
from typing import Any
1520

16-
with suppress(ImportError): # not type-checking on python < 3.11
17-
from typing import Self
21+
with suppress(ImportError):
22+
from typing import Self # not type-checking on python < 3.11 so don't care if this fails
23+
24+
if sys.version_info < (3, 10): # dataclass does not offer kw_only on python < 3.10 # pragma: no cover
25+
_dataclass = dataclass
26+
27+
def dataclass(*args, **kwargs): # noqa: ANN002, ANN003
28+
_ = kwargs.pop("kw_only", None)
29+
return _dataclass(*args, **kwargs)
1830

1931

2032
class CollectionTree:
@@ -172,66 +184,124 @@ def __repr__(self) -> str:
172184
return f"{self.node!r}\n{children_repr}\n"
173185

174186

175-
@dataclass
176-
class CollectedDir:
187+
class ExampleDir:
177188
"""
178-
The various elements required to test directory collection.
189+
A directory containing example files and the associated pytester instance.
179190
180-
- `pytester_instance`: pytest.Pytester
191+
- `pytester`: pytest.Pytester
192+
- `path`: pathlib.Path
181193
- `dir_node`: pytest.Dir
182194
- `items`: list[pytest.Item]
183195
"""
184196

185-
pytester_instance: pytest.Pytester
186-
dir_node: pytest.Dir
187-
items: list[pytest.Item]
197+
pytester: pytest.Pytester
198+
path: Path | None = None
188199

200+
def __init__(self, pytester: pytest.Pytester) -> None:
201+
self.pytester = pytester
202+
self.path = self.pytester.path
189203

190-
@dataclass
191-
class ExampleDir:
204+
@cached_property
205+
def dir_node(self) -> pytest.Dir:
206+
return self.pytester.getpathnode(self.path)
207+
208+
@cached_property
209+
def items(self) -> list[pytest.Item]:
210+
return self.pytester.genitems([self.dir_node])
211+
212+
@cached_property
213+
def runresult(self) -> pytest.RunResult:
214+
return self.pytester.runpytest()
215+
216+
217+
@dataclass(kw_only=True)
218+
class ExampleDirSpec:
192219
"""The various elements to set up a pytester instance."""
193220

194-
files: list[Path] = field(default_factory=list)
195221
conftest: str = ""
196222
ini: str = ""
223+
files: list[Path] = field(default_factory=list)
197224
notebooks: dict[str, list[str]] = field(default_factory=dict)
198225

226+
def __hash__(self) -> int:
227+
files = tuple(self.files)
228+
notebooks = tuple((notebook, "\n".join(contents)) for notebook, contents in self.notebooks.items())
229+
return hash((self.conftest, self.ini, files, notebooks))
230+
231+
232+
class FunctionRequest(Protocol):
233+
function: FunctionType
234+
keywords: dict[str, Any]
235+
236+
237+
class ExampleDirRequest(FunctionRequest):
238+
param: ExampleDirSpec
199239

200-
class ExampleDirRequest(Protocol):
201-
"""Typehint to param passed to example_dir."""
202240

203-
param: ExampleDir
241+
@pytest.fixture(scope="module")
242+
def example_dir_cache() -> dict[ExampleDirSpec, ExampleDir]:
243+
return {}
204244

205245

206246
@pytest.fixture
207-
def example_dir(request: ExampleDirRequest, pytester: pytest.Pytester) -> CollectedDir:
247+
def example_dir(
248+
request: ExampleDirRequest,
249+
pytester: pytest.Pytester,
250+
example_dir_cache: dict[ExampleDirSpec, ExampleDir],
251+
) -> ExampleDir:
208252
"""Parameterised fixture. Requires a list of `Path`s to copy into a pytester instance."""
209253
example = request.param
210-
if example.conftest:
211-
pytester.makeconftest(request.param.conftest)
254+
if (cached_dir := example_dir_cache.get(example)) is None:
255+
if example.conftest:
256+
pytester.makeconftest(request.param.conftest)
212257

213-
if example.ini:
214-
pytester.makeini(f"[pytest]\n{example.ini}")
258+
if example.ini:
259+
pytester.makeini(f"[pytest]\n{example.ini}")
215260

216-
for filetocopy in example.files:
217-
pytester.copy_example(str(filetocopy))
261+
for filetocopy in example.files:
262+
pytester.copy_example(str(filetocopy))
218263

219-
for notebook, contents in example.notebooks.items():
220-
nbnode = nbformat.v4.new_notebook()
221-
for cellsource in contents:
222-
cellnode = nbformat.v4.new_code_cell(cellsource)
223-
nbnode.cells.append(cellnode)
224-
nbformat.write(nb=nbnode, fp=pytester.path / f"{notebook}.ipynb")
225-
226-
dir_node = pytester.getpathnode(pytester.path)
227-
228-
return CollectedDir(
229-
pytester_instance=pytester,
230-
dir_node=dir_node,
231-
items=pytester.genitems([dir_node]),
232-
)
264+
for notebook, contents in example.notebooks.items():
265+
nbnode = nbformat.v4.new_notebook()
266+
for cellsource in contents:
267+
cellnode = nbformat.v4.new_code_cell(cellsource)
268+
nbnode.cells.append(cellnode)
269+
nbformat.write(nb=nbnode, fp=pytester.path / f"{notebook}.ipynb")
270+
cached_dir = example_dir_cache[example] = ExampleDir(pytester=pytester)
271+
else:
272+
msg = f"Using cached {cached_dir.path} for {next(iter(request.keywords))}"
273+
warn(msg, stacklevel=1)
274+
return example_dir_cache[example]
233275

234276

235277
def add_ipytest_magic(source: str) -> str:
236278
"""Add %%ipytest magic to the source code."""
237279
return f"%%ipytest\n\n{source}"
280+
281+
282+
def pytest_configure(config: pytest.Config) -> None: # pragma: no cover
283+
# Tests will be needed if this ever becomes public functionality
284+
"""Register autoskip & xfail_for marks."""
285+
config.addinivalue_line("markers", "autoskip: automatically skip test if expected results not provided")
286+
config.addinivalue_line("markers", "xfail_for: xfail specified tests dynamically")
287+
288+
289+
@pytest.hookimpl(tryfirst=True)
290+
def pytest_runtest_setup(item: pytest.Function) -> None: # pragma: no cover
291+
# Tests will be needed if this ever becomes public functionality
292+
if item.get_closest_marker("autoskip"):
293+
test_name = item.originalname.removeprefix("test_")
294+
expected = getattr(item.callspec.getparam("expected_results"), test_name)
295+
if not expected and expected is not None:
296+
item.add_marker(pytest.mark.skip(reason="No expected results"))
297+
298+
299+
def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: # pragma: no cover
300+
# Tests will be needed if this ever becomes public functionality
301+
"""xfail on presence of a custom marker: `xfail_for(tests:list[str], reasons:list[str])`.""" # noqa: D403
302+
for item in items:
303+
test_name = item.originalname.removeprefix("test_")
304+
if xfail_for := item.get_closest_marker("xfail_for"):
305+
for xfail_test, reason in zip(xfail_for.kwargs.get("tests"), xfail_for.kwargs.get("reasons")):
306+
if xfail_test == test_name:
307+
item.add_marker(pytest.mark.xfail(reason=reason, strict=True))

ruff-main.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@
22
extend = "pyproject.toml"
33
lint.extend-select = [
44
"TD", # TODOs need a description, even outside of `main`
5+
]
6+
lint.extend-ignore = [
7+
"TD002", # Don't need TODO authors - github issue gives that info
58
]

tests/conftest.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1 @@
1-
import pytest
2-
31
pytest_plugins = ["pytester", "pytest_ipynb2._pytester_helpers"]
4-
5-
6-
def pytest_configure(config: pytest.Config) -> None:
7-
"""Register custom xfail mark."""
8-
config.addinivalue_line("markers", "xfail_for: xfail specified tests dynamically")
9-
10-
11-
def pytest_collection_modifyitems(items: list[pytest.Function]) -> None:
12-
"""xfail on presence of a custom marker: `xfail_for(tests:list[str], reasons:list[str])`.""" # noqa: D403
13-
for item in items:
14-
test_name = item.originalname.removeprefix("test_")
15-
if xfail_for := item.get_closest_marker("xfail_for"):
16-
for xfail_test, reason in zip(xfail_for.kwargs.get("tests"), xfail_for.kwargs.get("reasons")):
17-
if xfail_test == test_name:
18-
item.add_marker(pytest.mark.xfail(reason=reason, strict=True))

tests/test_collection.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,18 @@
55

66
import pytest_ipynb2
77
import pytest_ipynb2.plugin
8-
from pytest_ipynb2._pytester_helpers import CollectedDir, CollectionTree, ExampleDir
8+
from pytest_ipynb2._pytester_helpers import CollectionTree, ExampleDir, ExampleDirSpec
99

1010
if TYPE_CHECKING:
1111
from pytest_ipynb2.plugin import Cell
1212

1313

1414
@pytest.fixture
15-
def expected_tree(request: pytest.FixtureRequest, example_dir: CollectedDir) -> CollectionTree:
15+
def expected_tree(request: pytest.FixtureRequest, example_dir: ExampleDir) -> CollectionTree:
1616
trees = {
1717
"notebook": {
1818
("<Session exitstatus='<UNSET>' testsfailed=0 testscollected=0>", pytest.Session): {
19-
(f"<Dir {example_dir.pytester_instance.path.name}>", pytest.Dir): {
19+
(f"<Dir {example_dir.path.name}>", pytest.Dir): {
2020
("<Notebook notebook.ipynb>", pytest_ipynb2.plugin.Notebook): {
2121
("<Cell 4>", pytest_ipynb2.plugin.Cell): {
2222
("<Function test_adder>", pytest.Function): None,
@@ -28,7 +28,7 @@ def expected_tree(request: pytest.FixtureRequest, example_dir: CollectedDir) ->
2828
},
2929
"notebook_2tests": {
3030
("<Session exitstatus='<UNSET>' testsfailed=0 testscollected=0>", pytest.Session): {
31-
(f"<Dir {example_dir.pytester_instance.path.name}>", pytest.Dir): {
31+
(f"<Dir {example_dir.path.name}>", pytest.Dir): {
3232
("<Notebook notebook_2tests.ipynb>", pytest_ipynb2.plugin.Notebook): {
3333
("<Cell 4>", pytest_ipynb2.plugin.Cell): {
3434
("<Function test_adder>", pytest.Function): None,
@@ -43,7 +43,7 @@ def expected_tree(request: pytest.FixtureRequest, example_dir: CollectedDir) ->
4343
},
4444
"both notebooks": {
4545
("<Session exitstatus='<UNSET>' testsfailed=0 testscollected=0>", pytest.Session): {
46-
(f"<Dir {example_dir.pytester_instance.path.name}>", pytest.Dir): {
46+
(f"<Dir {example_dir.path.name}>", pytest.Dir): {
4747
("<Notebook notebook.ipynb>", pytest_ipynb2.plugin.Notebook): {
4848
("<Cell 4>", pytest_ipynb2.plugin.Cell): {
4949
("<Function test_adder>", pytest.Function): None,
@@ -70,23 +70,23 @@ def expected_tree(request: pytest.FixtureRequest, example_dir: CollectedDir) ->
7070
["example_dir", "expected_tree"],
7171
[
7272
pytest.param(
73-
ExampleDir(
73+
ExampleDirSpec(
7474
files=[Path("tests/assets/notebook.ipynb").absolute()],
7575
conftest="pytest_plugins = ['pytest_ipynb2.plugin']",
7676
),
7777
"notebook",
7878
id="Simple Notebook",
7979
),
8080
pytest.param(
81-
ExampleDir(
81+
ExampleDirSpec(
8282
files=[Path("tests/assets/notebook_2tests.ipynb").absolute()],
8383
conftest="pytest_plugins = ['pytest_ipynb2.plugin']",
8484
),
8585
"notebook_2tests",
8686
id="Notebook 2 tests",
8787
),
8888
pytest.param(
89-
ExampleDir(
89+
ExampleDirSpec(
9090
files=[
9191
Path("tests/assets/notebook_2tests.ipynb").absolute(),
9292
Path("tests/assets/notebook.ipynb").absolute(),
@@ -99,15 +99,15 @@ def expected_tree(request: pytest.FixtureRequest, example_dir: CollectedDir) ->
9999
],
100100
indirect=True,
101101
)
102-
def test_cell_collected(example_dir: CollectedDir, expected_tree: CollectionTree):
102+
def test_cell_collected(example_dir: ExampleDir, expected_tree: CollectionTree):
103103
assert CollectionTree.from_items(example_dir.items) == expected_tree
104104

105105

106106
@pytest.mark.parametrize(
107107
"example_dir",
108108
[
109109
pytest.param(
110-
ExampleDir(
110+
ExampleDirSpec(
111111
files=[Path("tests/assets/notebook.ipynb").absolute()],
112112
conftest="pytest_plugins = ['pytest_ipynb2.plugin']",
113113
),
@@ -116,7 +116,7 @@ def test_cell_collected(example_dir: CollectedDir, expected_tree: CollectionTree
116116
],
117117
indirect=True,
118118
)
119-
def test_notebook_collection(example_dir: CollectedDir):
119+
def test_notebook_collection(example_dir: ExampleDir):
120120
files = list(example_dir.dir_node.collect())
121121
assert files
122122
assert len(files) == 1
@@ -128,7 +128,7 @@ def test_notebook_collection(example_dir: CollectedDir):
128128
"example_dir",
129129
[
130130
pytest.param(
131-
ExampleDir(
131+
ExampleDirSpec(
132132
files=[Path("tests/assets/notebook.ipynb").absolute()],
133133
conftest="pytest_plugins = ['pytest_ipynb2.plugin']",
134134
),
@@ -137,7 +137,7 @@ def test_notebook_collection(example_dir: CollectedDir):
137137
],
138138
indirect=True,
139139
)
140-
def test_cell_collection(example_dir: CollectedDir):
140+
def test_cell_collection(example_dir: ExampleDir):
141141
files = list(example_dir.dir_node.collect())
142142
cells = list(files[0].collect())
143143
assert cells
@@ -150,7 +150,7 @@ def test_cell_collection(example_dir: CollectedDir):
150150
"example_dir",
151151
[
152152
pytest.param(
153-
ExampleDir(
153+
ExampleDirSpec(
154154
files=[Path("tests/assets/notebook.ipynb").absolute()],
155155
conftest="pytest_plugins = ['pytest_ipynb2.plugin']",
156156
),
@@ -159,7 +159,7 @@ def test_cell_collection(example_dir: CollectedDir):
159159
],
160160
indirect=True,
161161
)
162-
def test_functions(example_dir: CollectedDir):
162+
def test_functions(example_dir: ExampleDir):
163163
files = list(example_dir.dir_node.collect())
164164
cells = list(files[0].collect())
165165
functions = list(cells[0].collect())
@@ -174,7 +174,7 @@ def test_functions(example_dir: CollectedDir):
174174
"example_dir",
175175
[
176176
pytest.param(
177-
ExampleDir(
177+
ExampleDirSpec(
178178
files=[Path("tests/assets/notebook.ipynb").absolute()],
179179
conftest="pytest_plugins = ['pytest_ipynb2.plugin']",
180180
),
@@ -183,7 +183,7 @@ def test_functions(example_dir: CollectedDir):
183183
],
184184
indirect=True,
185185
)
186-
def test_cellmodule_contents(example_dir: CollectedDir):
186+
def test_cellmodule_contents(example_dir: ExampleDir):
187187
cell: Cell = example_dir.items[0].parent
188188
expected_attrs = ["x", "y", "adder", "test_adder", "test_globals"]
189189
public_attrs = [attr for attr in cell._obj.__dict__ if not attr.startswith("__")] # noqa: SLF001

0 commit comments

Comments
 (0)