Skip to content

Revert xdist addition #277

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

Merged
merged 2 commits into from
Jun 20, 2025
Merged
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions .github/workflows/build-ultraplot.yml
Original file line number Diff line number Diff line change
@@ -43,7 +43,7 @@ jobs:
- name: Test Ultraplot
run: |
pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ultraplot
pytest --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ultraplot
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
@@ -76,14 +76,14 @@ jobs:
git fetch origin ${{ github.event.pull_request.base.sha }}
git checkout ${{ github.event.pull_request.base.sha }}
python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')"
pytest -n auto -W ignore --mpl-generate-path=baseline --mpl-default-style="./ultraplot.yml"
pytest -W ignore --mpl-generate-path=baseline --mpl-default-style="./ultraplot.yml"
git checkout ${{ github.sha }} # Return to PR branch
- name: Image Comparison Ultraplot
run: |
mkdir -p results
python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')"
pytest -n auto -W ignore --mpl --mpl-baseline-path=baseline --mpl-generate-summary=html --mpl-results-path=./results/ --mpl-default-style="./ultraplot.yml" --store-failed-only ultraplot/tests
pytest -W ignore --mpl --mpl-baseline-path=baseline --mpl-generate-summary=html --mpl-results-path=./results/ --mpl-default-style="./ultraplot.yml" --store-failed-only ultraplot/tests
# Return the html output of the comparison even if failed
- name: Upload comparison failures
35 changes: 17 additions & 18 deletions environment.yml
Original file line number Diff line number Diff line change
@@ -2,32 +2,31 @@ name: ultraplot-dev
channels:
- conda-forge
dependencies:
- basemap >=1.4.1
- cartopy
- jupyter
- jupytext
- matplotlib>=3.9
- nbsphinx
- networkx
- python>=3.10,<3.14
- numpy
- matplotlib>=3.9
- cartopy
- xarray
- seaborn
- pandas
- pint
- pip
- pre-commit
- pyarrow
- pytest
- pytest-cov
- pytest-mpl
- pytest-xdist
- python>=3.10,<3.14
- seaborn
- pytest-cov
- jupyter
- pip
- pint
- sphinx
- nbsphinx
- jupytext
- sphinx-copybutton
- sphinx-autoapi
- sphinx-automodapi
- sphinx-copybutton
- sphinx-design
- sphinx-rtd-theme
- typing-extensions
- xarray
- basemap >=1.4.1
- pre-commit
- sphinx-design
- networkx
- pyarrow
- pip:
- git+https://github.com/ultraplot/UltraTheme.git
273 changes: 129 additions & 144 deletions ultraplot/config.py
Original file line number Diff line number Diff line change
@@ -10,8 +10,10 @@
# Because I think it makes sense to have all the code that "runs" (i.e. not
# just definitions) in the same place, and I was having issues with circular
# dependencies and where import order of __init__.py was affecting behavior.
import logging, os, re, sys, threading

import logging
import os
import re
import sys
from collections import namedtuple
from collections.abc import MutableMapping
from numbers import Real
@@ -763,10 +765,7 @@
----------
%(rc.params)s
"""
import threading

self._context = []
self._lock = threading.RLock()
self._init(local=local, user=user, default=default, **kwargs)

def __getitem__(self, key):
@@ -786,10 +785,9 @@
Modify an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation
(e.g., ``uplt.rc[name] = value``).
"""
with self._lock:
kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value)
rc_ultraplot.update(kw_ultraplot)
rc_matplotlib.update(kw_matplotlib)
kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value)
rc_ultraplot.update(kw_ultraplot)
rc_matplotlib.update(kw_matplotlib)

