Skip to content

Fix vscode integration #52

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 65 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
b9fd142
Revert "overrriding _traceback_filter doesn't break anything else but…
MusicalNinjaDad Mar 5, 2025
9de40d9
Revert "just keep the notebook as Item.path (breaks exception_repr)"
MusicalNinjaDad Mar 5, 2025
20b3932
Revert "revert changes to path format:"
MusicalNinjaDad Mar 5, 2025
f284ad5
early config works as a plugin
MusicalNinjaDad Mar 5, 2025
50510cc
classmethods not static
MusicalNinjaDad Mar 5, 2025
80edcc0
will parse args
MusicalNinjaDad Mar 5, 2025
a757360
correctly modify CellPaths passed as commandline args
MusicalNinjaDad Mar 5, 2025
59600f5
docstrings
MusicalNinjaDad Mar 5, 2025
4d34c84
update all tests to remove conftest plugins entry
MusicalNinjaDad Mar 5, 2025
baea771
#49 also occurs for std python tests ... so is a fixture issue, not a…
MusicalNinjaDad Mar 5, 2025
a1cefac
fix #49
MusicalNinjaDad Mar 5, 2025
66ead65
typing, fmt, remove debugging warns,
MusicalNinjaDad Mar 5, 2025
0451d62
can call individual functions
MusicalNinjaDad Mar 5, 2025
8d41bd5
improve readability
MusicalNinjaDad Mar 5, 2025
be81e55
fmt
MusicalNinjaDad Mar 5, 2025
a08f07c
store pytest312 test results
MusicalNinjaDad Mar 6, 2025
efe1804
store vscode test results
MusicalNinjaDad Mar 6, 2025
5d42980
supress lint complaints for failing test
MusicalNinjaDad Mar 6, 2025
9304a6d
add logging to plugin
MusicalNinjaDad Mar 6, 2025
af82bc3
fix logging
MusicalNinjaDad Mar 6, 2025
ea24066
just clean-logs
MusicalNinjaDad Mar 6, 2025
f57818e
log from initial vscode debugging session
MusicalNinjaDad Mar 6, 2025
ab20a4e
move .out files to .logs
MusicalNinjaDad Mar 6, 2025
c77b825
add logging to vscodeserver?
MusicalNinjaDad Mar 6, 2025
3b45ea8
remove vscode-server symlink
MusicalNinjaDad Mar 6, 2025
d51b5b8
add original vscode-server python extensions
MusicalNinjaDad Mar 6, 2025
b2ed0f8
script symlinking
MusicalNinjaDad Mar 6, 2025
88919ac
add logging to vscode test explorer
MusicalNinjaDad Mar 6, 2025
4ddc76b
add our logs to git
MusicalNinjaDad Mar 6, 2025
1f6d814
envVars
MusicalNinjaDad Mar 6, 2025
b88efc3
capture testpipe files
MusicalNinjaDad Mar 6, 2025
c50aa2d
only clean specific log
MusicalNinjaDad Mar 6, 2025
56a092d
combine logs into single file
MusicalNinjaDad Mar 6, 2025
8eb4557
add logging to vscode pytest extension
MusicalNinjaDad Mar 6, 2025
653d6eb
temporarily remove most tests to make logs smaller
MusicalNinjaDad Mar 6, 2025
d6c81ed
stop tracking envVars.txt
MusicalNinjaDad Mar 7, 2025
76c1e6e
run test collection
MusicalNinjaDad Mar 7, 2025
5a249f1
only log if DEBUG exists
MusicalNinjaDad Mar 7, 2025
40eb4f3
remove old copy of run_pytest_script.py
MusicalNinjaDad Mar 7, 2025
f61d360
delete notebook from tests (used for debugging)
MusicalNinjaDad Mar 7, 2025
24b1e70
Revert "temporarily remove most tests to make logs smaller"
MusicalNinjaDad Mar 7, 2025
d88b103
update CellPath format to remove <>
MusicalNinjaDad Mar 7, 2025
3fc0dfe
add cell to cellcache and everything works!
MusicalNinjaDad Mar 7, 2025
46619d1
only lint our code
MusicalNinjaDad Mar 7, 2025
48aed27
fix python <3.12 relative_to to use subscripting
MusicalNinjaDad Mar 7, 2025
a127900
simplify absolutepath by removing <> management
MusicalNinjaDad Mar 7, 2025
88f8f0b
explain why we still convert nodeids & raise #50
MusicalNinjaDad Mar 8, 2025
cc62fcb
no longer need to patch linecache - remove unnecessary code
MusicalNinjaDad Mar 8, 2025
79af6a7
no longer need to patch commonpath - remove unnecessary code
MusicalNinjaDad Mar 8, 2025
1b578db
improve monkeypatch safety by using hookwrappers with tryfirst=True
MusicalNinjaDad Mar 8, 2025
c6e69e9
explain mokeypatch stash format
MusicalNinjaDad Mar 8, 2025
b65e6a1
update changelog
MusicalNinjaDad Mar 8, 2025
0beff35
remove logging
MusicalNinjaDad Mar 8, 2025
55b1406
make CellPath.is_cellpath a classmethod to allow others to subclass e…
MusicalNinjaDad Mar 8, 2025
28b74df
reorder plugin.py for logical readability
MusicalNinjaDad Mar 8, 2025
52ae32b
improve readability by moving CellPath to own module and renaming the…
MusicalNinjaDad Mar 8, 2025
040127b
fmt
MusicalNinjaDad Mar 8, 2025
b991966
move reblessing functionality (in Item.collect) to CellPath.PytestIte…
MusicalNinjaDad Mar 8, 2025
693b783
move Mixin outside CellPath class
MusicalNinjaDad Mar 8, 2025
1a9f2cf
Revert "move Mixin outside CellPath class"
MusicalNinjaDad Mar 8, 2025
3028fbe
add TODO #51
MusicalNinjaDad Mar 8, 2025
8a8f534
add pr number to changelog
MusicalNinjaDad Mar 8, 2025
787dcd3
fix Path subclassing on python < 3.12
MusicalNinjaDad Mar 8, 2025
624b41d
fix typing issue on Mixin
MusicalNinjaDad Mar 8, 2025
ab49436
fmt
MusicalNinjaDad Mar 8, 2025
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,7 @@ cython_debug/

