From 29302734a9f9dcc5107bcdb2998d32a682f7a392 Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Tue, 12 Aug 2025 21:49:09 -0600 Subject: [PATCH 01/13] test parallel in downstream tests --- .github/workflows/ci_cd.yml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 4cd67556..58164795 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -188,7 +188,7 @@ jobs: working-directory: pyvista - name: Unit Testing - run: xvfb-run python -m pytest -v --allow_useless_fixture --generated_image_dir gen_dir tests/plotting/test_plotting.py + run: xvfb-run python -m pytest -v --allow_useless_fixture --generated_image_dir gen_dir tests/plotting/test_plotting.py -n2 working-directory: pyvista - name: Upload generated image artifact diff --git a/pyproject.toml b/pyproject.toml index f3b5ac35..752b7320 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ tests = [ "coverage==7.10.1", "numpy<2.3", "pytest-cov==6.2.1", + "pytest-xdist==3.8.0", "pytest>=6.2.0", "pytest_mock<3.15", ] From b7f925221323cb0246ca585b4895dc5e2021684e Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Tue, 12 Aug 2025 23:51:52 -0600 Subject: [PATCH 02/13] use shared filed to sync --- .github/workflows/ci_cd.yml | 2 +- pytest_pyvista/pytest_pyvista.py | 59 ++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 58164795..87e68c69 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -109,7 +109,7 @@ jobs: pip list - name: Unit Testing - run: xvfb-run coverage run --branch --source=pytest_pyvista -m pytest --verbose . + run: xvfb-run coverage run --branch --source=pytest_pyvista -m pytest --verbose -n2 - uses: codecov/codecov-action@v5 if: matrix.python-version == '3.9' name: "Upload coverage to CodeCov" diff --git a/pytest_pyvista/pytest_pyvista.py b/pytest_pyvista/pytest_pyvista.py index cef1ae17..31b2a4cc 100644 --- a/pytest_pyvista/pytest_pyvista.py +++ b/pytest_pyvista/pytest_pyvista.py @@ -2,10 +2,12 @@ from __future__ import annotations +import fcntl import os from pathlib import Path import platform import shutil +import tempfile from typing import TYPE_CHECKING from typing import Callable from typing import Literal @@ -19,9 +21,46 @@ if TYPE_CHECKING: # pragma: no cover from collections.abc import Generator +_temp_file_paths: dict[str, str] = {} -VISITED_CACHED_IMAGE_NAMES: set[str] = set() -SKIPPED_CACHED_IMAGE_NAMES: set[str] = set() + +def _get_shared_temp_file(prefix: str = "visited_images_") -> Path: + if prefix not in _temp_file_paths: + fd, path = tempfile.mkstemp(prefix=prefix, suffix=".txt") + os.close(fd) + _temp_file_paths[prefix] = path + return Path(_temp_file_paths[prefix]) + + +def _append_visited_image(image_name: str) -> None: + path = _get_shared_temp_file() + with path.open("a") as f: + fcntl.flock(f, fcntl.LOCK_EX) + f.write(image_name + "\n") + fcntl.flock(f, fcntl.LOCK_UN) + + +def _append_skipped_image(image_name: str) -> None: + path = _get_shared_temp_file("skipped_images_") + with path.open("a") as f: + fcntl.flock(f, fcntl.LOCK_EX) + f.write(image_name + "\n") + fcntl.flock(f, fcntl.LOCK_UN) + + +def _read_shared_temp_file(prefix: str = "visited_images_") -> set[str]: + path = _get_shared_temp_file(prefix) + if os.path.isfile(path): # noqa:PTH113 + with path.open() as f: + return {line.strip() for line in f if line.strip()} + return set() + + +@pytest.fixture(scope="session", autouse=True) +def _clear_shared_temp_files() -> None: + for prefix in ("visited_images_", "skipped_images_"): + path = _get_shared_temp_file(prefix) + path.open("w").close() class RegressionError(RuntimeError): @@ -252,9 +291,9 @@ def remove_plotter_close_callback() -> None: macos_skip_image_cache=self.macos_skip_image_cache, ignore_image_cache=self.ignore_image_cache, ): - SKIPPED_CACHED_IMAGE_NAMES.add(image_name) + _append_skipped_image(image_name) return - VISITED_CACHED_IMAGE_NAMES.add(image_name) + _append_visited_image(image_name) if not image_filename.is_file() and not (self.allow_unused_generated or self.add_missing_images or self.reset_image_cache): # Raise error since the cached image does not exist and will not be added later @@ -358,13 +397,16 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config) -> None: # no if config.getoption("disallow_unused_cache"): cache_path = Path(_get_option_from_config_or_ini(config, "image_cache_dir")) cached_image_names = {f.name for f in cache_path.glob("*.png")} - unused_cached_image_names = cached_image_names - VISITED_CACHED_IMAGE_NAMES - SKIPPED_CACHED_IMAGE_NAMES + visited_images = _read_shared_temp_file() + skipped_images = _read_shared_temp_file("skipped_images_") + + unused_cached_image_names = cached_image_names - visited_images - skipped_images # Exclude images from skipped tests where multiple images are generated unused_skipped = unused_cached_image_names.copy() for image_name in unused_cached_image_names: base_image_name = _image_name_from_test_name(_test_name_from_image_name(image_name)) - if base_image_name in SKIPPED_CACHED_IMAGE_NAMES: + if base_image_name in skipped_images: unused_skipped.remove(image_name) if unused_skipped: @@ -379,9 +421,6 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config) -> None: # no tr.line("tests should be modified to ensure an image is generated for comparison.", red=True) pytest.exit("Unused cache images", returncode=pytest.ExitCode.TESTS_FAILED) - VISITED_CACHED_IMAGE_NAMES.clear() - SKIPPED_CACHED_IMAGE_NAMES.clear() - def _ensure_dir_exists(dirpath: str | Path, msg_name: str) -> None: if not Path(dirpath).is_dir(): @@ -410,7 +449,7 @@ def pytest_runtest_makereport(item, call) -> Generator: # noqa: ANN001, ARG001 # Mark cached image as skipped if test was skipped during setup or execution if rep.when in ["call", "setup"] and rep.skipped: - SKIPPED_CACHED_IMAGE_NAMES.add(_image_name_from_test_name(item.name)) + _append_skipped_image(_image_name_from_test_name(item.name)) # Attach the report to the item so fixtures/finalizers can inspect it setattr(item, f"rep_{rep.when}", rep) From 8822bf3b3a5908e793143f8a364d9c421b365d61 Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Wed, 13 Aug 2025 08:14:19 -0600 Subject: [PATCH 03/13] exist_ok for parallel writing --- pytest_pyvista/pytest_pyvista.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pytest_pyvista/pytest_pyvista.py b/pytest_pyvista/pytest_pyvista.py index 31b2a4cc..1708a6e0 100644 --- a/pytest_pyvista/pytest_pyvista.py +++ b/pytest_pyvista/pytest_pyvista.py @@ -426,7 +426,9 @@ def _ensure_dir_exists(dirpath: str | Path, msg_name: str) -> None: if not Path(dirpath).is_dir(): msg = f"pyvista test {msg_name}: {dirpath} does not yet exist. Creating dir." warnings.warn(msg, stacklevel=2) - Path(dirpath).mkdir(parents=True) + + # exist_ok to allow for multi-threading + Path(dirpath).mkdir(exist_ok=True, parents=True) def _get_option_from_config_or_ini(pytestconfig: pytest.Config, option: str, *, is_dir: bool = False): # noqa: ANN202 From 639df8c6fbac79abe13f9bc30165348f1079fc4a Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Wed, 13 Aug 2025 09:40:34 -0600 Subject: [PATCH 04/13] only write on session finish --- pytest_pyvista/pytest_pyvista.py | 88 ++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/pytest_pyvista/pytest_pyvista.py b/pytest_pyvista/pytest_pyvista.py index 1708a6e0..ccda7663 100644 --- a/pytest_pyvista/pytest_pyvista.py +++ b/pytest_pyvista/pytest_pyvista.py @@ -21,46 +21,52 @@ if TYPE_CHECKING: # pragma: no cover from collections.abc import Generator -_temp_file_paths: dict[str, str] = {} +class _SharedFileSync: + """Stores stores a set of strings in a file to multiprocessing sync.""" -def _get_shared_temp_file(prefix: str = "visited_images_") -> Path: - if prefix not in _temp_file_paths: - fd, path = tempfile.mkstemp(prefix=prefix, suffix=".txt") - os.close(fd) - _temp_file_paths[prefix] = path - return Path(_temp_file_paths[prefix]) + def __init__(self, prefix: str) -> None: + self._prefix = prefix + self._cached_path: Path | None = None + self._data: set[str] = set() + @property + def _path(self) -> Path: + if self._cached_path is None: + shared_dir = Path(tempfile.gettempdir()) + path = shared_dir / f"{self._prefix}_shared.txt" + self._cached_path = path + path.touch(exist_ok=True) + return self._cached_path -def _append_visited_image(image_name: str) -> None: - path = _get_shared_temp_file() - with path.open("a") as f: - fcntl.flock(f, fcntl.LOCK_EX) - f.write(image_name + "\n") - fcntl.flock(f, fcntl.LOCK_UN) + def add(self, name: str) -> None: + self._data.add(name) + def _read(self) -> set[str]: + if os.path.isfile(self._path): # noqa: PTH113 + with self._path.open() as f: + return {line.strip() for line in f if line.strip()} + return set() -def _append_skipped_image(image_name: str) -> None: - path = _get_shared_temp_file("skipped_images_") - with path.open("a") as f: - fcntl.flock(f, fcntl.LOCK_EX) - f.write(image_name + "\n") - fcntl.flock(f, fcntl.LOCK_UN) + def clear(self) -> None: + self._path.open("w").close() + self._data = set() + def write(self) -> None: + with self._path.open("a") as f: + fcntl.flock(f, fcntl.LOCK_EX) + for name in self._data: + f.write(name + "\n") + fcntl.flock(f, fcntl.LOCK_UN) + self._data.clear() -def _read_shared_temp_file(prefix: str = "visited_images_") -> set[str]: - path = _get_shared_temp_file(prefix) - if os.path.isfile(path): # noqa:PTH113 - with path.open() as f: - return {line.strip() for line in f if line.strip()} - return set() + @property + def data(self) -> set[str]: + return self._read() -@pytest.fixture(scope="session", autouse=True) -def _clear_shared_temp_files() -> None: - for prefix in ("visited_images_", "skipped_images_"): - path = _get_shared_temp_file(prefix) - path.open("w").close() +VISITED_CACHED_IMAGE_NAMES = _SharedFileSync("visited_") +SKIPPED_CACHED_IMAGE_NAMES = _SharedFileSync("skipped_") class RegressionError(RuntimeError): @@ -291,9 +297,9 @@ def remove_plotter_close_callback() -> None: macos_skip_image_cache=self.macos_skip_image_cache, ignore_image_cache=self.ignore_image_cache, ): - _append_skipped_image(image_name) + SKIPPED_CACHED_IMAGE_NAMES.add(image_name) return - _append_visited_image(image_name) + VISITED_CACHED_IMAGE_NAMES.add(image_name) if not image_filename.is_file() and not (self.allow_unused_generated or self.add_missing_images or self.reset_image_cache): # Raise error since the cached image does not exist and will not be added later @@ -397,16 +403,14 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config) -> None: # no if config.getoption("disallow_unused_cache"): cache_path = Path(_get_option_from_config_or_ini(config, "image_cache_dir")) cached_image_names = {f.name for f in cache_path.glob("*.png")} - visited_images = _read_shared_temp_file() - skipped_images = _read_shared_temp_file("skipped_images_") - unused_cached_image_names = cached_image_names - visited_images - skipped_images + unused_cached_image_names = cached_image_names - VISITED_CACHED_IMAGE_NAMES.data - SKIPPED_CACHED_IMAGE_NAMES.data # Exclude images from skipped tests where multiple images are generated unused_skipped = unused_cached_image_names.copy() for image_name in unused_cached_image_names: base_image_name = _image_name_from_test_name(_test_name_from_image_name(image_name)) - if base_image_name in skipped_images: + if base_image_name in SKIPPED_CACHED_IMAGE_NAMES.data: unused_skipped.remove(image_name) if unused_skipped: @@ -421,6 +425,9 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config) -> None: # no tr.line("tests should be modified to ensure an image is generated for comparison.", red=True) pytest.exit("Unused cache images", returncode=pytest.ExitCode.TESTS_FAILED) + VISITED_CACHED_IMAGE_NAMES.clear() + SKIPPED_CACHED_IMAGE_NAMES.clear() + def _ensure_dir_exists(dirpath: str | Path, msg_name: str) -> None: if not Path(dirpath).is_dir(): @@ -451,7 +458,7 @@ def pytest_runtest_makereport(item, call) -> Generator: # noqa: ANN001, ARG001 # Mark cached image as skipped if test was skipped during setup or execution if rep.when in ["call", "setup"] and rep.skipped: - _append_skipped_image(_image_name_from_test_name(item.name)) + SKIPPED_CACHED_IMAGE_NAMES.add(_image_name_from_test_name(item.name)) # Attach the report to the item so fixtures/finalizers can inspect it setattr(item, f"rep_{rep.when}", rep) @@ -531,3 +538,10 @@ def func_show(*args, **kwargs) -> None: # noqa: ANN002, ANN003 "Fixture `verify_image_cache` is used but no images were generated.\n" "Did you forget to call `show` or `plot`, or set `verify_image_cache.allow_useless_fixture=True`?." ) + + +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: # noqa: ARG001 + """Ensure cached image names are written to file.""" + # This works across multiple pytest-xdist nodes or a single non-parallel session + VISITED_CACHED_IMAGE_NAMES.write() + SKIPPED_CACHED_IMAGE_NAMES.write() From 1888e98387237e0ca51c04f9de6f28dedcccff6b Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Wed, 13 Aug 2025 11:05:24 -0600 Subject: [PATCH 05/13] Update pytest_pyvista/pytest_pyvista.py --- pytest_pyvista/pytest_pyvista.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pytest_pyvista/pytest_pyvista.py b/pytest_pyvista/pytest_pyvista.py index ccda7663..5c683fb0 100644 --- a/pytest_pyvista/pytest_pyvista.py +++ b/pytest_pyvista/pytest_pyvista.py @@ -49,8 +49,14 @@ def _read(self) -> set[str]: return set() def clear(self) -> None: - self._path.open("w").close() - self._data = set() + try: + with self._path.open('r+') as f: + fcntl.flock(f, fcntl.LOCK_EX) + f.truncate(0) + fcntl.flock(f, fcntl.LOCK_UN) + self._buffer.clear() + except OSError as e: # pragma: no cover + raise RuntimeError(f"Failed to clear shared file: {e}") def write(self) -> None: with self._path.open("a") as f: From 64b881eed83bde4e0cbc8815ef986205fdb8e99c Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Wed, 13 Aug 2025 11:10:45 -0600 Subject: [PATCH 06/13] revert 1888e98 --- pytest_pyvista/pytest_pyvista.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/pytest_pyvista/pytest_pyvista.py b/pytest_pyvista/pytest_pyvista.py index 5c683fb0..ccda7663 100644 --- a/pytest_pyvista/pytest_pyvista.py +++ b/pytest_pyvista/pytest_pyvista.py @@ -49,14 +49,8 @@ def _read(self) -> set[str]: return set() def clear(self) -> None: - try: - with self._path.open('r+') as f: - fcntl.flock(f, fcntl.LOCK_EX) - f.truncate(0) - fcntl.flock(f, fcntl.LOCK_UN) - self._buffer.clear() - except OSError as e: # pragma: no cover - raise RuntimeError(f"Failed to clear shared file: {e}") + self._path.open("w").close() + self._data = set() def write(self) -> None: with self._path.open("a") as f: From da0d279c95595f24112b97fb327a9422a1828cea Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Wed, 13 Aug 2025 11:10:59 -0600 Subject: [PATCH 07/13] Update pytest_pyvista/pytest_pyvista.py --- pytest_pyvista/pytest_pyvista.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pytest_pyvista/pytest_pyvista.py b/pytest_pyvista/pytest_pyvista.py index ccda7663..ceb08ced 100644 --- a/pytest_pyvista/pytest_pyvista.py +++ b/pytest_pyvista/pytest_pyvista.py @@ -58,7 +58,6 @@ def write(self) -> None: for name in self._data: f.write(name + "\n") fcntl.flock(f, fcntl.LOCK_UN) - self._data.clear() @property def data(self) -> set[str]: From 351e09b7f24ce79cd9ad70488ad03de791d843e6 Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Wed, 13 Aug 2025 13:18:19 -0600 Subject: [PATCH 08/13] write json instead --- pytest_pyvista/pytest_pyvista.py | 99 +++++++++++++++----------------- 1 file changed, 46 insertions(+), 53 deletions(-) diff --git a/pytest_pyvista/pytest_pyvista.py b/pytest_pyvista/pytest_pyvista.py index ccda7663..195907db 100644 --- a/pytest_pyvista/pytest_pyvista.py +++ b/pytest_pyvista/pytest_pyvista.py @@ -2,7 +2,7 @@ from __future__ import annotations -import fcntl +import json import os from pathlib import Path import platform @@ -12,6 +12,7 @@ from typing import Callable from typing import Literal from typing import cast +import uuid import warnings import pytest @@ -21,52 +22,8 @@ if TYPE_CHECKING: # pragma: no cover from collections.abc import Generator - -class _SharedFileSync: - """Stores stores a set of strings in a file to multiprocessing sync.""" - - def __init__(self, prefix: str) -> None: - self._prefix = prefix - self._cached_path: Path | None = None - self._data: set[str] = set() - - @property - def _path(self) -> Path: - if self._cached_path is None: - shared_dir = Path(tempfile.gettempdir()) - path = shared_dir / f"{self._prefix}_shared.txt" - self._cached_path = path - path.touch(exist_ok=True) - return self._cached_path - - def add(self, name: str) -> None: - self._data.add(name) - - def _read(self) -> set[str]: - if os.path.isfile(self._path): # noqa: PTH113 - with self._path.open() as f: - return {line.strip() for line in f if line.strip()} - return set() - - def clear(self) -> None: - self._path.open("w").close() - self._data = set() - - def write(self) -> None: - with self._path.open("a") as f: - fcntl.flock(f, fcntl.LOCK_EX) - for name in self._data: - f.write(name + "\n") - fcntl.flock(f, fcntl.LOCK_UN) - self._data.clear() - - @property - def data(self) -> set[str]: - return self._read() - - -VISITED_CACHED_IMAGE_NAMES = _SharedFileSync("visited_") -SKIPPED_CACHED_IMAGE_NAMES = _SharedFileSync("skipped_") +VISITED_CACHED_IMAGE_NAMES: set[str] = set() +SKIPPED_CACHED_IMAGE_NAMES: set[str] = set() class RegressionError(RuntimeError): @@ -404,13 +361,21 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config) -> None: # no cache_path = Path(_get_option_from_config_or_ini(config, "image_cache_dir")) cached_image_names = {f.name for f in cache_path.glob("*.png")} - unused_cached_image_names = cached_image_names - VISITED_CACHED_IMAGE_NAMES.data - SKIPPED_CACHED_IMAGE_NAMES.data + image_names_dir = getattr(config, "_image_names_dir", None) + if image_names_dir: + visited_cached_image_names = _combine_temp_jsons(image_names_dir, "visited") + skipped_cached_image_names = _combine_temp_jsons(image_names_dir, "skipped") + else: + visited_cached_image_names = set() + skipped_cached_image_names = set() + + unused_cached_image_names = cached_image_names - visited_cached_image_names - skipped_cached_image_names # Exclude images from skipped tests where multiple images are generated unused_skipped = unused_cached_image_names.copy() for image_name in unused_cached_image_names: base_image_name = _image_name_from_test_name(_test_name_from_image_name(image_name)) - if base_image_name in SKIPPED_CACHED_IMAGE_NAMES.data: + if base_image_name in skipped_cached_image_names: unused_skipped.remove(image_name) if unused_skipped: @@ -475,6 +440,14 @@ def __call__(self, plotter: Plotter) -> None: f(plotter) +@pytest.hookimpl +def pytest_configure(config: pytest.Config) -> None: + """Configure pytest session.""" + # create a image names directory for individual or multiple workers to write to + config._image_names_dir = Path(tempfile.mkdtemp()) # noqa: SLF001 + config._image_names_dir.mkdir(exist_ok=True) # noqa: SLF001 + + @pytest.fixture def verify_image_cache( request: pytest.FixtureRequest, @@ -540,8 +513,28 @@ def func_show(*args, **kwargs) -> None: # noqa: ANN002, ANN003 ) +def _combine_temp_jsons(json_dir: Path, prefix: str = "") -> set[str]: + # Read all JSON files from temp subdir into single set + combined_data: set[str] = set() + if json_dir.exists(): + for json_file in json_dir.glob(f"{prefix}*.json"): + with json_file.open() as f: + data = json.load(f) + combined_data.update(data) + + return combined_data + + +@pytest.hookimpl def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: # noqa: ARG001 - """Ensure cached image names are written to file.""" - # This works across multiple pytest-xdist nodes or a single non-parallel session - VISITED_CACHED_IMAGE_NAMES.write() - SKIPPED_CACHED_IMAGE_NAMES.write() + """Write skipped and visited image names to disk.""" + # uses uuid and is threadsafe + + image_names_dir = getattr(session.config, "_image_names_dir", None) + if image_names_dir: + visited_file = image_names_dir / f"visited_{uuid.uuid4()}_cache_names.json" + skipped_file = image_names_dir / f"skipped_{uuid.uuid4()}_cache_names.json" + + # Fixed: Write JSON instead of plain text + visited_file.write_text(json.dumps(list(VISITED_CACHED_IMAGE_NAMES))) + skipped_file.write_text(json.dumps(list(SKIPPED_CACHED_IMAGE_NAMES))) From f3f6469f432ca20d3b06044d20a62f71a45fcc0e Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Wed, 13 Aug 2025 14:45:57 -0600 Subject: [PATCH 09/13] use xdist hook --- pytest_pyvista/pytest_pyvista.py | 34 +++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/pytest_pyvista/pytest_pyvista.py b/pytest_pyvista/pytest_pyvista.py index 195907db..59739b4b 100644 --- a/pytest_pyvista/pytest_pyvista.py +++ b/pytest_pyvista/pytest_pyvista.py @@ -21,6 +21,10 @@ if TYPE_CHECKING: # pragma: no cover from collections.abc import Generator + import contextlib + + with contextlib.suppress(ImportError): + from xdist.workermanage import WorkerController VISITED_CACHED_IMAGE_NAMES: set[str] = set() SKIPPED_CACHED_IMAGE_NAMES: set[str] = set() @@ -361,7 +365,7 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config) -> None: # no cache_path = Path(_get_option_from_config_or_ini(config, "image_cache_dir")) cached_image_names = {f.name for f in cache_path.glob("*.png")} - image_names_dir = getattr(config, "_image_names_dir", None) + image_names_dir = Path(config.shared_directory) if image_names_dir: visited_cached_image_names = _combine_temp_jsons(image_names_dir, "visited") skipped_cached_image_names = _combine_temp_jsons(image_names_dir, "skipped") @@ -440,12 +444,28 @@ def __call__(self, plotter: Plotter) -> None: f(plotter) -@pytest.hookimpl def pytest_configure(config: pytest.Config) -> None: - """Configure pytest session.""" - # create a image names directory for individual or multiple workers to write to - config._image_names_dir = Path(tempfile.mkdtemp()) # noqa: SLF001 - config._image_names_dir.mkdir(exist_ok=True) # noqa: SLF001 + """Create a shared directory on initialization.""" + if is_controller(config): + config.shared_directory = tempfile.mkdtemp() + Path(config.shared_directory).mkdir(exist_ok=True) + + +def pytest_unconfigure(config: pytest.Config) -> None: + """Remove the shared directory when complete.""" + if is_controller(config): + shutil.rmtree(config.shared_directory) + + +@pytest.hookimpl +def pytest_configure_node(node: WorkerController) -> None: + """Configure pytest nodes by adding shared directory.""" + node.workerinput["shared_dir"] = node.config.shared_directory + + +def is_controller(config: pytest.Config) -> bool: + """Return if config is running in a xdist controller node or not running xdist at all.""" + return not hasattr(config, "workerinput") @pytest.fixture @@ -530,7 +550,7 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: # n """Write skipped and visited image names to disk.""" # uses uuid and is threadsafe - image_names_dir = getattr(session.config, "_image_names_dir", None) + image_names_dir = Path(session.config.shared_directory) if image_names_dir: visited_file = image_names_dir / f"visited_{uuid.uuid4()}_cache_names.json" skipped_file = image_names_dir / f"skipped_{uuid.uuid4()}_cache_names.json" From 93ffe06edc3832ac5fc3d9c209cc5c0c9c07b232 Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Wed, 13 Aug 2025 15:32:45 -0600 Subject: [PATCH 10/13] move back to a static path --- pytest_pyvista/pytest_pyvista.py | 40 +++++++++++--------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/pytest_pyvista/pytest_pyvista.py b/pytest_pyvista/pytest_pyvista.py index 59739b4b..e73f38a8 100644 --- a/pytest_pyvista/pytest_pyvista.py +++ b/pytest_pyvista/pytest_pyvista.py @@ -2,12 +2,12 @@ from __future__ import annotations +import contextlib import json import os from pathlib import Path import platform import shutil -import tempfile from typing import TYPE_CHECKING from typing import Callable from typing import Literal @@ -21,10 +21,6 @@ if TYPE_CHECKING: # pragma: no cover from collections.abc import Generator - import contextlib - - with contextlib.suppress(ImportError): - from xdist.workermanage import WorkerController VISITED_CACHED_IMAGE_NAMES: set[str] = set() SKIPPED_CACHED_IMAGE_NAMES: set[str] = set() @@ -365,7 +361,7 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config) -> None: # no cache_path = Path(_get_option_from_config_or_ini(config, "image_cache_dir")) cached_image_names = {f.name for f in cache_path.glob("*.png")} - image_names_dir = Path(config.shared_directory) + image_names_dir = getattr(config, "image_names_dir", None) if image_names_dir: visited_cached_image_names = _combine_temp_jsons(image_names_dir, "visited") skipped_cached_image_names = _combine_temp_jsons(image_names_dir, "skipped") @@ -444,28 +440,18 @@ def __call__(self, plotter: Plotter) -> None: f(plotter) -def pytest_configure(config: pytest.Config) -> None: - """Create a shared directory on initialization.""" - if is_controller(config): - config.shared_directory = tempfile.mkdtemp() - Path(config.shared_directory).mkdir(exist_ok=True) - - -def pytest_unconfigure(config: pytest.Config) -> None: - """Remove the shared directory when complete.""" - if is_controller(config): - shutil.rmtree(config.shared_directory) - - @pytest.hookimpl -def pytest_configure_node(node: WorkerController) -> None: - """Configure pytest nodes by adding shared directory.""" - node.workerinput["shared_dir"] = node.config.shared_directory - +def pytest_configure(config: pytest.Config) -> None: + """Configure pytest session.""" + # create a image names directory for individual or multiple workers to write to + if config.getoption("disallow_unused_cache"): + config.image_names_dir = Path(".pytest-pyvista") + config.image_names_dir.mkdir(exist_ok=True) -def is_controller(config: pytest.Config) -> bool: - """Return if config is running in a xdist controller node or not running xdist at all.""" - return not hasattr(config, "workerinput") + # ensure this directory is empty as it might be left over from a previous test + with contextlib.suppress(OSError): + for filename in config.image_names_dir.iterdir(): + filename.unlink() @pytest.fixture @@ -550,7 +536,7 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: # n """Write skipped and visited image names to disk.""" # uses uuid and is threadsafe - image_names_dir = Path(session.config.shared_directory) + image_names_dir = getattr(session.config, "image_names_dir", None) if image_names_dir: visited_file = image_names_dir / f"visited_{uuid.uuid4()}_cache_names.json" skipped_file = image_names_dir / f"skipped_{uuid.uuid4()}_cache_names.json" From b10daff9707f8ee62e081fb65cc33b495cfa2b4f Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Wed, 13 Aug 2025 17:09:28 -0600 Subject: [PATCH 11/13] use pytest's cache --- pytest_pyvista/pytest_pyvista.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pytest_pyvista/pytest_pyvista.py b/pytest_pyvista/pytest_pyvista.py index e73f38a8..9b71bf66 100644 --- a/pytest_pyvista/pytest_pyvista.py +++ b/pytest_pyvista/pytest_pyvista.py @@ -357,6 +357,10 @@ def remove_suffix(s: str) -> str: @pytest.hookimpl def pytest_terminal_summary(terminalreporter, exitstatus, config) -> None: # noqa: ANN001, ARG001 """Execute after the whole test run completes.""" + if hasattr(config, "workerinput"): + # on an pytest-xdist worker node, exit early + return + if config.getoption("disallow_unused_cache"): cache_path = Path(_get_option_from_config_or_ini(config, "image_cache_dir")) cached_image_names = {f.name for f in cache_path.glob("*.png")} @@ -445,7 +449,7 @@ def pytest_configure(config: pytest.Config) -> None: """Configure pytest session.""" # create a image names directory for individual or multiple workers to write to if config.getoption("disallow_unused_cache"): - config.image_names_dir = Path(".pytest-pyvista") + config.image_names_dir = Path(config.cache.makedir("pyvista")) config.image_names_dir.mkdir(exist_ok=True) # ensure this directory is empty as it might be left over from a previous test @@ -520,7 +524,7 @@ def func_show(*args, **kwargs) -> None: # noqa: ANN002, ANN003 def _combine_temp_jsons(json_dir: Path, prefix: str = "") -> set[str]: - # Read all JSON files from temp subdir into single set + # Read all JSON files from a directory and combine into single set combined_data: set[str] = set() if json_dir.exists(): for json_file in json_dir.glob(f"{prefix}*.json"): @@ -534,12 +538,11 @@ def _combine_temp_jsons(json_dir: Path, prefix: str = "") -> set[str]: @pytest.hookimpl def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: # noqa: ARG001 """Write skipped and visited image names to disk.""" - # uses uuid and is threadsafe - image_names_dir = getattr(session.config, "image_names_dir", None) if image_names_dir: - visited_file = image_names_dir / f"visited_{uuid.uuid4()}_cache_names.json" - skipped_file = image_names_dir / f"skipped_{uuid.uuid4()}_cache_names.json" + test_id = uuid.uuid4() + visited_file = image_names_dir / f"visited_{test_id}_cache_names.json" + skipped_file = image_names_dir / f"skipped_{test_id}_cache_names.json" # Fixed: Write JSON instead of plain text visited_file.write_text(json.dumps(list(VISITED_CACHED_IMAGE_NAMES))) From c8c3a10c78dd2f58ac1763fee12480536e4c5665 Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Wed, 13 Aug 2025 23:01:56 -0600 Subject: [PATCH 12/13] Update .github/workflows/ci_cd.yml Co-authored-by: user27182 <89109579+user27182@users.noreply.github.com> --- .github/workflows/ci_cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 87e68c69..79716613 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -188,7 +188,7 @@ jobs: working-directory: pyvista - name: Unit Testing - run: xvfb-run python -m pytest -v --allow_useless_fixture --generated_image_dir gen_dir tests/plotting/test_plotting.py -n2 + run: xvfb-run python -m pytest -v --allow_useless_fixture --generated_image_dir gen_dir tests/plotting --disallow_unused_cache -n2 working-directory: pyvista - name: Upload generated image artifact From be999ff956f21612dd401c9a49b5322538fd44d3 Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Fri, 29 Aug 2025 13:47:12 -0600 Subject: [PATCH 13/13] Revert "Update .github/workflows/ci_cd.yml" This reverts commit c8c3a10c78dd2f58ac1763fee12480536e4c5665. --- .github/workflows/ci_cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 79716613..87e68c69 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -188,7 +188,7 @@ jobs: working-directory: pyvista - name: Unit Testing - run: xvfb-run python -m pytest -v --allow_useless_fixture --generated_image_dir gen_dir tests/plotting --disallow_unused_cache -n2 + run: xvfb-run python -m pytest -v --allow_useless_fixture --generated_image_dir gen_dir tests/plotting/test_plotting.py -n2 working-directory: pyvista - name: Upload generated image artifact