def __getattr__(self, attr):
"""
@@ -815,83 +813,78 @@
"""
Apply settings from the most recent context block.
"""
with self._lock:
if not self._context:
raise RuntimeError(
"rc object must be initialized for context block using rc.context()."
)
context = self._context[-1]
kwargs = context.kwargs
rc_new = context.rc_new # used for context-based _get_item_context
rc_old = (
context.rc_old
) # used to re-apply settings without copying whole dict
for key, value in kwargs.items():
try:
kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value)
except Exception as e:
self.__exit__()
raise e

for rc_dict, kw_new in zip(
(rc_ultraplot, rc_matplotlib),
(kw_ultraplot, kw_matplotlib),
):
for key, value in kw_new.items():
rc_old[key] = rc_dict[key]
rc_new[key] = rc_dict[key] = value
if not self._context:
raise RuntimeError(

Check warning on line 817 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L817

Added line #L817 was not covered by tests
"rc object must be initialized for context block using rc.context()."
)
context = self._context[-1]
kwargs = context.kwargs
rc_new = context.rc_new # used for context-based _get_item_context
rc_old = context.rc_old # used to re-apply settings without copying whole dict
for key, value in kwargs.items():
try:
kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value)
except Exception as e:
self.__exit__()
raise e

Check warning on line 829 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L827-L829

Added lines #L827 - L829 were not covered by tests

for rc_dict, kw_new in zip(
(rc_ultraplot, rc_matplotlib),
(kw_ultraplot, kw_matplotlib),
):
for key, value in kw_new.items():
rc_old[key] = rc_dict[key]
rc_new[key] = rc_dict[key] = value

def __exit__(self, *args): # noqa: U100
"""
Restore settings from the most recent context block.
"""
with self._lock:
if not self._context:
raise RuntimeError(
"rc object must be initialized for context block using rc.context()."
)
context = self._context[-1]
for key, value in context.rc_old.items():
kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value)
rc_ultraplot.update(kw_ultraplot)
rc_matplotlib.update(kw_matplotlib)
del self._context[-1]
if not self._context:
raise RuntimeError(

Check warning on line 844 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L844

Added line #L844 was not covered by tests
"rc object must be initialized for context block using rc.context()."
)
context = self._context[-1]
for key, value in context.rc_old.items():
kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value)
rc_ultraplot.update(kw_ultraplot)
rc_matplotlib.update(kw_matplotlib)
del self._context[-1]

def _init(self, *, local, user, default, skip_cycle=False):
"""
Initialize the configurator.
"""
with self._lock:
# Always remove context objects
self._context.clear()

# Update from default settings
# NOTE: see _remove_blacklisted_style_params bugfix
if default:
rc_matplotlib.update(_get_style_dict("original", filter=False))
rc_matplotlib.update(rcsetup._rc_matplotlib_default)
rc_ultraplot.update(rcsetup._rc_ultraplot_default)
for key, value in rc_ultraplot.items():
kw_ultraplot, kw_matplotlib = self._get_item_dicts(
key, value, skip_cycle=skip_cycle
)
rc_matplotlib.update(kw_matplotlib)
rc_ultraplot.update(kw_ultraplot)

# Update from user home
user_path = None
if user:
user_path = self.user_file()
if os.path.isfile(user_path):
self.load(user_path)

# Update from local paths
if local:
local_paths = self.local_files()
for path in local_paths:
if path == user_path: # local files always have precedence
continue
self.load(path)
# Always remove context objects
self._context.clear()

# Update from default settings
# NOTE: see _remove_blacklisted_style_params bugfix
if default:
rc_matplotlib.update(_get_style_dict("original", filter=False))
rc_matplotlib.update(rcsetup._rc_matplotlib_default)
rc_ultraplot.update(rcsetup._rc_ultraplot_default)
for key, value in rc_ultraplot.items():
kw_ultraplot, kw_matplotlib = self._get_item_dicts(
key, value, skip_cycle=skip_cycle
)
rc_matplotlib.update(kw_matplotlib)
rc_ultraplot.update(kw_ultraplot)

# Update from user home
user_path = None
if user:
user_path = self.user_file()
if os.path.isfile(user_path):
self.load(user_path)

# Update from local paths
if local:
local_paths = self.local_files()
for path in local_paths:
if path == user_path: # local files always have precedence
continue
self.load(path)

Check warning on line 887 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L886-L887

Added lines #L886 - L887 were not covered by tests

@staticmethod
def _validate_key(key, value=None):
@@ -937,28 +930,27 @@
As with `~Configurator.__getitem__` but the search is limited based
on the context mode and ``None`` is returned if the key is not found.
"""
with self._lock:
key, _ = self._validate_key(key)
if mode is None:
mode = self._context_mode
cache = tuple(context.rc_new for context in self._context)
if mode == 0:
rcdicts = (*cache, rc_ultraplot, rc_matplotlib)
elif mode == 1:
rcdicts = (*cache, rc_ultraplot) # added settings only!
elif mode == 2:
rcdicts = (*cache,)
else:
raise ValueError(f"Invalid caching mode {mode!r}.")
for rcdict in rcdicts:
if not rcdict:
continue
try:
return rcdict[key]
except KeyError:
continue
if mode == 0: # otherwise return None
raise KeyError(f"Invalid rc setting {key!r}.")
key, _ = self._validate_key(key)
if mode is None:
mode = self._context_mode
cache = tuple(context.rc_new for context in self._context)
if mode == 0:
rcdicts = (*cache, rc_ultraplot, rc_matplotlib)
elif mode == 1:
rcdicts = (*cache, rc_ultraplot) # added settings only!
elif mode == 2:
rcdicts = (*cache,)
else:
raise ValueError(f"Invalid caching mode {mode!r}.")

