|
| 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 |
0 commit comments