|
2 | 2 |
|
3 | 3 | from __future__ import annotations
|
4 | 4 |
|
| 5 | +import sys |
5 | 6 | from dataclasses import dataclass, field
|
| 7 | +from functools import cached_property |
6 | 8 | from textwrap import indent
|
7 | 9 | from typing import TYPE_CHECKING, Protocol
|
| 10 | +from warnings import warn |
8 | 11 |
|
9 | 12 | import nbformat
|
10 | 13 | import pytest
|
11 | 14 |
|
12 | 15 | if TYPE_CHECKING:
|
13 | 16 | from contextlib import suppress
|
14 | 17 | from pathlib import Path
|
| 18 | + from types import FunctionType |
| 19 | + from typing import Any |
15 | 20 |
|
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) |
18 | 30 |
|
19 | 31 |
|
20 | 32 | class CollectionTree:
|
@@ -172,66 +184,124 @@ def __repr__(self) -> str:
|
172 | 184 | return f"{self.node!r}\n{children_repr}\n"
|
173 | 185 |
|
174 | 186 |
|
175 |
| -@dataclass |
176 |
| -class CollectedDir: |
| 187 | +class ExampleDir: |
177 | 188 | """
|
178 |
| - The various elements required to test directory collection. |
| 189 | + A directory containing example files and the associated pytester instance. |
179 | 190 |
|
180 |
| - - `pytester_instance`: pytest.Pytester |
| 191 | + - `pytester`: pytest.Pytester |
| 192 | + - `path`: pathlib.Path |
181 | 193 | - `dir_node`: pytest.Dir
|
182 | 194 | - `items`: list[pytest.Item]
|
183 | 195 | """
|
184 | 196 |
|
185 |
| - pytester_instance: pytest.Pytester |
186 |
| - dir_node: pytest.Dir |
187 |
| - items: list[pytest.Item] |
| 197 | + pytester: pytest.Pytester |
| 198 | + path: Path | None = None |
188 | 199 |
|
| 200 | + def __init__(self, pytester: pytest.Pytester) -> None: |
| 201 | + self.pytester = pytester |
| 202 | + self.path = self.pytester.path |
189 | 203 |
|
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: |
192 | 219 | """The various elements to set up a pytester instance."""
|
193 | 220 |
|
194 |
| - files: list[Path] = field(default_factory=list) |
195 | 221 | conftest: str = ""
|
196 | 222 | ini: str = ""
|
| 223 | + files: list[Path] = field(default_factory=list) |
197 | 224 | notebooks: dict[str, list[str]] = field(default_factory=dict)
|
198 | 225 |
|
| 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 |
199 | 239 |
|
200 |
| -class ExampleDirRequest(Protocol): |
201 |
| - """Typehint to param passed to example_dir.""" |
202 | 240 |
|
203 |
| - param: ExampleDir |
| 241 | +@pytest.fixture(scope="module") |
| 242 | +def example_dir_cache() -> dict[ExampleDirSpec, ExampleDir]: |
| 243 | + return {} |
204 | 244 |
|
205 | 245 |
|
206 | 246 | @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: |
208 | 252 | """Parameterised fixture. Requires a list of `Path`s to copy into a pytester instance."""
|
209 | 253 | 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) |
212 | 257 |
|
213 |
| - if example.ini: |
214 |
| - pytester.makeini(f"[pytest]\n{example.ini}") |
| 258 | + if example.ini: |
| 259 | + pytester.makeini(f"[pytest]\n{example.ini}") |
215 | 260 |
|
216 |
| - for filetocopy in example.files: |
217 |
| - pytester.copy_example(str(filetocopy)) |
| 261 | + for filetocopy in example.files: |
| 262 | + pytester.copy_example(str(filetocopy)) |
218 | 263 |
|
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] |
233 | 275 |
|
234 | 276 |
|
235 | 277 | def add_ipytest_magic(source: str) -> str:
|
236 | 278 | """Add %%ipytest magic to the source code."""
|
237 | 279 | 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)) |
0 commit comments