Check warning on line 944 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L944

Added line #L944 was not covered by tests
for rcdict in rcdicts:
if not rcdict:
continue
try:
return rcdict[key]
except KeyError:
continue
if mode == 0: # otherwise return None
raise KeyError(f"Invalid rc setting {key!r}.")

Check warning on line 953 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L953

Added line #L953 was not covered by tests

def _get_item_dicts(self, key, value, skip_cycle=False):
"""
@@ -1460,26 +1452,25 @@
>>> fig, ax = uplt.subplots()
>>> ax.format(ticklen=5, metalinewidth=2)
"""
with self._lock:
# Add input dictionaries
for arg in args:
if not isinstance(arg, dict):
raise ValueError(f"Non-dictionary argument {arg!r}.")
kwargs.update(arg)

# Add settings from file
if file is not None:
kw = self._load_file(file)
kw = {key: value for key, value in kw.items() if key not in kwargs}
kwargs.update(kw)

# Activate context object
if mode not in range(3):
raise ValueError(f"Invalid mode {mode!r}.")
cls = namedtuple("RcContext", ("mode", "kwargs", "rc_new", "rc_old"))
context = cls(mode=mode, kwargs=kwargs, rc_new={}, rc_old={})
self._context.append(context)
return self
# Add input dictionaries
for arg in args:
if not isinstance(arg, dict):
raise ValueError(f"Non-dictionary argument {arg!r}.")

Check warning on line 1458 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L1458

Added line #L1458 was not covered by tests
kwargs.update(arg)

# Add settings from file
if file is not None:
kw = self._load_file(file)
kw = {key: value for key, value in kw.items() if key not in kwargs}
kwargs.update(kw)

Check warning on line 1465 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L1463-L1465

Added lines #L1463 - L1465 were not covered by tests

# Activate context object
if mode not in range(3):
raise ValueError(f"Invalid mode {mode!r}.")

Check warning on line 1469 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L1469

Added line #L1469 was not covered by tests
cls = namedtuple("RcContext", ("mode", "kwargs", "rc_new", "rc_old"))
context = cls(mode=mode, kwargs=kwargs, rc_new={}, rc_old={})
self._context.append(context)
return self

