Skip to content

Commit acbd54b

Browse files
Fix vscode integration (#54)
1 parent fbc42e8 commit acbd54b

File tree

12 files changed

+322
-252
lines changed

12 files changed

+322
-252
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,7 @@ cython_debug/
172172

173173
# Jupyter Book (via myst) build outputs
174174
_build
175+
176+
!.logs/*.log
177+
178+
**/envVars.txt

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [unreleased]
9+
10+
### Fixed
11+
12+
- Integration with vscode Test Explorer [#54][pr-54]
13+
14+
### Changed
15+
16+
- CellPath now uses subscript format `path/to/notebook.ipynb[Celln]` [#54][pr-54]
17+
18+
[pr-54]: https://github.com/MusicalNinjaDad/pytest-ipynb2/pull/54
19+
820
## [0.4.0] - 2025-03-03
921

1022
### Fixed

README.md

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,9 @@ Usage is very simple:
2020
pip install pytest-ipynb2
2121
```
2222

23-
1. Enable by adding to the default options in `pyproject.toml`:
23+
1. That's it! pytest will now collect and execute any tests in jupyter notebooks when run from the command line or IDE.
2424
25-
```toml
26-
[tool.pytest.ini_options]
27-
addopts = [
28-
"-p pytest_ipynb2.plugin",
29-
]
30-
```
31-
32-
1. That's it! pytest will now collect and execute any tests in jupyter notebooks when run form the command line or IDE.
25+
1. If you want to run a specific test you can pass it on the command line in the format `pytest path/to/notebook.ipynb[Cell3]::test_name`
3326
3427
## Test identification
3528

__pyversion__

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.4.0
1+
0.5.0

justfile

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ clean:
2222
find . -type f -name "*.egg" -delete
2323
find . -type f -name "*.so" -delete
2424

25+
clean-logs:
26+
rm -rf .logs/pytest_ipynb2.log
27+
touch .logs/pytest_ipynb2.log
28+
2529
# clean, remove existing .venvs and rebuild the venvs with uv sync
2630
reset: clean install
2731

@@ -72,4 +76,10 @@ show-cov:
7276

7377
# serve python docs on localhost:3000
7478
docs:
75-
uv run mkdocs serve
79+
uv run mkdocs serve
80+
81+
# use our versions of vscode extensions
82+
symlink-vscode:
83+
rm -rf ~/.vscode-server/extensions/ms-python.python-2025.2.0-linux-x64
84+
ln -s /workspaces/pytest-ipynb2/.vscode-server/extensions/ms-python.python-2025.2.0-linux-x64 \
85+
~/.vscode-server/extensions/ms-python.python-2025.2.0-linux-x64

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
[tool.setuptools.dynamic]
3333
version = { file = "__pyversion__" }
3434

35+
[project.entry-points.pytest11]
36+
pytest-ipynb2 = "pytest_ipynb2.plugin"
37+
3538
# ===========================
3639
# Build, package
3740
# ===========================
@@ -123,7 +126,7 @@
123126
line-length = 120
124127
format.skip-magic-trailing-comma = false
125128
format.quote-style = "double"
126-
extend-exclude = ["tests/assets"]
129+
extend-exclude = ["tests/assets", ".*"]
127130

128131
[tool.ruff.lint]
129132
select = ["ALL"]

pytest_ipynb2/_cellpath.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
import types
5+
from contextlib import suppress
6+
from functools import cached_property
7+
from pathlib import Path
8+
from typing import TYPE_CHECKING
9+
10+
import _pytest._code.code
11+
import _pytest.nodes
12+
import _pytest.pathlib
13+
14+
if TYPE_CHECKING:
15+
from collections.abc import Generator
16+
from os import PathLike
17+
from types import FunctionType, ModuleType
18+
from typing import Any, Final, Self
19+
20+
import pytest
21+
22+
CELL_PREFIX: Final[str] = "Cell"
23+
24+
if sys.version_info < (3, 12): # pragma: no cover
25+
# Can't subclass `pathlib.Path` directly in python < 3.12
26+
_Path = Path
27+
Path: Final[type] = type(_Path())
28+
29+
30+
class CellPath(Path):
31+
"""Provide handling of Cells specified as `path/to/file[Celln]`."""
32+
33+
def __eq__(self, other: object) -> bool:
34+
"""Equality testing handled by `pathlib.Path`."""
35+
return Path(self) == other
36+
37+
def __hash__(self) -> int:
38+
"""Hashing handled by `pathlib.Path`."""
39+
return super().__hash__()
40+
41+
def exists(self, *args: Any, **kwargs: Any) -> bool:
42+
"""(Only) check that the notebook exists."""
43+
# TODO: #33 Extend `CellPath.exists` to also check that the cell exists (if performance allows)
44+
return self.notebook.exists(*args, **kwargs)
45+
46+
if sys.version_info < (3, 13): # pragma: no cover
47+
48+
def relative_to(self, other: PathLike, *args: Any, **kwargs: Any) -> Self:
49+
"""Relative_to only works out-of-the-box on python 3.13 and above."""
50+
return type(self)(f"{self.notebook.relative_to(other, *args, **kwargs)}[{self.cell}]")
51+
52+
@cached_property
53+
def notebook(self) -> Path:
54+
"""Path of the notebook."""
55+
return self.get_notebookpath(str(self))
56+
57+
@cached_property
58+
def cell(self) -> str:
59+
"""The cell specifier (e.g. "Cell0")."""
60+
return f"{CELL_PREFIX}{self.get_cellid(str(self))}"
61+
62+
@classmethod
63+
def is_cellpath(cls, path: str) -> bool:
64+
"""Determine whether a str is a valid representation of our pseudo-path."""
65+
return path.split(".")[-1].startswith("ipynb") and path.split(f"[{CELL_PREFIX}")[-1].removesuffix("]").isdigit()
66+
67+
@classmethod
68+
def get_notebookpath(cls, path: str) -> Path:
69+
"""Return the real path of the notebook based on a pseudo-path."""
70+
notebookpath = path.split(f"[{CELL_PREFIX}")[0]
71+
return Path(notebookpath)
72+
73+
@classmethod
74+
def get_cellid(cls, path: str) -> int:
75+
"""Return the Cell id from the pseudo-path."""
76+
cellid = path.split(f"[{CELL_PREFIX}")[-1].removesuffix("]")
77+
return int(cellid)
78+
79+
@classmethod
80+
def to_nodeid(cls, path: str) -> str:
81+
"""
82+
Convert a pseudo-path to an equivalent nodeid.
83+
84+
Examples:
85+
'notebook.ipynb[Cell0]::test_func' -> 'notebook.ipynb::Cell0::test_func'
86+
'notebook.ipynb[Cell1]' -> 'notebook.ipynb::Cell1'
87+
"""
88+
cellpath, *nodepath = path.split("::")
89+
notebookpath = f"{cls.get_notebookpath(cellpath)}"
90+
cell = f"{CELL_PREFIX}{cls.get_cellid(cellpath)}"
91+
return "::".join((notebookpath, cell, *nodepath))
92+
93+
class PytestItemMixin:
94+
"""Provides various overrides to handle our pseudo-path."""
95+
96+
# TODO: #51 Use metaclass to remove direct references to `CellPath` in `CellPath.PytestItemMixin`
97+
path: CellPath
98+
name: str
99+
100+
def reportinfo(self) -> tuple[Path, int, str]:
101+
"""
102+
Returns tuple of notebook path, (linenumber=)0, Celln::testname.
103+
104+
`reportinfo` is used by `location` and included as the header line in the report:
105+
```
106+
==== FAILURES ====
107+
___ reportinfo[2] ___
108+
```
109+
"""
110+
# `nodes.Item.location` calls `absolutepath()` and then `main._node_location_to_relpath` which caches the
111+
# results in `_bestrelpathcache[node_path]` very early in the test process.
112+
# As we provide the full CellPath as reportinfo[0] we need to patch `_pytest.nodes.absolutepath` in
113+
# `CellPath.patch_pytest_pathlib` (above)
114+
#
115+
# `TerminalReporter._locationline` adds a `<-` section if `nodeid.split("::")[0] != location[0]`.
116+
# Verbosity<2 tests runs are grouped by location[0] in the testlog.
117+
return self.path, 0, f"{self.path.cell}::{self.name}"
118+
119+
def collect(self) -> Generator[pytest.Function, None, None]:
120+
"""Rebless children to include our overrides from the Mixin."""
121+
# TODO(MusicalNinjaDad): #22 Handle Tests grouped in Class
122+
for item in super().collect(): # pytype: disable=attribute-error
123+
item_type = type(item)
124+
type_with_mixin = types.new_class(item_type.__name__, (CellPath.PytestItemMixin, item_type))
125+
item.__class__ = type_with_mixin
126+
yield item
127+
128+
@staticmethod
129+
def patch_pytest_absolutepath() -> dict[tuple[ModuleType, str], FunctionType]:
130+
"""Patch _pytest.pathlib functions."""
131+
original_functions = {}
132+
133+
# pytest has some unique handling to get the absolute path of a file. Possbily no longer needed with later
134+
# versions of pathlib? Hopefully we will be able to remove this patch with a later version of pytest.
135+
#
136+
# The original function is defined in _pytest.pathlib but
137+
# both `code` and `nodes` import it as `from .pathlib import absolutepath`
138+
# so we need to patch in both these namespaces...
139+
_pytest_absolutepath = _pytest.pathlib.absolutepath
140+
141+
def _absolutepath(path: str | PathLike[str] | Path) -> Path:
142+
"""Return accurate absolute path for string representations of CellPath."""
143+
# pytype: disable=attribute-error
144+
try:
145+
return path.absolute() # pytest prefers to avoid this, guessing for historical reasons???
146+
except AttributeError:
147+
with suppress(AttributeError): # in case this is not a `str` but some other `PathLike`
148+
if CellPath.is_cellpath(path):
149+
return CellPath(path).absolute()
150+
return _pytest_absolutepath(path)
151+
# pytype: enable=attribute-error
152+
153+
# 1. `code.Code.path` calls `absolutepath(self.raw.co_filename)` which is the info primarily used in
154+
# `TracebackEntry` and therefore relevant for failure reporting.
155+
original_functions[(_pytest._code.code, "absolutepath")] = _pytest_absolutepath # noqa: SLF001
156+
_pytest._code.code.absolutepath = _absolutepath # noqa: SLF001
157+
# 2. `nodes.Item.location` calls `absolutepath()` and then `main._node_location_to_relpath` which caches the
158+
# results of the `absolutepath()` call in `_bestrelpathcache[node_path]` very early in the test process.
159+
original_functions[(_pytest.nodes, "absolutepath")] = _pytest_absolutepath
160+
_pytest.nodes.absolutepath = _absolutepath
161+
162+
return original_functions

pytest_ipynb2/_pytester_helpers.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,10 @@ class ExampleDir:
197197
pytester: pytest.Pytester
198198
path: Path | None = None
199199

200-
def __init__(self, pytester: pytest.Pytester) -> None:
200+
def __init__(self, pytester: pytest.Pytester, args: list[str]) -> None:
201201
self.pytester = pytester
202202
self.path = self.pytester.path
203+
self.args = args
203204

204205
@cached_property
205206
def dir_node(self) -> pytest.Dir:
@@ -211,7 +212,7 @@ def items(self) -> list[pytest.Item]:
211212

212213
@cached_property
213214
def runresult(self) -> pytest.RunResult:
214-
return self.pytester.runpytest()
215+
return self.pytester.runpytest(*self.args)
215216

216217

217218
@dataclass(kw_only=True)
@@ -223,6 +224,7 @@ class ExampleDirSpec:
223224
ini: str = ""
224225
files: list[Path] = field(default_factory=list)
225226
notebooks: dict[str, list[str]] = field(default_factory=dict)
227+
args: list[str] = field(default_factory=list)
226228

227229
def __hash__(self) -> int:
228230
files = tuple(self.files)
@@ -270,7 +272,7 @@ def example_dir(
270272
cellnode = nbformat.v4.new_code_cell(cellsource)
271273
nbnode.cells.append(cellnode)
272274
nbformat.write(nb=nbnode, fp=pytester.path / example.path / f"{notebook}.ipynb")
273-
cached_dir = example_dir_cache[example] = ExampleDir(pytester=pytester)
275+
cached_dir = example_dir_cache[example] = ExampleDir(pytester=pytester, args=example.args)
274276
elif request.config.get_verbosity() >= 3: # noqa: PLR2004 # pragma: no cover
275277
# 1st keyword is the test name (incl. any parametrized id)
276278
msg = f"Using cached {cached_dir.path} for {next(iter(request.keywords))}"

0 commit comments

Comments
 (0)