# Jupyter Book (via myst) build outputs
_build

!.logs/*.log

**/envVars.txt
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [unreleased]

### Fixed

- Integration with vscode Test Explorer [#52][pr-52]

### Changed

- CellPath now uses subscript format `path/to/notebook.ipynb[Celln]` [#52][pr-52]

[pr-52]: https://github.com/MusicalNinjaDad/pytest-ipynb2/pull/52

## [0.4.0] - 2025-03-03

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion __pyversion__
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.0
0.5.0
12 changes: 11 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ clean:
find . -type f -name "*.egg" -delete
find . -type f -name "*.so" -delete

clean-logs:
rm -rf .logs/pytest_ipynb2.log
touch .logs/pytest_ipynb2.log

# clean, remove existing .venvs and rebuild the venvs with uv sync
reset: clean install

Expand Down Expand Up @@ -72,4 +76,10 @@ show-cov:

# serve python docs on localhost:3000
docs:
uv run mkdocs serve
uv run mkdocs serve

# use our versions of vscode extensions
symlink-vscode:
rm -rf ~/.vscode-server/extensions/ms-python.python-2025.2.0-linux-x64
ln -s /workspaces/pytest-ipynb2/.vscode-server/extensions/ms-python.python-2025.2.0-linux-x64 \
~/.vscode-server/extensions/ms-python.python-2025.2.0-linux-x64
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
[tool.setuptools.dynamic]
version = { file = "__pyversion__" }

[project.entry-points.pytest11]
pytest-ipynb2 = "pytest_ipynb2.plugin"

# ===========================
# Build, package
# ===========================
Expand Down Expand Up @@ -123,7 +126,7 @@
line-length = 120
format.skip-magic-trailing-comma = false
format.quote-style = "double"
extend-exclude = ["tests/assets"]
extend-exclude = ["tests/assets", ".*"]

[tool.ruff.lint]
select = ["ALL"]
Expand Down
162 changes: 162 additions & 0 deletions pytest_ipynb2/_cellpath.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
from __future__ import annotations

import sys
import types
from contextlib import suppress
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING

import _pytest._code.code
import _pytest.nodes
import _pytest.pathlib

if TYPE_CHECKING:
from collections.abc import Generator
from os import PathLike
from types import FunctionType, ModuleType
from typing import Any, Final, Self

import pytest

CELL_PREFIX: Final[str] = "Cell"

if sys.version_info < (3, 12): # pragma: no cover
# Can't subclass `pathlib.Path` directly in python < 3.12
_Path = Path
Path: Final[type] = type(_Path())


class CellPath(Path):
"""Provide handling of Cells specified as `path/to/file[Celln]`."""

def __eq__(self, other: object) -> bool:
"""Equality testing handled by `pathlib.Path`."""
return Path(self) == other

def __hash__(self) -> int:
"""Hashing handled by `pathlib.Path`."""
return super().__hash__()

def exists(self, *args: Any, **kwargs: Any) -> bool:
"""(Only) check that the notebook exists."""
# TODO: #33 Extend `CellPath.exists` to also check that the cell exists (if performance allows)
return self.notebook.exists(*args, **kwargs)

if sys.version_info < (3, 13): # pragma: no cover

def relative_to(self, other: PathLike, *args: Any, **kwargs: Any) -> Self:
"""Relative_to only works out-of-the-box on python 3.13 and above."""
return type(self)(f"{self.notebook.relative_to(other, *args, **kwargs)}[{self.cell}]")

@cached_property
def notebook(self) -> Path:
"""Path of the notebook."""
return self.get_notebookpath(str(self))

@cached_property
def cell(self) -> str:
"""The cell specifier (e.g. "Cell0")."""
return f"{CELL_PREFIX}{self.get_cellid(str(self))}"

@classmethod
def is_cellpath(cls, path: str) -> bool:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Assess robustness of is_cellpath string splitting logic.

The current implementation relies on splitting the string based on the '[Cell' substring. Consider edge cases such as unexpected additional '[' characters or variations of file names that might inadvertently fulfill the condition. If these formats are strictly controlled, the code is acceptable; if not, enhanced validation might be warranted.

Suggested implementation:

import re
# (existing imports)
    @classmethod
    def is_cellpath(cls, path: str) -> bool:
        """Determine whether a str is a valid representation of our pseudo-path."""
        pattern = r'^(?P<notebook>.+\.ipynb)\[' + re.escape(CELL_PREFIX) + r'(?P<cellid>\d+)\]$'
        return re.fullmatch(pattern, path) is not None

If CELL_PREFIX is subject to change or defined elsewhere with different values, ensure that its usage with re.escape is valid across the codebase.

"""Determine whether a str is a valid representation of our pseudo-path."""
return path.split(".")[-1].startswith("ipynb") and path.split(f"[{CELL_PREFIX}")[-1].removesuffix("]").isdigit()

@classmethod
def get_notebookpath(cls, path: str) -> Path:
"""Return the real path of the notebook based on a pseudo-path."""
notebookpath = path.split(f"[{CELL_PREFIX}")[0]
return Path(notebookpath)

@classmethod
def get_cellid(cls, path: str) -> int:
"""Return the Cell id from the pseudo-path."""
cellid = path.split(f"[{CELL_PREFIX}")[-1].removesuffix("]")
return int(cellid)

@classmethod
def to_nodeid(cls, path: str) -> str:
"""
Convert a pseudo-path to an equivalent nodeid.

Examples:
'notebook.ipynb[Cell0]::test_func' -> 'notebook.ipynb::Cell0::test_func'
'notebook.ipynb[Cell1]' -> 'notebook.ipynb::Cell1'
"""
cellpath, *nodepath = path.split("::")
notebookpath = f"{cls.get_notebookpath(cellpath)}"
cell = f"{CELL_PREFIX}{cls.get_cellid(cellpath)}"
return "::".join((notebookpath, cell, *nodepath))

class PytestItemMixin:
"""Provides various overrides to handle our pseudo-path."""

# TODO: #51 Use metaclass to remove direct references to `CellPath` in `CellPath.PytestItemMixin`
path: CellPath
name: str

def reportinfo(self) -> tuple[Path, int, str]:
"""
Returns tuple of notebook path, (linenumber=)0, Celln::testname.

`reportinfo` is used by `location` and included as the header line in the report:
```
==== FAILURES ====
___ reportinfo[2] ___
```
"""
# `nodes.Item.location` calls `absolutepath()` and then `main._node_location_to_relpath` which caches the
# results in `_bestrelpathcache[node_path]` very early in the test process.
# As we provide the full CellPath as reportinfo[0] we need to patch `_pytest.nodes.absolutepath` in
# `CellPath.patch_pytest_pathlib` (above)
#
# `TerminalReporter._locationline` adds a `<-` section if `nodeid.split("::")[0] != location[0]`.
# Verbosity<2 tests runs are grouped by location[0] in the testlog.
return self.path, 0, f"{self.path.cell}::{self.name}"

def collect(self) -> Generator[pytest.Function, None, None]:
"""Rebless children to include our overrides from the Mixin."""
# TODO(MusicalNinjaDad): #22 Handle Tests grouped in Class
for item in super().collect(): # pytype: disable=attribute-error
item_type = type(item)
type_with_mixin = types.new_class(item_type.__name__, (CellPath.PytestItemMixin, item_type))
item.__class__ = type_with_mixin
yield item

@staticmethod
def patch_pytest_absolutepath() -> dict[tuple[ModuleType, str], FunctionType]:
"""Patch _pytest.pathlib functions."""
original_functions = {}

# pytest has some unique handling to get the absolute path of a file. Possbily no longer needed with later
# versions of pathlib? Hopefully we will be able to remove this patch with a later version of pytest.
#
# The original function is defined in _pytest.pathlib but
# both `code` and `nodes` import it as `from .pathlib import absolutepath`
# so we need to patch in both these namespaces...
_pytest_absolutepath = _pytest.pathlib.absolutepath

def _absolutepath(path: str | PathLike[str] | Path) -> Path:
"""Return accurate absolute path for string representations of CellPath."""
# pytype: disable=attribute-error
try:
return path.absolute() # pytest prefers to avoid this, guessing for historical reasons???
except AttributeError:
with suppress(AttributeError): # in case this is not a `str` but some other `PathLike`
if CellPath.is_cellpath(path):
return CellPath(path).absolute()
return _pytest_absolutepath(path)
# pytype: enable=attribute-error

# 1. `code.Code.path` calls `absolutepath(self.raw.co_filename)` which is the info primarily used in
# `TracebackEntry` and therefore relevant for failure reporting.
original_functions[(_pytest._code.code, "absolutepath")] = _pytest_absolutepath # noqa: SLF001
_pytest._code.code.absolutepath = _absolutepath # noqa: SLF001
# 2. `nodes.Item.location` calls `absolutepath()` and then `main._node_location_to_relpath` which caches the
# results of the `absolutepath()` call in `_bestrelpathcache[node_path]` very early in the test process.
original_functions[(_pytest.nodes, "absolutepath")] = _pytest_absolutepath
_pytest.nodes.absolutepath = _absolutepath

return original_functions
8 changes: 5 additions & 3 deletions pytest_ipynb2/_pytester_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,10 @@ class ExampleDir:
pytester: pytest.Pytester
path: Path | None = None

def __init__(self, pytester: pytest.Pytester) -> None:
def __init__(self, pytester: pytest.Pytester, args: list[str]) -> None:
self.pytester = pytester
self.path = self.pytester.path
self.args = args

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

@cached_property
def runresult(self) -> pytest.RunResult:
return self.pytester.runpytest()
return self.pytester.runpytest(*self.args)


@dataclass(kw_only=True)
Expand All @@ -223,6 +224,7 @@ class ExampleDirSpec:
ini: str = ""
files: list[Path] = field(default_factory=list)
notebooks: dict[str, list[str]] = field(default_factory=dict)
args: list[str] = field(default_factory=list)

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