def category(self, cat, *, trimcat=True, context=False):
"""
@@ -1585,30 +1576,25 @@
Configurator.category
Configurator.fill
"""
with self._lock:
prefix, kw = "", {}
if not args:
pass
elif len(args) == 1 and isinstance(args[0], str):
prefix = args[0]
elif len(args) == 1 and isinstance(args[0], dict):
kw = args[0]
elif (
len(args) == 2
and isinstance(args[0], str)
and isinstance(args[1], dict)
):
prefix, kw = args
else:
raise ValueError(
f"Invalid arguments {args!r}. Usage is either "
"rc.update(dict), rc.update(kwy=value, ...), "
"rc.update(category, dict), or rc.update(category, key=value, ...)."
)
prefix = prefix and prefix + "."
kw.update(kwargs)
for key, value in kw.items():
self.__setitem__(prefix + key, value)
prefix, kw = "", {}

Check warning on line 1579 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L1579

Added line #L1579 was not covered by tests
if not args:
pass

Check warning on line 1581 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L1581

Added line #L1581 was not covered by tests
elif len(args) == 1 and isinstance(args[0], str):
prefix = args[0]

Check warning on line 1583 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L1583

Added line #L1583 was not covered by tests
elif len(args) == 1 and isinstance(args[0], dict):
kw = args[0]

Check warning on line 1585 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L1585

Added line #L1585 was not covered by tests
elif len(args) == 2 and isinstance(args[0], str) and isinstance(args[1], dict):
prefix, kw = args

Check warning on line 1587 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L1587

Added line #L1587 was not covered by tests
else:
raise ValueError(

Check warning on line 1589 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L1589

Added line #L1589 was not covered by tests
f"Invalid arguments {args!r}. Usage is either "
"rc.update(dict), rc.update(kwy=value, ...), "
"rc.update(category, dict), or rc.update(category, key=value, ...)."
)
prefix = prefix and prefix + "."
kw.update(kwargs)

Check warning on line 1595 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L1594-L1595

Added lines #L1594 - L1595 were not covered by tests
for key, value in kw.items():
self.__setitem__(prefix + key, value)

Check warning on line 1597 in ultraplot/config.py

Codecov / codecov/patch

ultraplot/config.py#L1597

Added line #L1597 was not covered by tests

@docstring._snippet_manager
def reset(self, local=True, user=True, default=True, **kwargs):
@@ -1786,8 +1772,7 @@
"""
Return the highest (least permissive) context mode.
"""
with self._lock:
return max((context.mode for context in self._context), default=0)
return max((context.mode for context in self._context), default=0)

@property
def changed(self):
9 changes: 1 addition & 8 deletions ultraplot/internals/context.py
Original file line number Diff line number Diff line change
@@ -2,7 +2,6 @@
"""
Utilities for manging context.
"""
import threading
from . import ic # noqa: F401


@@ -26,10 +25,6 @@ class _state_context(object):
Temporarily modify attribute(s) for an arbitrary object.
"""

_lock = (
threading.RLock()
) # class-wide reentrant lock (or use instance-wide if needed)

def __init__(self, obj, **kwargs):
self._obj = obj
self._attrs_new = kwargs
@@ -38,14 +33,12 @@ def __init__(self, obj, **kwargs):
}

def __enter__(self):
self._lock.acquire()
for key, value in self._attrs_new.items():
setattr(self._obj, key, value)

def __exit__(self, *args):
def __exit__(self, *args): # noqa: U100
for key in self._attrs_new.keys():
if key in self._attrs_prev:
setattr(self._obj, key, self._attrs_prev[key])
else:
delattr(self._obj, key)
self._lock.release()
237 changes: 45 additions & 192 deletions ultraplot/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,138 +1,71 @@
"""
Conftest.py for UltraPlot testing with modular MPL plugin architecture.
This file provides essential test fixtures and integrates the enhanced matplotlib
testing functionality through a clean, modular plugin system.
Thread-Safe Random Number Generation:
- Provides explicit RNG fixtures for test functions that need random numbers
- Each thread gets independent, deterministic RNG instances
- Compatible with pytest-xdist parallel execution
- Clean separation of concerns - tests explicitly declare RNG dependencies
"""

import threading, os, shutil, pytest, re
import numpy as np, ultraplot as uplt
import warnings, logging
import os, shutil, pytest, re, numpy as np, ultraplot as uplt
from pathlib import Path
from datetime import datetime

# Import the modular MPL plugin components
from ultraplot.tests.mpl_plugin import (
StoreFailedMplPlugin,
ProgressTracker,
CleanupManager,
HTMLReportGenerator,
)
from ultraplot.tests.mpl_plugin.utils import (
count_mpl_tests,
should_generate_html_report,
get_failed_mpl_tests,
)
from ultraplot.tests.mpl_plugin.progress import get_progress_tracker
from ultraplot.tests.mpl_plugin.cleanup import get_cleanup_manager
import warnings, logging

SEED = 51423


@pytest.fixture
def rng():
"""
Fixture providing a numpy random generator for tests.
This fixture provides a numpy.random.Generator instance that:
- Uses the same seed (51423) for each test
- Ensures reproducible results
- Resets state for each test
Usage in tests:
def test_something(rng):
random_data = rng.normal(0, 1, size=100)
random_ints = rng.integers(0, 10, size=5)
Ensure all tests start with the same rng
"""
# Each test gets the same seed for reproducibility
return np.random.default_rng(seed=SEED)


@pytest.fixture(autouse=True)
def isolate_mpl_testing():
"""
Isolate matplotlib testing for parallel execution.
This prevents race conditions in parallel testing (pytest-xdist) where
multiple processes can interfere with each other's image comparison tests.
The main issue is that pytest-mpl uses shared temporary directories that
can conflict between processes.
"""
import matplotlib as mpl
import matplotlib.pyplot as plt
import tempfile
import os

# Store original backend and ensure consistent state
original_backend = mpl.get_backend()
if original_backend != "Agg":
mpl.use("Agg", force=True)

# Clear any existing figures
plt.close("all")

# Create process-specific temporary directory for mpl results
# This prevents file conflicts between parallel processes
worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master")
with tempfile.TemporaryDirectory(prefix=f"mpl_test_{worker_id}_") as temp_dir:
os.environ["MPL_TEST_TEMP_DIR"] = temp_dir

yield

# Clean up after test
plt.close("all")
uplt.close("all")

# Remove environment variable
if "MPL_TEST_TEMP_DIR" in os.environ:
del os.environ["MPL_TEST_TEMP_DIR"]

# Restore original backend
if original_backend != "Agg":
mpl.use(original_backend, force=True)
return np.random.default_rng(SEED)


@pytest.fixture(autouse=True)
def close_figures_after_test():
"""Automatically close all figures after each test."""
yield
uplt.close("all")


# Define command line option
def pytest_addoption(parser):
"""Add command line options for enhanced matplotlib testing."""
parser.addoption(
"--store-failed-only",
action="store_true",
help="Store only failed matplotlib comparison images (enables artifact optimization)",
help="Store only failed matplotlib comparison images",
)


def pytest_collection_modifyitems(config, items):
"""
Modify test items during collection to set up MPL testing.
class StoreFailedMplPlugin:
def __init__(self, config):
self.config = config

Check warning on line 33 in ultraplot/tests/conftest.py

Codecov / codecov/patch

ultraplot/tests/conftest.py#L33

Added line #L33 was not covered by tests

This function:
- Counts matplotlib image comparison tests
- Sets up progress tracking
- Skips tests with missing baseline images
"""
# Count total mpl tests for progress tracking
total_mpl_tests = count_mpl_tests(items)
# Get base directories as Path objects
self.result_dir = Path(config.getoption("--mpl-results-path", "./results"))
self.baseline_dir = Path(config.getoption("--mpl-baseline-path", "./baseline"))

Check warning on line 37 in ultraplot/tests/conftest.py

Codecov / codecov/patch

ultraplot/tests/conftest.py#L36-L37

Added lines #L36 - L37 were not covered by tests

print(f"Store Failed MPL Plugin initialized")
print(f"Result dir: {self.result_dir}")

Check warning on line 40 in ultraplot/tests/conftest.py

Codecov / codecov/patch

ultraplot/tests/conftest.py#L39-L40

Added lines #L39 - L40 were not covered by tests

def _has_mpl_marker(self, report: pytest.TestReport):
"""Check if the test has the mpl_image_compare marker."""
return report.keywords.get("mpl_image_compare", False)

Check warning on line 44 in ultraplot/tests/conftest.py

Codecov / codecov/patch

ultraplot/tests/conftest.py#L44

Added line #L44 was not covered by tests

def _remove_success(self, report: pytest.TestReport):
"""Remove successful test images."""

if total_mpl_tests > 0:
print(f"📊 Detected {total_mpl_tests} matplotlib image comparison tests")
# Initialize progress tracker with total count
progress_tracker = get_progress_tracker()
progress_tracker.set_total_tests(total_mpl_tests)
pattern = r"(?P<sep>::|/)|\[|\]|\.py"
name = re.sub(

Check warning on line 50 in ultraplot/tests/conftest.py

Codecov / codecov/patch

ultraplot/tests/conftest.py#L49-L50

Added lines #L49 - L50 were not covered by tests
pattern,
lambda m: "." if m.group("sep") else "_" if m.group(0) == "[" else "",
report.nodeid,
)
target = (self.result_dir / name).absolute()

Check warning on line 55 in ultraplot/tests/conftest.py

Codecov / codecov/patch

ultraplot/tests/conftest.py#L55

Added line #L55 was not covered by tests
if target.is_dir():
shutil.rmtree(target)

Check warning on line 57 in ultraplot/tests/conftest.py

Codecov / codecov/patch

ultraplot/tests/conftest.py#L57

Added line #L57 was not covered by tests

# Skip tests that don't have baseline images
@pytest.hookimpl(trylast=True)
def pytest_runtest_logreport(self, report):
"""Hook that processes each test report."""
# Delete successfull tests
if report.when == "call" and report.failed == False:
if self._has_mpl_marker(report):
self._remove_success(report)

Check warning on line 65 in ultraplot/tests/conftest.py

Codecov / codecov/patch

ultraplot/tests/conftest.py#L65

Added line #L65 was not covered by tests


def pytest_collection_modifyitems(config, items):
for item in items:
for mark in item.own_markers:
if base_dir := config.getoption("--mpl-baseline-path", default=None):
@@ -144,90 +77,10 @@
)


@pytest.hookimpl(trylast=True)
def pytest_terminal_summary(terminalreporter, exitstatus, config):
"""
Generate enhanced summary and HTML reports after all tests complete.
This function:
- Finalizes progress tracking
- Performs deferred cleanup
- Generates interactive HTML reports
- Only runs on the main process (not xdist workers)
"""
# Skip on workers, only run on the main process
if hasattr(config, "workerinput"):
return

# Check if we should generate reports
if not should_generate_html_report(config):
return

# Get the plugin instance to finalize operations
plugin = _get_plugin_instance(config)
if plugin:
# Finalize progress and cleanup
plugin.finalize()

# Generate HTML report
html_generator = HTMLReportGenerator(config)
failed_tests = plugin.get_failed_tests()
html_generator.generate_report(failed_tests)


# Register the plugin if the option is used
def pytest_configure(config):
"""
Configure pytest with the enhanced MPL plugin.
This function:
- Suppresses verbose matplotlib logging
- Registers the StoreFailedMplPlugin for enhanced functionality
- Sets up the plugin regardless of cleanup options (HTML reports always available)
- Configures process-specific temporary directories for parallel testing
"""
# Suppress ultraplot config loading which mpl does not recognize
logging.getLogger("matplotlib").setLevel(logging.ERROR)
logging.getLogger("ultraplot").setLevel(logging.WARNING)

# Configure process-specific results directory for parallel testing
worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master")
if (
not hasattr(config.option, "mpl_results_path")
or not config.option.mpl_results_path
):
config.option.mpl_results_path = f"./mpl-results-{worker_id}"

try:
# Always register the plugin - it provides enhanced functionality beyond just cleanup
config.pluginmanager.register(StoreFailedMplPlugin(config))
if config.getoption("--store-failed-only", False):
config.pluginmanager.register(StoreFailedMplPlugin(config))

Check warning on line 84 in ultraplot/tests/conftest.py

Codecov / codecov/patch

ultraplot/tests/conftest.py#L84

Added line #L84 was not covered by tests
except Exception as e:
print(f"Error during MPL plugin configuration: {e}")


def _get_plugin_instance(config):
"""Get the StoreFailedMplPlugin instance from the plugin manager."""
for plugin in config.pluginmanager.get_plugins():
if isinstance(plugin, StoreFailedMplPlugin):
return plugin
return None


# Legacy support - these functions are kept for backward compatibility
# but now delegate to the modular plugin system


def _should_generate_html_report(config):
"""Legacy function - delegates to utils module."""
return should_generate_html_report(config)


def _get_failed_mpl_tests(config):
"""Legacy function - delegates to utils module."""
return get_failed_mpl_tests(config)


def _get_results_directory(config):
"""Legacy function - delegates to utils module."""
from ultraplot.tests.mpl_plugin.utils import get_results_directory

return get_results_directory(config)
print(f"Error during plugin configuration: {e}")

Check warning on line 86 in ultraplot/tests/conftest.py

Codecov / codecov/patch

ultraplot/tests/conftest.py#L86

Added line #L86 was not covered by tests
30 changes: 0 additions & 30 deletions ultraplot/tests/mpl_plugin/__init__.py

This file was deleted.

109 changes: 0 additions & 109 deletions ultraplot/tests/mpl_plugin/cleanup.py

This file was deleted.

99 changes: 0 additions & 99 deletions ultraplot/tests/mpl_plugin/core.py

This file was deleted.

80 changes: 0 additions & 80 deletions ultraplot/tests/mpl_plugin/progress.py

This file was deleted.

642 changes: 0 additions & 642 deletions ultraplot/tests/mpl_plugin/reporting.py

This file was deleted.

8 changes: 0 additions & 8 deletions ultraplot/tests/mpl_plugin/templates/image_column.html

This file was deleted.

49 changes: 0 additions & 49 deletions ultraplot/tests/mpl_plugin/templates/report.html

This file was deleted.

245 changes: 0 additions & 245 deletions ultraplot/tests/mpl_plugin/templates/scripts.js

This file was deleted.

293 changes: 0 additions & 293 deletions ultraplot/tests/mpl_plugin/templates/styles.css

This file was deleted.

11 changes: 0 additions & 11 deletions ultraplot/tests/mpl_plugin/templates/test_case.html

This file was deleted.

131 changes: 0 additions & 131 deletions ultraplot/tests/mpl_plugin/utils.py

This file was deleted.

4 changes: 2 additions & 2 deletions ultraplot/tests/test_1dplots.py
Original file line number Diff line number Diff line change
@@ -525,7 +525,7 @@ def test_heatmap_labels(rng):
return fig


@pytest.mark.mpl_image_compare
@pytest.mark.mpl_image_compare()
def test_networks(rng):
"""
Create a baseline network graph that tests
@@ -575,7 +575,7 @@ def test_networks(rng):
inax = ax.inset_axes([*pos, 0.2, 0.2], zoom=0)
layout_kw = {}
if layout in ("random", "spring", "arf"):
layout_kw = dict(seed=np.random.default_rng(SEED))
layout_kw = dict(seed=SEED)

inax.graph(
g,