From c98be527db641aae3edb47d62e7e35ea45bbb78b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 18:36:21 +0200 Subject: [PATCH 01/47] lock on retrieval --- ultraplot/config.py | 227 ++++++++++++++++++++++---------------------- 1 file changed, 116 insertions(+), 111 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 8cdfe5c9..d372629a 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -774,12 +774,15 @@ def __getitem__(self, key): Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation (e.g., ``value = uplt.rc[name]``). """ - key, _ = self._validate_key(key) # might issue ultraplot removed/renamed error - try: - return rc_ultraplot[key] - except KeyError: - pass - return rc_matplotlib[key] # might issue matplotlib removed/renamed error + with self._lock: + key, _ = self._validate_key( + key + ) # might issue ultraplot removed/renamed error + try: + return rc_ultraplot[key] + except KeyError: + pass + return rc_matplotlib[key] # might issue matplotlib removed/renamed error def __setitem__(self, key, value): """ @@ -966,113 +969,115 @@ def _get_item_dicts(self, key, value, skip_cycle=False): properties associated with this key. Used when setting items, entering context blocks, or loading files. """ - # Get validated key, value, and child keys - key, value = self._validate_key(key, value) - value = self._validate_value(key, value) - keys = (key,) + rcsetup._rc_children.get(key, ()) # settings to change - contains = lambda *args: any(arg in keys for arg in args) # noqa: E731 - - # Fill dictionaries of matplotlib and ultraplot settings - # NOTE: Raise key error right away so it can be caught by _load_file(). - # Also ignore deprecation warnings so we only get them *once* on assignment - kw_ultraplot = {} # custom properties - kw_matplotlib = {} # builtin properties - with warnings.catch_warnings(): - warnings.simplefilter("ignore", mpl.MatplotlibDeprecationWarning) - warnings.simplefilter("ignore", warnings.UltraPlotWarning) - for key in keys: - if key in rc_matplotlib: - kw_matplotlib[key] = value - elif key in rc_ultraplot: - kw_ultraplot[key] = value - else: - raise KeyError(f"Invalid rc setting {key!r}.") - # Special key: configure inline backend - if contains("inlineformat"): - config_inline_backend(value) - - # Special key: apply stylesheet - elif contains("style"): - if value is not None: - ikw_matplotlib = _get_style_dict(value) - kw_matplotlib.update(ikw_matplotlib) - kw_ultraplot.update(_infer_ultraplot_dict(ikw_matplotlib)) - - # Cycler - # NOTE: Have to skip this step during initial ultraplot import - elif contains("cycle") and not skip_cycle: - from .colors import _get_cmap_subtype - - cmap = _get_cmap_subtype(value, "discrete") - kw_matplotlib["axes.prop_cycle"] = cycler.cycler("color", cmap.colors) - kw_matplotlib["patch.facecolor"] = "C0" - - # Turning bounding box on should turn border off and vice versa - elif contains("abc.bbox", "title.bbox", "abc.border", "title.border"): - if value: - name, this = key.split(".") - other = "border" if this == "bbox" else "bbox" - kw_ultraplot[name + "." + other] = False - - # Fontsize - # NOTE: Re-application of e.g. size='small' uses the updated 'font.size' - elif contains("font.size"): - kw_ultraplot.update( - { - key: value - for key, value in rc_ultraplot.items() - if key in rcsetup.FONT_KEYS and value in mfonts.font_scalings - } - ) - kw_matplotlib.update( - { - key: value - for key, value in rc_matplotlib.items() - if key in rcsetup.FONT_KEYS and value in mfonts.font_scalings - } - ) + with self._lock: + # Get validated key, value, and child keys + key, value = self._validate_key(key, value) + value = self._validate_value(key, value) + keys = (key,) + rcsetup._rc_children.get(key, ()) # settings to change + contains = lambda *args: any(arg in keys for arg in args) # noqa: E731 + + # Fill dictionaries of matplotlib and ultraplot settings + # NOTE: Raise key error right away so it can be caught by _load_file(). + # Also ignore deprecation warnings so we only get them *once* on assignment + kw_ultraplot = {} # custom properties + kw_matplotlib = {} # builtin properties + with warnings.catch_warnings(): + warnings.simplefilter("ignore", mpl.MatplotlibDeprecationWarning) + warnings.simplefilter("ignore", warnings.UltraPlotWarning) + for key in keys: + if key in rc_matplotlib: + kw_matplotlib[key] = value + elif key in rc_ultraplot: + kw_ultraplot[key] = value + else: + raise KeyError(f"Invalid rc setting {key!r}.") + + # Special key: configure inline backend + if contains("inlineformat"): + config_inline_backend(value) + + # Special key: apply stylesheet + elif contains("style"): + if value is not None: + ikw_matplotlib = _get_style_dict(value) + kw_matplotlib.update(ikw_matplotlib) + kw_ultraplot.update(_infer_ultraplot_dict(ikw_matplotlib)) + + # Cycler + # NOTE: Have to skip this step during initial ultraplot import + elif contains("cycle") and not skip_cycle: + from .colors import _get_cmap_subtype + + cmap = _get_cmap_subtype(value, "discrete") + kw_matplotlib["axes.prop_cycle"] = cycler.cycler("color", cmap.colors) + kw_matplotlib["patch.facecolor"] = "C0" + + # Turning bounding box on should turn border off and vice versa + elif contains("abc.bbox", "title.bbox", "abc.border", "title.border"): + if value: + name, this = key.split(".") + other = "border" if this == "bbox" else "bbox" + kw_ultraplot[name + "." + other] = False + + # Fontsize + # NOTE: Re-application of e.g. size='small' uses the updated 'font.size' + elif contains("font.size"): + kw_ultraplot.update( + { + key: value + for key, value in rc_ultraplot.items() + if key in rcsetup.FONT_KEYS and value in mfonts.font_scalings + } + ) + kw_matplotlib.update( + { + key: value + for key, value in rc_matplotlib.items() + if key in rcsetup.FONT_KEYS and value in mfonts.font_scalings + } + ) - # Tick length/major-minor tick length ratio - elif contains("tick.len", "tick.lenratio"): - if contains("tick.len"): - ticklen = value - ratio = rc_ultraplot["tick.lenratio"] - else: - ticklen = rc_ultraplot["tick.len"] - ratio = value - kw_matplotlib["xtick.minor.size"] = ticklen * ratio - kw_matplotlib["ytick.minor.size"] = ticklen * ratio - - # Spine width/major-minor tick width ratio - elif contains("tick.width", "tick.widthratio"): - if contains("tick.width"): - tickwidth = value - ratio = rc_ultraplot["tick.widthratio"] - else: - tickwidth = rc_ultraplot["tick.width"] - ratio = value - kw_matplotlib["xtick.minor.width"] = tickwidth * ratio - kw_matplotlib["ytick.minor.width"] = tickwidth * ratio - - # Gridline width - elif contains("grid.width", "grid.widthratio"): - if contains("grid.width"): - gridwidth = value - ratio = rc_ultraplot["grid.widthratio"] - else: - gridwidth = rc_ultraplot["grid.width"] - ratio = value - kw_ultraplot["gridminor.linewidth"] = gridwidth * ratio - kw_ultraplot["gridminor.width"] = gridwidth * ratio - - # Gridline toggling - elif contains("grid", "gridminor"): - b, which = _translate_grid( - value, "gridminor" if contains("gridminor") else "grid" - ) - kw_matplotlib["axes.grid"] = b - kw_matplotlib["axes.grid.which"] = which + # Tick length/major-minor tick length ratio + elif contains("tick.len", "tick.lenratio"): + if contains("tick.len"): + ticklen = value + ratio = rc_ultraplot["tick.lenratio"] + else: + ticklen = rc_ultraplot["tick.len"] + ratio = value + kw_matplotlib["xtick.minor.size"] = ticklen * ratio + kw_matplotlib["ytick.minor.size"] = ticklen * ratio + + # Spine width/major-minor tick width ratio + elif contains("tick.width", "tick.widthratio"): + if contains("tick.width"): + tickwidth = value + ratio = rc_ultraplot["tick.widthratio"] + else: + tickwidth = rc_ultraplot["tick.width"] + ratio = value + kw_matplotlib["xtick.minor.width"] = tickwidth * ratio + kw_matplotlib["ytick.minor.width"] = tickwidth * ratio + + # Gridline width + elif contains("grid.width", "grid.widthratio"): + if contains("grid.width"): + gridwidth = value + ratio = rc_ultraplot["grid.widthratio"] + else: + gridwidth = rc_ultraplot["grid.width"] + ratio = value + kw_ultraplot["gridminor.linewidth"] = gridwidth * ratio + kw_ultraplot["gridminor.width"] = gridwidth * ratio + + # Gridline toggling + elif contains("grid", "gridminor"): + b, which = _translate_grid( + value, "gridminor" if contains("gridminor") else "grid" + ) + kw_matplotlib["axes.grid"] = b + kw_matplotlib["axes.grid.which"] = which return kw_ultraplot, kw_matplotlib From ddde31ca507a838d49fb2cc179a04f163bd9d8d9 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 19:21:07 +0200 Subject: [PATCH 02/47] restore lock --- ultraplot/config.py | 228 ++++++++++++++++++------------------ ultraplot/tests/conftest.py | 22 +++- 2 files changed, 130 insertions(+), 120 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index d372629a..12f8b568 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -774,15 +774,12 @@ def __getitem__(self, key): Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation (e.g., ``value = uplt.rc[name]``). """ - with self._lock: - key, _ = self._validate_key( - key - ) # might issue ultraplot removed/renamed error - try: - return rc_ultraplot[key] - except KeyError: - pass - return rc_matplotlib[key] # might issue matplotlib removed/renamed error + key, _ = self._validate_key(key) # might issue ultraplot removed/renamed error + try: + return rc_ultraplot[key] + except KeyError: + pass + return rc_matplotlib[key] # might issue matplotlib removed/renamed error def __setitem__(self, key, value): """ @@ -970,114 +967,113 @@ def _get_item_dicts(self, key, value, skip_cycle=False): context blocks, or loading files. """ - with self._lock: - # Get validated key, value, and child keys - key, value = self._validate_key(key, value) - value = self._validate_value(key, value) - keys = (key,) + rcsetup._rc_children.get(key, ()) # settings to change - contains = lambda *args: any(arg in keys for arg in args) # noqa: E731 - - # Fill dictionaries of matplotlib and ultraplot settings - # NOTE: Raise key error right away so it can be caught by _load_file(). - # Also ignore deprecation warnings so we only get them *once* on assignment - kw_ultraplot = {} # custom properties - kw_matplotlib = {} # builtin properties - with warnings.catch_warnings(): - warnings.simplefilter("ignore", mpl.MatplotlibDeprecationWarning) - warnings.simplefilter("ignore", warnings.UltraPlotWarning) - for key in keys: - if key in rc_matplotlib: - kw_matplotlib[key] = value - elif key in rc_ultraplot: - kw_ultraplot[key] = value - else: - raise KeyError(f"Invalid rc setting {key!r}.") - - # Special key: configure inline backend - if contains("inlineformat"): - config_inline_backend(value) - - # Special key: apply stylesheet - elif contains("style"): - if value is not None: - ikw_matplotlib = _get_style_dict(value) - kw_matplotlib.update(ikw_matplotlib) - kw_ultraplot.update(_infer_ultraplot_dict(ikw_matplotlib)) - - # Cycler - # NOTE: Have to skip this step during initial ultraplot import - elif contains("cycle") and not skip_cycle: - from .colors import _get_cmap_subtype - - cmap = _get_cmap_subtype(value, "discrete") - kw_matplotlib["axes.prop_cycle"] = cycler.cycler("color", cmap.colors) - kw_matplotlib["patch.facecolor"] = "C0" - - # Turning bounding box on should turn border off and vice versa - elif contains("abc.bbox", "title.bbox", "abc.border", "title.border"): - if value: - name, this = key.split(".") - other = "border" if this == "bbox" else "bbox" - kw_ultraplot[name + "." + other] = False - - # Fontsize - # NOTE: Re-application of e.g. size='small' uses the updated 'font.size' - elif contains("font.size"): - kw_ultraplot.update( - { - key: value - for key, value in rc_ultraplot.items() - if key in rcsetup.FONT_KEYS and value in mfonts.font_scalings - } - ) - kw_matplotlib.update( - { - key: value - for key, value in rc_matplotlib.items() - if key in rcsetup.FONT_KEYS and value in mfonts.font_scalings - } - ) - - # Tick length/major-minor tick length ratio - elif contains("tick.len", "tick.lenratio"): - if contains("tick.len"): - ticklen = value - ratio = rc_ultraplot["tick.lenratio"] - else: - ticklen = rc_ultraplot["tick.len"] - ratio = value - kw_matplotlib["xtick.minor.size"] = ticklen * ratio - kw_matplotlib["ytick.minor.size"] = ticklen * ratio - - # Spine width/major-minor tick width ratio - elif contains("tick.width", "tick.widthratio"): - if contains("tick.width"): - tickwidth = value - ratio = rc_ultraplot["tick.widthratio"] - else: - tickwidth = rc_ultraplot["tick.width"] - ratio = value - kw_matplotlib["xtick.minor.width"] = tickwidth * ratio - kw_matplotlib["ytick.minor.width"] = tickwidth * ratio - - # Gridline width - elif contains("grid.width", "grid.widthratio"): - if contains("grid.width"): - gridwidth = value - ratio = rc_ultraplot["grid.widthratio"] + # Get validated key, value, and child keys + key, value = self._validate_key(key, value) + value = self._validate_value(key, value) + keys = (key,) + rcsetup._rc_children.get(key, ()) # settings to change + contains = lambda *args: any(arg in keys for arg in args) # noqa: E731 + + # Fill dictionaries of matplotlib and ultraplot settings + # NOTE: Raise key error right away so it can be caught by _load_file(). + # Also ignore deprecation warnings so we only get them *once* on assignment + kw_ultraplot = {} # custom properties + kw_matplotlib = {} # builtin properties + with warnings.catch_warnings(): + warnings.simplefilter("ignore", mpl.MatplotlibDeprecationWarning) + warnings.simplefilter("ignore", warnings.UltraPlotWarning) + for key in keys: + if key in rc_matplotlib: + kw_matplotlib[key] = value + elif key in rc_ultraplot: + kw_ultraplot[key] = value else: - gridwidth = rc_ultraplot["grid.width"] - ratio = value - kw_ultraplot["gridminor.linewidth"] = gridwidth * ratio - kw_ultraplot["gridminor.width"] = gridwidth * ratio - - # Gridline toggling - elif contains("grid", "gridminor"): - b, which = _translate_grid( - value, "gridminor" if contains("gridminor") else "grid" - ) - kw_matplotlib["axes.grid"] = b - kw_matplotlib["axes.grid.which"] = which + raise KeyError(f"Invalid rc setting {key!r}.") + + # Special key: configure inline backend + if contains("inlineformat"): + config_inline_backend(value) + + # Special key: apply stylesheet + elif contains("style"): + if value is not None: + ikw_matplotlib = _get_style_dict(value) + kw_matplotlib.update(ikw_matplotlib) + kw_ultraplot.update(_infer_ultraplot_dict(ikw_matplotlib)) + + # Cycler + # NOTE: Have to skip this step during initial ultraplot import + elif contains("cycle") and not skip_cycle: + from .colors import _get_cmap_subtype + + cmap = _get_cmap_subtype(value, "discrete") + kw_matplotlib["axes.prop_cycle"] = cycler.cycler("color", cmap.colors) + kw_matplotlib["patch.facecolor"] = "C0" + + # Turning bounding box on should turn border off and vice versa + elif contains("abc.bbox", "title.bbox", "abc.border", "title.border"): + if value: + name, this = key.split(".") + other = "border" if this == "bbox" else "bbox" + kw_ultraplot[name + "." + other] = False + + # Fontsize + # NOTE: Re-application of e.g. size='small' uses the updated 'font.size' + elif contains("font.size"): + kw_ultraplot.update( + { + key: value + for key, value in rc_ultraplot.items() + if key in rcsetup.FONT_KEYS and value in mfonts.font_scalings + } + ) + kw_matplotlib.update( + { + key: value + for key, value in rc_matplotlib.items() + if key in rcsetup.FONT_KEYS and value in mfonts.font_scalings + } + ) + + # Tick length/major-minor tick length ratio + elif contains("tick.len", "tick.lenratio"): + if contains("tick.len"): + ticklen = value + ratio = rc_ultraplot["tick.lenratio"] + else: + ticklen = rc_ultraplot["tick.len"] + ratio = value + kw_matplotlib["xtick.minor.size"] = ticklen * ratio + kw_matplotlib["ytick.minor.size"] = ticklen * ratio + + # Spine width/major-minor tick width ratio + elif contains("tick.width", "tick.widthratio"): + if contains("tick.width"): + tickwidth = value + ratio = rc_ultraplot["tick.widthratio"] + else: + tickwidth = rc_ultraplot["tick.width"] + ratio = value + kw_matplotlib["xtick.minor.width"] = tickwidth * ratio + kw_matplotlib["ytick.minor.width"] = tickwidth * ratio + + # Gridline width + elif contains("grid.width", "grid.widthratio"): + if contains("grid.width"): + gridwidth = value + ratio = rc_ultraplot["grid.widthratio"] + else: + gridwidth = rc_ultraplot["grid.width"] + ratio = value + kw_ultraplot["gridminor.linewidth"] = gridwidth * ratio + kw_ultraplot["gridminor.width"] = gridwidth * ratio + + # Gridline toggling + elif contains("grid", "gridminor"): + b, which = _translate_grid( + value, "gridminor" if contains("gridminor") else "grid" + ) + kw_matplotlib["axes.grid"] = b + kw_matplotlib["axes.grid.which"] = which return kw_ultraplot, kw_matplotlib diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 51839c19..82cf7669 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -9,6 +9,12 @@ - Each thread gets independent, deterministic RNG instances - Compatible with pytest-xdist parallel execution - Clean separation of concerns - tests explicitly declare RNG dependencies + +Matplotlib rcParams Safety: +- Automatic rcParams isolation for all tests prevents interference +- Tests that modify matplotlib settings are automatically isolated +- Dedicated rcparams_isolation fixture for explicit isolation needs +- Thread-safe for parallel execution with pytest-xdist """ import threading, os, shutil, pytest, re @@ -63,17 +69,21 @@ def isolate_mpl_testing(): 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. + + Additionally, this fixture provides rcParams isolation to ensure tests + that modify matplotlib settings don't interfere with each other. """ - import matplotlib as mpl - import matplotlib.pyplot as plt - import tempfile - import os + import matplotlib as mpl, matplotlib.pyplot as plt + import tempfile, os, copy # Store original backend and ensure consistent state original_backend = mpl.get_backend() if original_backend != "Agg": mpl.use("Agg", force=True) + # Store original rcParams for isolation + original_rcparams = copy.deepcopy(mpl.rcParams) + # Clear any existing figures plt.close("all") @@ -89,6 +99,10 @@ def isolate_mpl_testing(): plt.close("all") uplt.close("all") + # Restore original rcParams to prevent test interference + mpl.rcParams.clear() + mpl.rcParams.update(original_rcparams) + # Remove environment variable if "MPL_TEST_TEMP_DIR" in os.environ: del os.environ["MPL_TEST_TEMP_DIR"] From 413a57edb2c4e5f0910e9fa51c2e213004663941 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 19:31:03 +0200 Subject: [PATCH 03/47] add loadscope --- .github/workflows/build-ultraplot.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 736ecc48..b2a899ef 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -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 -n auto --dist loadscope -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 -n auto --dist loadscope -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 From d0e032ac55eb197d2092dd802757ef0c0b674ffc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 20:14:03 +0200 Subject: [PATCH 04/47] further make configuration threadsafe --- ultraplot/config.py | 123 +++++++++++++++++++++++++++----------------- 1 file changed, 76 insertions(+), 47 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 12f8b568..8f16d9de 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -744,8 +744,8 @@ def __str__(self): return type(rc_matplotlib).__str__(src) + "\n..." def __iter__(self): - yield from rc_ultraplot # sorted ultraplot settings, ignoring deprecations - yield from rc_matplotlib # sorted matplotlib settings, ignoring deprecations + yield from self._rc_ultraplot # sorted ultraplot settings, ignoring deprecations + yield from self._rc_matplotlib # sorted matplotlib settings, ignoring deprecations def __len__(self): return len(tuple(iter(self))) @@ -765,10 +765,40 @@ def __init__(self, local=True, user=True, default=True, **kwargs): """ import threading - self._context = [] + self._local_props = threading.local() self._lock = threading.RLock() self._init(local=local, user=user, default=default, **kwargs) + @property + def _context(self): + """Get current thread's context list.""" + if not hasattr(self._local_props, "context"): + self._local_props.context = [] + return self._local_props.context + + @_context.setter + def _context(self, value): + """Set current thread's context list.""" + self._local_props.context = value + + @property + def _rc_ultraplot(self): + """Get current thread's ultraplot config dict.""" + if not hasattr(self._local_props, "rc_ultraplot"): + # Use dict() to avoid validation during thread-local setup + self._local_props.rc_ultraplot = dict(rcsetup._rc_ultraplot_default) + return self._local_props.rc_ultraplot + + @property + def _rc_matplotlib(self): + """Get current thread's matplotlib config dict.""" + if not hasattr(self._local_props, "rc_matplotlib"): + import matplotlib as mpl + + # Use dict() to avoid validation during thread-local setup + self._local_props.rc_matplotlib = dict(mpl.rcParams) + return self._local_props.rc_matplotlib + def __getitem__(self, key): """ Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation @@ -776,10 +806,10 @@ def __getitem__(self, key): """ key, _ = self._validate_key(key) # might issue ultraplot removed/renamed error try: - return rc_ultraplot[key] + return self._rc_ultraplot[key] except KeyError: pass - return rc_matplotlib[key] # might issue matplotlib removed/renamed error + return self._rc_matplotlib[key] # might issue matplotlib removed/renamed error def __setitem__(self, key, value): """ @@ -834,7 +864,7 @@ def __enter__(self): raise e for rc_dict, kw_new in zip( - (rc_ultraplot, rc_matplotlib), + (self._rc_ultraplot, self._rc_matplotlib), (kw_ultraplot, kw_matplotlib), ): for key, value in kw_new.items(): @@ -853,45 +883,44 @@ def __exit__(self, *args): # noqa: U100 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) + self._rc_ultraplot.update(kw_ultraplot) + self._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: + self._rc_matplotlib.update(_get_style_dict("original", filter=False)) + self._rc_matplotlib.update(rcsetup._rc_matplotlib_default) + self._rc_ultraplot.update(rcsetup._rc_ultraplot_default) + for key, value in self._rc_ultraplot.items(): + kw_ultraplot, kw_matplotlib = self._get_item_dicts( + key, value, skip_cycle=skip_cycle + ) + self._rc_matplotlib.update(kw_matplotlib) + self._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) @staticmethod def _validate_key(key, value=None): @@ -943,9 +972,9 @@ def _get_item_context(self, key, mode=None): mode = self._context_mode cache = tuple(context.rc_new for context in self._context) if mode == 0: - rcdicts = (*cache, rc_ultraplot, rc_matplotlib) + rcdicts = (*cache, self._rc_ultraplot, self._rc_matplotlib) elif mode == 1: - rcdicts = (*cache, rc_ultraplot) # added settings only! + rcdicts = (*cache, self._rc_ultraplot) # added settings only! elif mode == 2: rcdicts = (*cache,) else: @@ -1038,9 +1067,9 @@ def _get_item_dicts(self, key, value, skip_cycle=False): elif contains("tick.len", "tick.lenratio"): if contains("tick.len"): ticklen = value - ratio = rc_ultraplot["tick.lenratio"] + ratio = self._rc_ultraplot["tick.lenratio"] else: - ticklen = rc_ultraplot["tick.len"] + ticklen = self._rc_ultraplot["tick.len"] ratio = value kw_matplotlib["xtick.minor.size"] = ticklen * ratio kw_matplotlib["ytick.minor.size"] = ticklen * ratio @@ -1049,9 +1078,9 @@ def _get_item_dicts(self, key, value, skip_cycle=False): elif contains("tick.width", "tick.widthratio"): if contains("tick.width"): tickwidth = value - ratio = rc_ultraplot["tick.widthratio"] + ratio = self._rc_ultraplot["tick.widthratio"] else: - tickwidth = rc_ultraplot["tick.width"] + tickwidth = self._rc_ultraplot["tick.width"] ratio = value kw_matplotlib["xtick.minor.width"] = tickwidth * ratio kw_matplotlib["ytick.minor.width"] = tickwidth * ratio @@ -1060,9 +1089,9 @@ def _get_item_dicts(self, key, value, skip_cycle=False): elif contains("grid.width", "grid.widthratio"): if contains("grid.width"): gridwidth = value - ratio = rc_ultraplot["grid.widthratio"] + ratio = self._rc_ultraplot["grid.widthratio"] else: - gridwidth = rc_ultraplot["grid.width"] + gridwidth = self._rc_ultraplot["grid.width"] ratio = value kw_ultraplot["gridminor.linewidth"] = gridwidth * ratio kw_ultraplot["gridminor.width"] = gridwidth * ratio From 6c9a24287bea4e93c63e1ff16501bab5a5bf4cb4 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 20:54:58 +0200 Subject: [PATCH 05/47] rm unnecessary funcs --- ultraplot/tests/conftest.py | 52 ------------------------------------- 1 file changed, 52 deletions(-) diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 82cf7669..1c60b99a 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -60,58 +60,6 @@ def test_something(rng): 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. - - Additionally, this fixture provides rcParams isolation to ensure tests - that modify matplotlib settings don't interfere with each other. - """ - import matplotlib as mpl, matplotlib.pyplot as plt - import tempfile, os, copy - - # Store original backend and ensure consistent state - original_backend = mpl.get_backend() - if original_backend != "Agg": - mpl.use("Agg", force=True) - - # Store original rcParams for isolation - original_rcparams = copy.deepcopy(mpl.rcParams) - - # 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") - - # Restore original rcParams to prevent test interference - mpl.rcParams.clear() - mpl.rcParams.update(original_rcparams) - - # 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) - - @pytest.fixture(autouse=True) def close_figures_after_test(): """Automatically close all figures after each test.""" From fb71696a22c523f507385af6831a2a6cacfe4cd5 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 20:58:16 +0200 Subject: [PATCH 06/47] reset rc after test --- ultraplot/tests/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 1c60b99a..6bf36ee0 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -61,8 +61,10 @@ def test_something(rng): @pytest.fixture(autouse=True) -def close_figures_after_test(): - """Automatically close all figures after each test.""" +def reset_rc_after_test(): + """Reset rc to full ultraplot defaults before each test.""" + # Reset rc to ensure each test starts with full ultraplot configuration + uplt.rc.reset() yield uplt.close("all") From f06562fc2f14ffb1a3d90c7f1daae9f8aba62a81 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 20:59:19 +0200 Subject: [PATCH 07/47] reset before and after explicitly --- ultraplot/config.py | 10 ++++------ ultraplot/tests/conftest.py | 7 +++++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 8f16d9de..a0bd3e78 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -785,18 +785,16 @@ def _context(self, value): def _rc_ultraplot(self): """Get current thread's ultraplot config dict.""" if not hasattr(self._local_props, "rc_ultraplot"): - # Use dict() to avoid validation during thread-local setup - self._local_props.rc_ultraplot = dict(rcsetup._rc_ultraplot_default) + # Copy from global rc_ultraplot which has full initialization + self._local_props.rc_ultraplot = dict(rc_ultraplot) return self._local_props.rc_ultraplot @property def _rc_matplotlib(self): """Get current thread's matplotlib config dict.""" if not hasattr(self._local_props, "rc_matplotlib"): - import matplotlib as mpl - - # Use dict() to avoid validation during thread-local setup - self._local_props.rc_matplotlib = dict(mpl.rcParams) + # Copy from global rc_matplotlib which has full initialization + self._local_props.rc_matplotlib = dict(rc_matplotlib) return self._local_props.rc_matplotlib def __getitem__(self, key): diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 6bf36ee0..9451cd06 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -61,12 +61,15 @@ def test_something(rng): @pytest.fixture(autouse=True) -def reset_rc_after_test(): - """Reset rc to full ultraplot defaults before each test.""" +def reset_rc_and_close_figures(): + """Reset rc to full ultraplot defaults and close figures for each test.""" # Reset rc to ensure each test starts with full ultraplot configuration uplt.rc.reset() yield + # Clean up after test uplt.close("all") + # Reset again to clean up any test modifications + uplt.rc.reset() def pytest_addoption(parser): From edf814bcd126daee12ca760eb9fcbe93b5fe6711 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 21:01:03 +0200 Subject: [PATCH 08/47] restore build.yml --- .github/workflows/build-ultraplot.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index b2a899ef..736ecc48 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -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 --dist loadscope -W ignore --mpl-generate-path=baseline --mpl-default-style="./ultraplot.yml" + pytest -n auto -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 --dist loadscope -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 -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 # Return the html output of the comparison even if failed - name: Upload comparison failures From bddf96ff09c2f6351f4202c17f19768081913dea Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 21:19:39 +0200 Subject: [PATCH 09/47] further attempt to fix the settings --- ultraplot/config.py | 30 ++++++++++++++++++--- ultraplot/tests/conftest.py | 53 ++++++++++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index a0bd3e78..3a845fb9 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -785,7 +785,7 @@ def _context(self, value): def _rc_ultraplot(self): """Get current thread's ultraplot config dict.""" if not hasattr(self._local_props, "rc_ultraplot"): - # Copy from global rc_ultraplot which has full initialization + # Use dict() to avoid validation during thread-local setup self._local_props.rc_ultraplot = dict(rc_ultraplot) return self._local_props.rc_ultraplot @@ -793,7 +793,7 @@ def _rc_ultraplot(self): def _rc_matplotlib(self): """Get current thread's matplotlib config dict.""" if not hasattr(self._local_props, "rc_matplotlib"): - # Copy from global rc_matplotlib which has full initialization + # Use dict() to avoid validation during thread-local setup self._local_props.rc_matplotlib = dict(rc_matplotlib) return self._local_props.rc_matplotlib @@ -895,10 +895,34 @@ def _init(self, *, local, user, default, skip_cycle=False): # Update from default settings # NOTE: see _remove_blacklisted_style_params bugfix if default: + # Clear thread-local dicts to force fresh initialization + if hasattr(self._local_props, "rc_ultraplot"): + delattr(self._local_props, "rc_ultraplot") + if hasattr(self._local_props, "rc_matplotlib"): + delattr(self._local_props, "rc_matplotlib") + + # Apply defaults with proper processing order self._rc_matplotlib.update(_get_style_dict("original", filter=False)) self._rc_matplotlib.update(rcsetup._rc_matplotlib_default) self._rc_ultraplot.update(rcsetup._rc_ultraplot_default) - for key, value in self._rc_ultraplot.items(): + + # Apply ultraplot settings to matplotlib in correct order + # Process 'gridminor' before 'grid' to avoid conflicts + ultraplot_items = list(self._rc_ultraplot.items()) + grid_items = [] + other_items = [] + + for key, value in ultraplot_items: + if key in ("grid", "gridminor"): + grid_items.append((key, value)) + else: + other_items.append((key, value)) + + # Sort grid items so gridminor comes before grid + grid_items.sort(key=lambda x: 0 if x[0] == "gridminor" else 1) + + # Process all items in the correct order + for key, value in other_items + grid_items: kw_ultraplot, kw_matplotlib = self._get_item_dicts( key, value, skip_cycle=skip_cycle ) diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 9451cd06..8b560d9c 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -63,13 +63,58 @@ def test_something(rng): @pytest.fixture(autouse=True) def reset_rc_and_close_figures(): """Reset rc to full ultraplot defaults and close figures for each test.""" - # Reset rc to ensure each test starts with full ultraplot configuration - uplt.rc.reset() + # Force complete ultraplot initialization for this thread + _ensure_ultraplot_defaults() + yield + # Clean up after test uplt.close("all") - # Reset again to clean up any test modifications - uplt.rc.reset() + + # Reset to clean state for next test + _ensure_ultraplot_defaults() + + +def _ensure_ultraplot_defaults(): + """Ensure current thread has complete ultraplot configuration.""" + from ultraplot.internals import rcsetup + from ultraplot.config import _get_style_dict + + # Clear thread-local storage to force reinitialization + if hasattr(uplt.rc, "_local_props"): + if hasattr(uplt.rc._local_props, "rc_ultraplot"): + delattr(uplt.rc._local_props, "rc_ultraplot") + if hasattr(uplt.rc._local_props, "rc_matplotlib"): + delattr(uplt.rc._local_props, "rc_matplotlib") + + # Force thread-local dicts to exist + _ = uplt.rc._rc_ultraplot + _ = uplt.rc._rc_matplotlib + + # Apply complete ultraplot initialization sequence + uplt.rc._rc_matplotlib.update(_get_style_dict("original", filter=False)) + uplt.rc._rc_matplotlib.update(rcsetup._rc_matplotlib_default) + uplt.rc._rc_ultraplot.update(rcsetup._rc_ultraplot_default) + + # Apply ultraplot->matplotlib translations in correct order + ultraplot_items = list(uplt.rc._rc_ultraplot.items()) + grid_items = [(k, v) for k, v in ultraplot_items if k in ("grid", "gridminor")] + other_items = [(k, v) for k, v in ultraplot_items if k not in ("grid", "gridminor")] + + # Process gridminor before grid to avoid conflicts + grid_items.sort(key=lambda x: 0 if x[0] == "gridminor" else 1) + + # Apply all ultraplot settings to matplotlib + for key, value in other_items + grid_items: + try: + kw_ultraplot, kw_matplotlib = uplt.rc._get_item_dicts( + key, value, skip_cycle=True + ) + uplt.rc._rc_matplotlib.update(kw_matplotlib) + uplt.rc._rc_ultraplot.update(kw_ultraplot) + except: + # Skip any problematic settings during test setup + continue def pytest_addoption(parser): From a0606843a69a93d31734068550c376800766ac12 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 22:43:58 +0200 Subject: [PATCH 10/47] lock retrieval behind thread local setting --- ultraplot/config.py | 4 ++-- ultraplot/tests/conftest.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 3a845fb9..b2c0d4b0 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -816,8 +816,8 @@ def __setitem__(self, key, value): """ with self._lock: kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) - rc_ultraplot.update(kw_ultraplot) - rc_matplotlib.update(kw_matplotlib) + self._rc_ultraplot.update(kw_ultraplot) + self._rc_matplotlib.update(kw_matplotlib) def __getattr__(self, attr): """ diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 8b560d9c..2d341ba7 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -68,7 +68,7 @@ def reset_rc_and_close_figures(): yield - # Clean up after test + # Clean up after test - only close figures, don't reset rc uplt.close("all") # Reset to clean state for next test From 30284493e7e6df148185e0aa6fec7d22425d803b Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Wed, 18 Jun 2025 07:50:09 +0200 Subject: [PATCH 11/47] Add xdist to image compare (#266) --- ultraplot/config.py | 74 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index b2c0d4b0..cd8b6199 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -765,7 +765,11 @@ def __init__(self, local=True, user=True, default=True, **kwargs): """ import threading +<<<<<<< HEAD self._local_props = threading.local() +======= + self._context = [] +>>>>>>> 500e45b1 (Add xdist to image compare (#266)) self._lock = threading.RLock() self._init(local=local, user=user, default=default, **kwargs) @@ -816,8 +820,13 @@ def __setitem__(self, key, value): """ with self._lock: kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) +<<<<<<< HEAD self._rc_ultraplot.update(kw_ultraplot) self._rc_matplotlib.update(kw_matplotlib) +======= + rc_ultraplot.update(kw_ultraplot) + rc_matplotlib.update(kw_matplotlib) +>>>>>>> 500e45b1 (Add xdist to image compare (#266)) def __getattr__(self, attr): """ @@ -862,7 +871,11 @@ def __enter__(self): raise e for rc_dict, kw_new in zip( +<<<<<<< HEAD (self._rc_ultraplot, self._rc_matplotlib), +======= + (rc_ultraplot, rc_matplotlib), +>>>>>>> 500e45b1 (Add xdist to image compare (#266)) (kw_ultraplot, kw_matplotlib), ): for key, value in kw_new.items(): @@ -881,17 +894,24 @@ def __exit__(self, *args): # noqa: U100 context = self._context[-1] for key, value in context.rc_old.items(): kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) +<<<<<<< HEAD self._rc_ultraplot.update(kw_ultraplot) self._rc_matplotlib.update(kw_matplotlib) +======= + rc_ultraplot.update(kw_ultraplot) + rc_matplotlib.update(kw_matplotlib) +>>>>>>> 500e45b1 (Add xdist to image compare (#266)) del self._context[-1] def _init(self, *, local, user, default, skip_cycle=False): """ Initialize the configurator. """ - # Always remove context objects - self._context.clear() + with self._lock: + # Always remove context objects + self._context.clear() +<<<<<<< HEAD # Update from default settings # NOTE: see _remove_blacklisted_style_params bugfix if default: @@ -928,21 +948,35 @@ def _init(self, *, local, user, default, skip_cycle=False): ) self._rc_matplotlib.update(kw_matplotlib) self._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) +======= + # 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) +>>>>>>> 500e45b1 (Add xdist to image compare (#266)) + + # 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) @staticmethod def _validate_key(key, value=None): @@ -994,9 +1028,15 @@ def _get_item_context(self, key, mode=None): mode = self._context_mode cache = tuple(context.rc_new for context in self._context) if mode == 0: +<<<<<<< HEAD rcdicts = (*cache, self._rc_ultraplot, self._rc_matplotlib) elif mode == 1: rcdicts = (*cache, self._rc_ultraplot) # added settings only! +======= + rcdicts = (*cache, rc_ultraplot, rc_matplotlib) + elif mode == 1: + rcdicts = (*cache, rc_ultraplot) # added settings only! +>>>>>>> 500e45b1 (Add xdist to image compare (#266)) elif mode == 2: rcdicts = (*cache,) else: From 4efb834908f700766fefa062d0c27ea1447342ba Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 09:56:25 +0200 Subject: [PATCH 12/47] redo thread safety and add a unittest --- ultraplot/config.py | 493 ++++++++++++++------------------ ultraplot/internals/__init__.py | 1 + ultraplot/internals/rcsetup.py | 24 +- ultraplot/tests/conftest.py | 47 +-- 4 files changed, 235 insertions(+), 330 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index cd8b6199..aca6a2b4 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -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 @@ -373,7 +375,7 @@ def _infer_ultraplot_dict(kw_params): return kw_ultraplot -def config_inline_backend(fmt=None): +def config_inline_backend(fmt=None, rc_ultraplot=None): """ Set up the ipython `inline backend display format \ `__ @@ -733,29 +735,6 @@ class Configurator(MutableMapping, dict): on import. See the :ref:`user guide ` for details. """ - def __repr__(self): - cls = type("rc", (dict,), {}) # temporary class with short name - src = cls({key: val for key, val in rc_ultraplot.items() if "." not in key}) - return type(rc_matplotlib).__repr__(src).strip()[:-1] + ",\n ...\n })" - - def __str__(self): - cls = type("rc", (dict,), {}) # temporary class with short name - src = cls({key: val for key, val in rc_ultraplot.items() if "." not in key}) - return type(rc_matplotlib).__str__(src) + "\n..." - - def __iter__(self): - yield from self._rc_ultraplot # sorted ultraplot settings, ignoring deprecations - yield from self._rc_matplotlib # sorted matplotlib settings, ignoring deprecations - - def __len__(self): - return len(tuple(iter(self))) - - def __delitem__(self, key): # noqa: U100 - raise RuntimeError("rc settings cannot be deleted.") - - def __delattr__(self, attr): # noqa: U100 - raise RuntimeError("rc settings cannot be deleted.") - @docstring._snippet_manager def __init__(self, local=True, user=True, default=True, **kwargs): """ @@ -765,41 +744,102 @@ def __init__(self, local=True, user=True, default=True, **kwargs): """ import threading -<<<<<<< HEAD - self._local_props = threading.local() -======= - self._context = [] ->>>>>>> 500e45b1 (Add xdist to image compare (#266)) - self._lock = threading.RLock() + self._thread_local = threading.local() self._init(local=local, user=user, default=default, **kwargs) + def _init(self, *, local, user, default, skip_cycle=False): + """ + Initialize the configurator. + Note: this is also used to reset the class. + """ + # Always remove context objects + self._context.clear() + + # Update from default settings + # NOTE: see _remove_blacklisted_style_params bugfix + if default: + self.rc_matplotlib.update(_get_style_dict("original", filter=False)) + self.rc_matplotlib.update(rcsetup._rc_matplotlib_default) + self.rc_ultraplot.update(rcsetup._rc_ultraplot_default) + for key, value in self.rc_ultraplot.items(): + kw_ultraplot, kw_matplotlib = self._get_item_dicts( + key, value, skip_cycle=skip_cycle + ) + self.rc_matplotlib.update(kw_matplotlib) + self.rc_ultraplot.update(kw_ultraplot) + self.rc_matplotlib["backend"] = mpl.get_backend() + + # 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) + @property def _context(self): - """Get current thread's context list.""" - if not hasattr(self._local_props, "context"): - self._local_props.context = [] - return self._local_props.context + if not hasattr(self._thread_local, "_context"): + # Initialize context as an empty list + self._thread_local._context = [] + return self._thread_local._context @_context.setter def _context(self, value): - """Set current thread's context list.""" - self._local_props.context = value + if isinstance(value, list): + self._thread_local._context = value + + def _get_thread_local_copy(self, attr, source): + if not hasattr(self._thread_local, attr): + # Initialize with a copy of the source dictionary + setattr(self._thread_local, attr, source) + return getattr(self._thread_local, attr) @property - def _rc_ultraplot(self): - """Get current thread's ultraplot config dict.""" - if not hasattr(self._local_props, "rc_ultraplot"): - # Use dict() to avoid validation during thread-local setup - self._local_props.rc_ultraplot = dict(rc_ultraplot) - return self._local_props.rc_ultraplot + def rc_matplotlib(self): + return self._get_thread_local_copy("rc_matplotlib", mpl.rcParams) @property - def _rc_matplotlib(self): - """Get current thread's matplotlib config dict.""" - if not hasattr(self._local_props, "rc_matplotlib"): - # Use dict() to avoid validation during thread-local setup - self._local_props.rc_matplotlib = dict(rc_matplotlib) - return self._local_props.rc_matplotlib + def rc_ultraplot(self): + return self._get_thread_local_copy( + "rc_ultraplot", rcsetup._rc_ultraplot_default + ) + + def __repr__(self): + cls = type("rc", (dict,), {}) # temporary class with short name + src = cls( + {key: val for key, val in self.rc_ultraplot.items() if "." not in key} + ) + return ( + type(self.rc_matplotlib).__repr__(src).strip()[:-1] + ",\n ...\n })" + ) + + def __str__(self): + cls = type("rc", (dict,), {}) # temporary class with short name + src = cls( + {key: val for key, val in self.rc_ultraplot.items() if "." not in key} + ) + return type(self.rc_matplotlib).__str__(src) + "\n..." + + def __iter__(self): + yield from self.rc_ultraplot # sorted ultraplot settings, ignoring deprecations + yield from self.rc_matplotlib # sorted matplotlib settings, ignoring deprecations + + def __len__(self): + return len(tuple(iter(self))) + + def __delitem__(self, key): # noqa: U100 + raise RuntimeError("rc settings cannot be deleted.") + + def __delattr__(self, attr): # noqa: U100 + raise RuntimeError("rc settings cannot be deleted.") def __getitem__(self, key): """ @@ -808,25 +848,19 @@ def __getitem__(self, key): """ key, _ = self._validate_key(key) # might issue ultraplot removed/renamed error try: - return self._rc_ultraplot[key] + return self.rc_ultraplot[key] except KeyError: pass - return self._rc_matplotlib[key] # might issue matplotlib removed/renamed error + return self.rc_matplotlib[key] # might issue matplotlib removed/renamed error def __setitem__(self, key, value): """ 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) -<<<<<<< HEAD - self._rc_ultraplot.update(kw_ultraplot) - self._rc_matplotlib.update(kw_matplotlib) -======= - rc_ultraplot.update(kw_ultraplot) - rc_matplotlib.update(kw_matplotlib) ->>>>>>> 500e45b1 (Add xdist to image compare (#266)) + kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) + self.rc_ultraplot.update(kw_ultraplot) + self.rc_matplotlib.update(kw_matplotlib) def __getattr__(self, attr): """ @@ -852,134 +886,45 @@ def __enter__(self): """ 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( -<<<<<<< HEAD - (self._rc_ultraplot, self._rc_matplotlib), -======= - (rc_ultraplot, rc_matplotlib), ->>>>>>> 500e45b1 (Add xdist to image compare (#266)) - (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( + "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( + (self.rc_ultraplot, self.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) -<<<<<<< HEAD - self._rc_ultraplot.update(kw_ultraplot) - self._rc_matplotlib.update(kw_matplotlib) -======= - rc_ultraplot.update(kw_ultraplot) - rc_matplotlib.update(kw_matplotlib) ->>>>>>> 500e45b1 (Add xdist to image compare (#266)) - 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() - -<<<<<<< HEAD - # Update from default settings - # NOTE: see _remove_blacklisted_style_params bugfix - if default: - # Clear thread-local dicts to force fresh initialization - if hasattr(self._local_props, "rc_ultraplot"): - delattr(self._local_props, "rc_ultraplot") - if hasattr(self._local_props, "rc_matplotlib"): - delattr(self._local_props, "rc_matplotlib") - - # Apply defaults with proper processing order - self._rc_matplotlib.update(_get_style_dict("original", filter=False)) - self._rc_matplotlib.update(rcsetup._rc_matplotlib_default) - self._rc_ultraplot.update(rcsetup._rc_ultraplot_default) - - # Apply ultraplot settings to matplotlib in correct order - # Process 'gridminor' before 'grid' to avoid conflicts - ultraplot_items = list(self._rc_ultraplot.items()) - grid_items = [] - other_items = [] - - for key, value in ultraplot_items: - if key in ("grid", "gridminor"): - grid_items.append((key, value)) - else: - other_items.append((key, value)) - - # Sort grid items so gridminor comes before grid - grid_items.sort(key=lambda x: 0 if x[0] == "gridminor" else 1) - - # Process all items in the correct order - for key, value in other_items + grid_items: - kw_ultraplot, kw_matplotlib = self._get_item_dicts( - key, value, skip_cycle=skip_cycle - ) - self._rc_matplotlib.update(kw_matplotlib) - self._rc_ultraplot.update(kw_ultraplot) -======= - # 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) ->>>>>>> 500e45b1 (Add xdist to image compare (#266)) - - # 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) + 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) + self.rc_ultraplot.update(kw_ultraplot) + self.rc_matplotlib.update(kw_matplotlib) + del self._context[-1] - @staticmethod - def _validate_key(key, value=None): + def _validate_key(self, key, value=None): """ Validate setting names and handle `rc_ultraplot` deprecations. """ @@ -991,13 +936,12 @@ def _validate_key(key, value=None): key = key.lower() if "." not in key: key = rcsetup._rc_nodots.get(key, key) - key, value = rc_ultraplot._check_key( + key, value = self.rc_ultraplot._check_key( key, value ) # may issue deprecation warning return key, value - @staticmethod - def _validate_value(key, value): + def _validate_value(self, key, value): """ Validate setting values and convert numpy ndarray to list if possible. """ @@ -1009,11 +953,11 @@ def _validate_value(key, value): # are being read rather than after the end of the file reading. if isinstance(value, np.ndarray): value = value.item() if value.size == 1 else value.tolist() - validate_matplotlib = getattr(rc_matplotlib, "validate", None) - validate_ultraplot = rc_ultraplot._validate + validate_matplotlib = getattr(self.rc_matplotlib, "validate", None) + validate_ultraplot = getattr(self.rc_ultraplot, "_validate", None) if validate_matplotlib is not None and key in validate_matplotlib: value = validate_matplotlib[key](value) - elif key in validate_ultraplot: + elif validate_ultraplot is not None and key in validate_ultraplot: value = validate_ultraplot[key](value) return value @@ -1022,34 +966,27 @@ def _get_item_context(self, key, mode=None): 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: -<<<<<<< HEAD - rcdicts = (*cache, self._rc_ultraplot, self._rc_matplotlib) - elif mode == 1: - rcdicts = (*cache, self._rc_ultraplot) # added settings only! -======= - rcdicts = (*cache, rc_ultraplot, rc_matplotlib) - elif mode == 1: - rcdicts = (*cache, rc_ultraplot) # added settings only! ->>>>>>> 500e45b1 (Add xdist to image compare (#266)) - 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, self.rc_ultraplot, self.rc_matplotlib) + elif mode == 1: + rcdicts = (*cache, self.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}.") def _get_item_dicts(self, key, value, skip_cycle=False): """ @@ -1057,7 +994,6 @@ def _get_item_dicts(self, key, value, skip_cycle=False): properties associated with this key. Used when setting items, entering context blocks, or loading files. """ - # Get validated key, value, and child keys key, value = self._validate_key(key, value) value = self._validate_value(key, value) @@ -1073,16 +1009,16 @@ def _get_item_dicts(self, key, value, skip_cycle=False): warnings.simplefilter("ignore", mpl.MatplotlibDeprecationWarning) warnings.simplefilter("ignore", warnings.UltraPlotWarning) for key in keys: - if key in rc_matplotlib: + if key in self.rc_matplotlib: kw_matplotlib[key] = value - elif key in rc_ultraplot: + elif key in self.rc_ultraplot: kw_ultraplot[key] = value else: raise KeyError(f"Invalid rc setting {key!r}.") # Special key: configure inline backend if contains("inlineformat"): - config_inline_backend(value) + config_inline_backend(value, self.rc_ultraplot) # Special key: apply stylesheet elif contains("style"): @@ -1113,14 +1049,14 @@ def _get_item_dicts(self, key, value, skip_cycle=False): kw_ultraplot.update( { key: value - for key, value in rc_ultraplot.items() + for key, value in self.rc_ultraplot.items() if key in rcsetup.FONT_KEYS and value in mfonts.font_scalings } ) kw_matplotlib.update( { key: value - for key, value in rc_matplotlib.items() + for key, value in self.rc_matplotlib.items() if key in rcsetup.FONT_KEYS and value in mfonts.font_scalings } ) @@ -1129,9 +1065,9 @@ def _get_item_dicts(self, key, value, skip_cycle=False): elif contains("tick.len", "tick.lenratio"): if contains("tick.len"): ticklen = value - ratio = self._rc_ultraplot["tick.lenratio"] + ratio = self.rc_ultraplot["tick.lenratio"] else: - ticklen = self._rc_ultraplot["tick.len"] + ticklen = self.rc_ultraplot["tick.len"] ratio = value kw_matplotlib["xtick.minor.size"] = ticklen * ratio kw_matplotlib["ytick.minor.size"] = ticklen * ratio @@ -1140,9 +1076,9 @@ def _get_item_dicts(self, key, value, skip_cycle=False): elif contains("tick.width", "tick.widthratio"): if contains("tick.width"): tickwidth = value - ratio = self._rc_ultraplot["tick.widthratio"] + ratio = self.rc_ultraplot["tick.widthratio"] else: - tickwidth = self._rc_ultraplot["tick.width"] + tickwidth = self.rc_ultraplot["tick.width"] ratio = value kw_matplotlib["xtick.minor.width"] = tickwidth * ratio kw_matplotlib["ytick.minor.width"] = tickwidth * ratio @@ -1151,9 +1087,9 @@ def _get_item_dicts(self, key, value, skip_cycle=False): elif contains("grid.width", "grid.widthratio"): if contains("grid.width"): gridwidth = value - ratio = self._rc_ultraplot["grid.widthratio"] + ratio = self.rc_ultraplot["grid.widthratio"] else: - gridwidth = self._rc_ultraplot["grid.width"] + gridwidth = self.rc_ultraplot["grid.width"] ratio = value kw_ultraplot["gridminor.linewidth"] = gridwidth * ratio kw_ultraplot["gridminor.width"] = gridwidth * ratio @@ -1552,26 +1488,25 @@ def context(self, *args, mode=0, file=None, **kwargs): >>> 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}.") + 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 def category(self, cat, *, trimcat=True, context=False): """ @@ -1677,30 +1612,25 @@ def update(self, *args, **kwargs): 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 = "", {} + 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) @docstring._snippet_manager def reset(self, local=True, user=True, default=True, **kwargs): @@ -1878,8 +1808,7 @@ def _context_mode(self): """ 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): @@ -1919,18 +1848,18 @@ def changed(self): _init_user_folders() _init_user_file() -#: A dictionary-like container of matplotlib settings. Assignments are -#: validated and restricted to recognized setting names. -rc_matplotlib = mpl.rcParams # PEP8 4 lyfe - -#: A dictionary-like container of ultraplot settings. Assignments are -#: validated and restricted to recognized setting names. -rc_ultraplot = rcsetup._rc_ultraplot_default.copy() # a validated rcParams-style dict #: Instance of `Configurator`. This controls both `rc_matplotlib` and `rc_ultraplot` #: settings. See the :ref:`configuration guide ` for details. rc = Configurator(skip_cycle=True) +#: A dictionary-like container of ultraplot settings. Assignments are +#: validated and restricted to recognized setting names. +rc_ultraplot = rc.rc_ultraplot +#: A dictionary-like container of matplotlib settings. Assignments are +#: validated and restricted to recognized setting names. +rc_matplotlib = rc.rc_matplotlib + # Deprecated RcConfigurator = warnings._rename_objs( "0.8.0", diff --git a/ultraplot/internals/__init__.py b/ultraplot/internals/__init__.py index 7a7ea938..c4b1d932 100644 --- a/ultraplot/internals/__init__.py +++ b/ultraplot/internals/__init__.py @@ -7,6 +7,7 @@ from numbers import Integral, Real import numpy as np + from matplotlib import rcParams as rc_matplotlib try: # print debugging (used with internal modules) diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index e719d1f9..3957a2c7 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -529,10 +529,24 @@ class _RcParams(MutableMapping, dict): # NOTE: By omitting __delitem__ in MutableMapping we effectively # disable mutability. Also disables deleting items with pop(). def __init__(self, source, validate): + import threading + + self._thread_props = threading.local() # for thread-local properties + self._validate = validate for key, value in source.items(): self.__setitem__(key, value) # trigger validation + @property + def _validate(self): + if not hasattr(self._thread_props, "_validate"): + self._thread_props._validate = _rc_ultraplot_validate + return self._thread_props._validate + + @_validate.setter + def _validate(self, value): + self._thread_props._validate = value + def __repr__(self): return RcParams.__repr__(self) @@ -588,8 +602,14 @@ def _check_key(key, value=None): return key, value def copy(self): - source = {key: dict.__getitem__(self, key) for key in self} - return _RcParams(source, self._validate) + # Access validation dict from thread-local if available, else fallback + validate = getattr(self._thread_props, "_validate", None) + if validate is None: + # fallback: guess it from another thread (e.g., first one that set it) + validate = dict() + + source = dict(self) + return _RcParams(source, validate) # Borrow validators from matplotlib and construct some new ones diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 2d341ba7..a23df504 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -64,58 +64,13 @@ def test_something(rng): def reset_rc_and_close_figures(): """Reset rc to full ultraplot defaults and close figures for each test.""" # Force complete ultraplot initialization for this thread - _ensure_ultraplot_defaults() + uplt.rc.reset() yield # Clean up after test - only close figures, don't reset rc uplt.close("all") - # Reset to clean state for next test - _ensure_ultraplot_defaults() - - -def _ensure_ultraplot_defaults(): - """Ensure current thread has complete ultraplot configuration.""" - from ultraplot.internals import rcsetup - from ultraplot.config import _get_style_dict - - # Clear thread-local storage to force reinitialization - if hasattr(uplt.rc, "_local_props"): - if hasattr(uplt.rc._local_props, "rc_ultraplot"): - delattr(uplt.rc._local_props, "rc_ultraplot") - if hasattr(uplt.rc._local_props, "rc_matplotlib"): - delattr(uplt.rc._local_props, "rc_matplotlib") - - # Force thread-local dicts to exist - _ = uplt.rc._rc_ultraplot - _ = uplt.rc._rc_matplotlib - - # Apply complete ultraplot initialization sequence - uplt.rc._rc_matplotlib.update(_get_style_dict("original", filter=False)) - uplt.rc._rc_matplotlib.update(rcsetup._rc_matplotlib_default) - uplt.rc._rc_ultraplot.update(rcsetup._rc_ultraplot_default) - - # Apply ultraplot->matplotlib translations in correct order - ultraplot_items = list(uplt.rc._rc_ultraplot.items()) - grid_items = [(k, v) for k, v in ultraplot_items if k in ("grid", "gridminor")] - other_items = [(k, v) for k, v in ultraplot_items if k not in ("grid", "gridminor")] - - # Process gridminor before grid to avoid conflicts - grid_items.sort(key=lambda x: 0 if x[0] == "gridminor" else 1) - - # Apply all ultraplot settings to matplotlib - for key, value in other_items + grid_items: - try: - kw_ultraplot, kw_matplotlib = uplt.rc._get_item_dicts( - key, value, skip_cycle=True - ) - uplt.rc._rc_matplotlib.update(kw_matplotlib) - uplt.rc._rc_ultraplot.update(kw_ultraplot) - except: - # Skip any problematic settings during test setup - continue - def pytest_addoption(parser): """Add command line options for enhanced matplotlib testing.""" From 6804b22a25343454112f47f9862f30137984dd93 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 10:05:42 +0200 Subject: [PATCH 13/47] actually add the unittest --- ultraplot/tests/test_thread_safety.py | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 ultraplot/tests/test_thread_safety.py diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py new file mode 100644 index 00000000..15cd2cc5 --- /dev/null +++ b/ultraplot/tests/test_thread_safety.py @@ -0,0 +1,28 @@ +import ultraplot as uplt, threading + + +def modify_rc(): + """ + Apply arbitrary rc parameters in a thread-safe manner. + """ + id = threading.get_ident() # set it to thread id + with uplt.rc.context(fontsize=id): + assert uplt.rc["font.size"] == id, f"Thread {id} failed to set rc params" + + +def test_setting_props_on_threads(): + """ + Test the thread safety of a context setting + """ + # We spawn workers and these workers should try to set + # an arbitrarry rc parameter. This should + # be local to that context and not affect the main thread. + fontsize = uplt.rc["font.size"] + workers = [] + for worker in range(10): + w = threading.Thread(target=modify_rc) + workers.append(w) + w.start() + for w in workers: + w.join() + assert uplt.rc["font.size"] == fontsize From 27178f5aceb16263af9cccb990ddc352997e9b18 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 11:01:42 +0200 Subject: [PATCH 14/47] add thread exception raising --- ultraplot/tests/test_thread_safety.py | 79 ++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index 15cd2cc5..35aad856 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -1,28 +1,79 @@ -import ultraplot as uplt, threading +import ultraplot as uplt, threading, pytest, warnings -def modify_rc(): +def modify_rc_in_context(prop: str, value=None): """ Apply arbitrary rc parameters in a thread-safe manner. """ + with uplt.rc.context(fontsize=value): + assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" + + +def modify_rc_on_thread(prop: str, value=None): id = threading.get_ident() # set it to thread id - with uplt.rc.context(fontsize=id): - assert uplt.rc["font.size"] == id, f"Thread {id} failed to set rc params" + assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params {prop}={value}" + + +def _spawn_and_run_threads(func, n=10, **kwargs): + options = kwargs.pop("options") + workers = [] + exceptions = [] + + def wrapped_func(**kw): + try: + func(**kw) + except Exception as e: + exceptions.append(e) + + for worker in range(n): + kw = kwargs.copy() + kw["value"] = options[worker % len(options)] + w = threading.Thread(target=wrapped_func, kwargs=kw) + workers.append(w) + w.start() + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") # catch all warnings + for w in workers: + w.join() + + if exceptions: + raise RuntimeError(f"Thread raised exception: {exceptions[0]}") from exceptions[ + 0 + ] + + if record: + raise RuntimeError("Thread raised a warning") -def test_setting_props_on_threads(): +def test_setting_within_context(): """ Test the thread safety of a context setting """ # We spawn workers and these workers should try to set # an arbitrarry rc parameter. This should # be local to that context and not affect the main thread. - fontsize = uplt.rc["font.size"] - workers = [] - for worker in range(10): - w = threading.Thread(target=modify_rc) - workers.append(w) - w.start() - for w in workers: - w.join() - assert uplt.rc["font.size"] == fontsize + prop, value = "font.size", uplt.rc["font.size"] + options = list(range(10)) + _spawn_and_run_threads(modify_rc_in_context, prop=prop, options=options) + assert uplt.rc[prop] == value + + +def test_setting_without_context(): + """ + Test the thread safety of a context setting + """ + # We spawn workers and these workers should try to set + # an arbitrarry rc parameter. This should + # be local to that context and not affect the main thread. + # + # Test an ultraplot parameter + prop = "abc" + value = uplt.rc[prop] + options = "a b c d e f g".split() + _spawn_and_run_threads(modify_rc_on_thread, prop=prop, options=options) + assert uplt.rc[prop] == value + + prop, value = "font.size", uplt.rc["font.size"] + options = list(range(10)) + _spawn_and_run_threads(modify_rc_on_thread, prop=prop, options=options) From 813604d678f9e822d4d19be7d23c3053308c29c3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 11:07:25 +0200 Subject: [PATCH 15/47] correct test values --- ultraplot/tests/test_thread_safety.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index 35aad856..6f0bfef9 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -11,6 +11,7 @@ def modify_rc_in_context(prop: str, value=None): def modify_rc_on_thread(prop: str, value=None): id = threading.get_ident() # set it to thread id + uplt.rc[prop] = value assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params {prop}={value}" @@ -70,7 +71,7 @@ def test_setting_without_context(): # Test an ultraplot parameter prop = "abc" value = uplt.rc[prop] - options = "a b c d e f g".split() + options = "A. a. aa".split() _spawn_and_run_threads(modify_rc_on_thread, prop=prop, options=options) assert uplt.rc[prop] == value From 05d4f549f9c5e2822bcc2702bf5e64044a7029cf Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 11:11:44 +0200 Subject: [PATCH 16/47] refactor unittest --- ultraplot/tests/test_thread_safety.py | 29 +++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index 6f0bfef9..7139e9ec 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -1,21 +1,18 @@ import ultraplot as uplt, threading, pytest, warnings -def modify_rc_in_context(prop: str, value=None): +def modify_rc_on_thread(prop: str, value=None, with_context=True): """ Apply arbitrary rc parameters in a thread-safe manner. """ - with uplt.rc.context(fontsize=value): + if with_context: + with uplt.rc.context(fontsize=value): + assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" + else: assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" -def modify_rc_on_thread(prop: str, value=None): - id = threading.get_ident() # set it to thread id - uplt.rc[prop] = value - assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params {prop}={value}" - - -def _spawn_and_run_threads(func, n=10, **kwargs): +def _spawn_and_run_threads(func, n=30, **kwargs): options = kwargs.pop("options") workers = [] exceptions = [] @@ -56,7 +53,9 @@ def test_setting_within_context(): # be local to that context and not affect the main thread. prop, value = "font.size", uplt.rc["font.size"] options = list(range(10)) - _spawn_and_run_threads(modify_rc_in_context, prop=prop, options=options) + _spawn_and_run_threads( + modify_rc_on_thread, prop=prop, options=options, with_context=True + ) assert uplt.rc[prop] == value @@ -71,10 +70,14 @@ def test_setting_without_context(): # Test an ultraplot parameter prop = "abc" value = uplt.rc[prop] - options = "A. a. aa".split() - _spawn_and_run_threads(modify_rc_on_thread, prop=prop, options=options) + options = "A. a. aa aaa aaaa.".split() + _spawn_and_run_threads( + modify_rc_on_thread, prop=prop, options=options, with_context=False + ) assert uplt.rc[prop] == value prop, value = "font.size", uplt.rc["font.size"] options = list(range(10)) - _spawn_and_run_threads(modify_rc_on_thread, prop=prop, options=options) + _spawn_and_run_threads( + modify_rc_on_thread, prop=prop, options=options, with_context=False + ) From 427367ba05b0e2a4cbf50b136b03c75c52c624ce Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 11:19:07 +0200 Subject: [PATCH 17/47] more refactoring --- ultraplot/tests/test_thread_safety.py | 43 ++++++++------------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index 7139e9ec..ebefed7e 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -44,40 +44,23 @@ def wrapped_func(**kw): raise RuntimeError("Thread raised a warning") -def test_setting_within_context(): +@pytest.mark.parametrize("with_context", [True, False]) +@pytest.mark.parametrize( + "prop, options", + [ + ("font.size", list(range(10))), + ("abc", "A. a. aa aaa aaaa.".split()), + ], +) +def test_setting_without_context(prop, options, with_context): """ Test the thread safety of a context setting """ - # We spawn workers and these workers should try to set - # an arbitrarry rc parameter. This should - # be local to that context and not affect the main thread. - prop, value = "font.size", uplt.rc["font.size"] - options = list(range(10)) - _spawn_and_run_threads( - modify_rc_on_thread, prop=prop, options=options, with_context=True - ) - assert uplt.rc[prop] == value - - -def test_setting_without_context(): - """ - Test the thread safety of a context setting - """ - # We spawn workers and these workers should try to set - # an arbitrarry rc parameter. This should - # be local to that context and not affect the main thread. - # - # Test an ultraplot parameter - prop = "abc" value = uplt.rc[prop] - options = "A. a. aa aaa aaaa.".split() _spawn_and_run_threads( - modify_rc_on_thread, prop=prop, options=options, with_context=False + modify_rc_on_thread, + prop=prop, + options=options, + with_context=with_context, ) assert uplt.rc[prop] == value - - prop, value = "font.size", uplt.rc["font.size"] - options = list(range(10)) - _spawn_and_run_threads( - modify_rc_on_thread, prop=prop, options=options, with_context=False - ) From 5129f15e940069fb8d00220be09bd48e9981ab23 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 14:22:42 +0200 Subject: [PATCH 18/47] save state --- .github/workflows/build-ultraplot.yml | 6 +- environment.yml | 35 +++-- ultraplot/config.py | 72 ++++++++++ ultraplot/internals/context.py | 9 +- ultraplot/internals/rcsetup.py | 5 +- ultraplot/tests/conftest.py | 184 ++++++++------------------ ultraplot/tests/test_1dplots.py | 7 +- ultraplot/tests/test_thread_safety.py | 15 ++- 8 files changed, 163 insertions(+), 170 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 736ecc48..f558cd6c 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -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 diff --git a/environment.yml b/environment.yml index 16dda8d4..dc35dc6b 100644 --- a/environment.yml +++ b/environment.yml @@ -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 diff --git a/ultraplot/config.py b/ultraplot/config.py index aca6a2b4..5704ce23 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -841,6 +841,19 @@ def __delitem__(self, key): # noqa: U100 def __delattr__(self, attr): # noqa: U100 raise RuntimeError("rc settings cannot be deleted.") +<<<<<<< HEAD +======= + @docstring._snippet_manager + def __init__(self, local=True, user=True, default=True, **kwargs): + """ + Parameters + ---------- + %(rc.params)s + """ + self._context = [] + self._init(local=local, user=user, default=default, **kwargs) + +>>>>>>> main def __getitem__(self, key): """ Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation @@ -859,8 +872,13 @@ def __setitem__(self, key, value): (e.g., ``uplt.rc[name] = value``). """ kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) +<<<<<<< HEAD self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) +======= + rc_ultraplot.update(kw_ultraplot) + rc_matplotlib.update(kw_matplotlib) +>>>>>>> main def __getattr__(self, attr): """ @@ -902,7 +920,11 @@ def __enter__(self): raise e for rc_dict, kw_new in zip( +<<<<<<< HEAD (self.rc_ultraplot, self.rc_matplotlib), +======= + (rc_ultraplot, rc_matplotlib), +>>>>>>> main (kw_ultraplot, kw_matplotlib), ): for key, value in kw_new.items(): @@ -920,11 +942,55 @@ def __exit__(self, *args): # noqa: U100 context = self._context[-1] for key, value in context.rc_old.items(): kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) +<<<<<<< HEAD self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) del self._context[-1] def _validate_key(self, key, value=None): +======= + 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. + """ + # 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) + + @staticmethod + def _validate_key(key, value=None): +>>>>>>> main """ Validate setting names and handle `rc_ultraplot` deprecations. """ @@ -971,9 +1037,15 @@ def _get_item_context(self, key, mode=None): mode = self._context_mode cache = tuple(context.rc_new for context in self._context) if mode == 0: +<<<<<<< HEAD rcdicts = (*cache, self.rc_ultraplot, self.rc_matplotlib) elif mode == 1: rcdicts = (*cache, self.rc_ultraplot) # added settings only! +======= + rcdicts = (*cache, rc_ultraplot, rc_matplotlib) + elif mode == 1: + rcdicts = (*cache, rc_ultraplot) # added settings only! +>>>>>>> main elif mode == 2: rcdicts = (*cache,) else: diff --git a/ultraplot/internals/context.py b/ultraplot/internals/context.py index cc93f0d4..f429e689 100644 --- a/ultraplot/internals/context.py +++ b/ultraplot/internals/context.py @@ -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() diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 3957a2c7..6c7c9a96 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -541,7 +541,7 @@ def __init__(self, source, validate): def _validate(self): if not hasattr(self._thread_props, "_validate"): self._thread_props._validate = _rc_ultraplot_validate - return self._thread_props._validate + return self._thread_props._validate.copy() @_validate.setter def _validate(self, value): @@ -607,9 +607,8 @@ def copy(self): if validate is None: # fallback: guess it from another thread (e.g., first one that set it) validate = dict() - source = dict(self) - return _RcParams(source, validate) + return _RcParams(source.copy(), validate.copy()) # Borrow validators from matplotlib and construct some new ones diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index a23df504..e2c33db3 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD """ Conftest.py for UltraPlot testing with modular MPL plugin architecture. @@ -20,23 +21,11 @@ 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 +>>>>>>> main 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 @@ -44,18 +33,9 @@ @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 """ +<<<<<<< HEAD # Each test gets the same seed for reproducibility return np.random.default_rng(seed=SEED) @@ -66,40 +46,66 @@ def reset_rc_and_close_figures(): # Force complete ultraplot initialization for this thread uplt.rc.reset() +======= + return np.random.default_rng(SEED) + + +@pytest.fixture(autouse=True) +def close_figures_after_test(): +>>>>>>> main yield # Clean up after test - only close figures, don't reset rc 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 - 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")) + + print(f"Store Failed MPL Plugin initialized") + print(f"Result dir: {self.result_dir}") - 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) + 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) - # Skip tests that don't have baseline images + def _remove_success(self, report: pytest.TestReport): + """Remove successful test images.""" + + pattern = r"(?P::|/)|\[|\]|\.py" + name = re.sub( + pattern, + lambda m: "." if m.group("sep") else "_" if m.group(0) == "[" else "", + report.nodeid, + ) + target = (self.result_dir / name).absolute() + if target.is_dir(): + shutil.rmtree(target) + + @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) + + +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): @@ -111,90 +117,10 @@ def pytest_collection_modifyitems(config, items): ) -@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)) 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}") diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index 77c40a13..9fd207a3 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -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 @@ -574,14 +574,15 @@ def test_networks(rng): node_color = uplt.colormaps.get_cmap(cmap)(np.linspace(0, 1, len(g))) 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)) + if layout in ("random", "arf"): + layout_kw = dict(seed=SEED) inax.graph( g, layout=layout, edge_kw=dict(alpha=alpha), node_kw=dict(node_color=node_color), + layout_kw=layout_kw, ) xspine, yspine = spines inax[0]._toggle_spines(spines) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index ebefed7e..1202adb7 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -6,9 +6,10 @@ def modify_rc_on_thread(prop: str, value=None, with_context=True): Apply arbitrary rc parameters in a thread-safe manner. """ if with_context: - with uplt.rc.context(fontsize=value): + with uplt.rc.context(**{prop: value}): assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" else: + uplt.rc[prop] = value assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" @@ -36,9 +37,9 @@ def wrapped_func(**kw): w.join() if exceptions: - raise RuntimeError(f"Thread raised exception: {exceptions[0]}") from exceptions[ - 0 - ] + raise RuntimeError( + f"Thread raised exception: {exceptions[0]} with {kwargs=}" + ) from exceptions[0] if record: raise RuntimeError("Thread raised a warning") @@ -52,7 +53,7 @@ def wrapped_func(**kw): ("abc", "A. a. aa aaa aaaa.".split()), ], ) -def test_setting_without_context(prop, options, with_context): +def test_setting_rc(prop, options, with_context): """ Test the thread safety of a context setting """ @@ -63,4 +64,6 @@ def test_setting_without_context(prop, options, with_context): options=options, with_context=with_context, ) - assert uplt.rc[prop] == value + assert ( + uplt.rc[prop] == value + ), f"Failed to reset {value=} after threads finished, {uplt.rc[prop]=}." From f124b91519d50f665e2024a5273a7f89201d8fc3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 14:23:58 +0200 Subject: [PATCH 19/47] save state --- ultraplot/config.py | 72 ------------------------------------- ultraplot/tests/conftest.py | 11 ------ 2 files changed, 83 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 5704ce23..aca6a2b4 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -841,19 +841,6 @@ def __delitem__(self, key): # noqa: U100 def __delattr__(self, attr): # noqa: U100 raise RuntimeError("rc settings cannot be deleted.") -<<<<<<< HEAD -======= - @docstring._snippet_manager - def __init__(self, local=True, user=True, default=True, **kwargs): - """ - Parameters - ---------- - %(rc.params)s - """ - self._context = [] - self._init(local=local, user=user, default=default, **kwargs) - ->>>>>>> main def __getitem__(self, key): """ Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation @@ -872,13 +859,8 @@ def __setitem__(self, key, value): (e.g., ``uplt.rc[name] = value``). """ kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) -<<<<<<< HEAD self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) -======= - rc_ultraplot.update(kw_ultraplot) - rc_matplotlib.update(kw_matplotlib) ->>>>>>> main def __getattr__(self, attr): """ @@ -920,11 +902,7 @@ def __enter__(self): raise e for rc_dict, kw_new in zip( -<<<<<<< HEAD (self.rc_ultraplot, self.rc_matplotlib), -======= - (rc_ultraplot, rc_matplotlib), ->>>>>>> main (kw_ultraplot, kw_matplotlib), ): for key, value in kw_new.items(): @@ -942,55 +920,11 @@ def __exit__(self, *args): # noqa: U100 context = self._context[-1] for key, value in context.rc_old.items(): kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) -<<<<<<< HEAD self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) del self._context[-1] def _validate_key(self, key, value=None): -======= - 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. - """ - # 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) - - @staticmethod - def _validate_key(key, value=None): ->>>>>>> main """ Validate setting names and handle `rc_ultraplot` deprecations. """ @@ -1037,15 +971,9 @@ def _get_item_context(self, key, mode=None): mode = self._context_mode cache = tuple(context.rc_new for context in self._context) if mode == 0: -<<<<<<< HEAD rcdicts = (*cache, self.rc_ultraplot, self.rc_matplotlib) elif mode == 1: rcdicts = (*cache, self.rc_ultraplot) # added settings only! -======= - rcdicts = (*cache, rc_ultraplot, rc_matplotlib) - elif mode == 1: - rcdicts = (*cache, rc_ultraplot) # added settings only! ->>>>>>> main elif mode == 2: rcdicts = (*cache,) else: diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index e2c33db3..cb4a6e48 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -1,4 +1,3 @@ -<<<<<<< HEAD """ Conftest.py for UltraPlot testing with modular MPL plugin architecture. @@ -21,9 +20,7 @@ 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 ->>>>>>> main from pathlib import Path import warnings, logging @@ -35,7 +32,6 @@ def rng(): """ Ensure all tests start with the same rng """ -<<<<<<< HEAD # Each test gets the same seed for reproducibility return np.random.default_rng(seed=SEED) @@ -46,13 +42,6 @@ def reset_rc_and_close_figures(): # Force complete ultraplot initialization for this thread uplt.rc.reset() -======= - return np.random.default_rng(SEED) - - -@pytest.fixture(autouse=True) -def close_figures_after_test(): ->>>>>>> main yield # Clean up after test - only close figures, don't reset rc From a1b0e6b4dceb971a1bc1a39857805c2e06f65f88 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 14:54:46 +0200 Subject: [PATCH 20/47] restore conftest.py --- ultraplot/tests/conftest.py | 179 ++++++++++++++++++++++++++---------- 1 file changed, 128 insertions(+), 51 deletions(-) diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 026df182..d5c87235 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -9,21 +9,33 @@ - Each thread gets independent, deterministic RNG instances - Compatible with pytest-xdist parallel execution - Clean separation of concerns - tests explicitly declare RNG dependencies - -Matplotlib rcParams Safety: -- Automatic rcParams isolation for all tests prevents interference -- Tests that modify matplotlib settings are automatically isolated -- Dedicated rcparams_isolation fixture for explicit isolation needs -- Thread-safe for parallel execution with pytest-xdist """ import threading, os, shutil, pytest, re import numpy as np, ultraplot as uplt import warnings, logging +<<<<<<< HEAD import os, shutil, pytest, re, numpy as np, ultraplot as uplt +======= +>>>>>>> e8823ad9 (restore conftest.py) from pathlib import Path -import warnings, logging +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 SEED = 51423 @@ -31,13 +43,24 @@ @pytest.fixture def rng(): """ - Ensure all tests start with the same 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) """ # Each test gets the same seed for reproducibility return np.random.default_rng(seed=SEED) @pytest.fixture(autouse=True) +<<<<<<< HEAD def reset_rc_and_close_figures(): """Reset rc to full ultraplot defaults and close figures for each test.""" # Force complete ultraplot initialization for this thread @@ -48,59 +71,42 @@ def reset_rc_and_close_figures(): @pytest.fixture(autouse=True) def close_figures_after_test(): +======= +def close_figures_after_test(): + """Automatically close all figures after each test.""" +>>>>>>> e8823ad9 (restore conftest.py) yield - - # Clean up after test - only close figures, don't reset rc 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", + help="Store only failed matplotlib comparison images (enables artifact optimization)", ) -class StoreFailedMplPlugin: - def __init__(self, config): - self.config = config - - # 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")) - - print(f"Store Failed MPL Plugin initialized") - print(f"Result dir: {self.result_dir}") - - 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) - - def _remove_success(self, report: pytest.TestReport): - """Remove successful test images.""" - - pattern = r"(?P::|/)|\[|\]|\.py" - name = re.sub( - pattern, - lambda m: "." if m.group("sep") else "_" if m.group(0) == "[" else "", - report.nodeid, - ) - target = (self.result_dir / name).absolute() - if target.is_dir(): - shutil.rmtree(target) +def pytest_collection_modifyitems(config, items): + """ + Modify test items during collection to set up MPL testing. - @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) + 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) + 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) -def pytest_collection_modifyitems(config, items): + # Skip tests that don't have baseline images for item in items: for mark in item.own_markers: if base_dir := config.getoption("--mpl-baseline-path", default=None): @@ -112,10 +118,81 @@ def pytest_collection_modifyitems(config, items): ) -# Register the plugin if the option is used +@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) + + 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) + """ + # Suppress ultraplot config loading which mpl does not recognize + logging.getLogger("matplotlib").setLevel(logging.ERROR) + logging.getLogger("ultraplot").setLevel(logging.WARNING) + try: - if config.getoption("--store-failed-only", False): - config.pluginmanager.register(StoreFailedMplPlugin(config)) + # Always register the plugin - it provides enhanced functionality beyond just cleanup + config.pluginmanager.register(StoreFailedMplPlugin(config)) except Exception as e: - print(f"Error during plugin configuration: {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) From 255c16a837c2f036e5a78de3c02123bcf2345747 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 15:24:44 +0200 Subject: [PATCH 21/47] restore files --- ultraplot/config.py | 13 + ultraplot/tests/conftest.py | 20 +- ultraplot/tests/mpl_plugin/__init__.py | 30 ++ ultraplot/tests/mpl_plugin/cleanup.py | 109 ++++ ultraplot/tests/mpl_plugin/core.py | 99 ++++ ultraplot/tests/mpl_plugin/progress.py | 80 +++ ultraplot/tests/mpl_plugin/reporting.py | 642 ++++++++++++++++++++++++ ultraplot/tests/mpl_plugin/utils.py | 131 +++++ 8 files changed, 1116 insertions(+), 8 deletions(-) create mode 100644 ultraplot/tests/mpl_plugin/__init__.py create mode 100644 ultraplot/tests/mpl_plugin/cleanup.py create mode 100644 ultraplot/tests/mpl_plugin/core.py create mode 100644 ultraplot/tests/mpl_plugin/progress.py create mode 100644 ultraplot/tests/mpl_plugin/reporting.py create mode 100644 ultraplot/tests/mpl_plugin/utils.py diff --git a/ultraplot/config.py b/ultraplot/config.py index 6cdc5812..fbdeb1f0 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -840,6 +840,10 @@ def __delitem__(self, key): # noqa: U100 def __delattr__(self, attr): # noqa: U100 raise RuntimeError("rc settings cannot be deleted.") +<<<<<<< HEAD +======= + +>>>>>>> e674a3b6 (restore files) def __getitem__(self, key): """ Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation @@ -860,7 +864,10 @@ def __setitem__(self, key, value): kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) +<<<<<<< HEAD +======= +>>>>>>> e674a3b6 (restore files) def __getattr__(self, attr): """ @@ -920,6 +927,7 @@ def __exit__(self, *args): # noqa: U100 context = self._context[-1] for key, value in context.rc_old.items(): kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) +<<<<<<< HEAD self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) @@ -963,6 +971,11 @@ def _init(self, *, local, user, default, skip_cycle=False): continue self.load(path) +======= + self.rc_ultraplot.update(kw_ultraplot) + self.rc_matplotlib.update(kw_matplotlib) + del self._context[-1] +>>>>>>> e674a3b6 (restore files) def _validate_key(self, key, value=None): """ diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index d5c87235..2855c98d 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -9,16 +9,22 @@ - Each thread gets independent, deterministic RNG instances - Compatible with pytest-xdist parallel execution - Clean separation of concerns - tests explicitly declare RNG dependencies +<<<<<<< HEAD +======= + +Matplotlib rcParams Safety: +- Automatic rcParams isolation for all tests prevents interference +- Tests that modify matplotlib settings are automatically isolated +- Dedicated rcparams_isolation fixture for explicit isolation needs +- Thread-safe for parallel execution with pytest-xdist +>>>>>>> e674a3b6 (restore files) """ import threading, os, shutil, pytest, re import numpy as np, ultraplot as uplt import warnings, logging -<<<<<<< HEAD import os, shutil, pytest, re, numpy as np, ultraplot as uplt -======= ->>>>>>> e8823ad9 (restore conftest.py) from pathlib import Path from datetime import datetime @@ -60,7 +66,6 @@ def test_something(rng): @pytest.fixture(autouse=True) -<<<<<<< HEAD def reset_rc_and_close_figures(): """Reset rc to full ultraplot defaults and close figures for each test.""" # Force complete ultraplot initialization for this thread @@ -71,11 +76,9 @@ def reset_rc_and_close_figures(): @pytest.fixture(autouse=True) def close_figures_after_test(): -======= -def close_figures_after_test(): - """Automatically close all figures after each test.""" ->>>>>>> e8823ad9 (restore conftest.py) yield + + # Clean up after test - only close figures, don't reset rc uplt.close("all") @@ -157,6 +160,7 @@ def pytest_configure(config): - 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) diff --git a/ultraplot/tests/mpl_plugin/__init__.py b/ultraplot/tests/mpl_plugin/__init__.py new file mode 100644 index 00000000..e5326d58 --- /dev/null +++ b/ultraplot/tests/mpl_plugin/__init__.py @@ -0,0 +1,30 @@ +""" +MPL Plugin Module for Enhanced Matplotlib Testing + +This module provides enhanced functionality for matplotlib image comparison tests, +including progress tracking, artifact optimization, and HTML report generation. + +The module is structured as follows: +- core.py: Main plugin class and core functionality +- progress.py: Progress bar and visual feedback +- cleanup.py: Deferred cleanup and artifact optimization +- reporting.py: HTML report generation +- utils.py: Utility functions and helpers +""" + +from .core import StoreFailedMplPlugin +from .progress import ProgressTracker +from .cleanup import CleanupManager +from .reporting import HTMLReportGenerator +from .utils import extract_test_name_from_filename, categorize_image_file + +__all__ = [ + "StoreFailedMplPlugin", + "ProgressTracker", + "CleanupManager", + "HTMLReportGenerator", + "extract_test_name_from_filename", + "categorize_image_file", +] + +__version__ = "1.0.0" diff --git a/ultraplot/tests/mpl_plugin/cleanup.py b/ultraplot/tests/mpl_plugin/cleanup.py new file mode 100644 index 00000000..4947c0ee --- /dev/null +++ b/ultraplot/tests/mpl_plugin/cleanup.py @@ -0,0 +1,109 @@ +""" +Cleanup management module for matplotlib test artifacts. + +This module provides deferred cleanup functionality to optimize artifact sizes +and eliminate race conditions in parallel test execution. +""" + +import shutil +import threading +from pathlib import Path + + +class CleanupManager: + """Manages deferred cleanup of successful test artifacts.""" + + def __init__(self): + self.pending_cleanups = set() + self.lock = threading.Lock() + + def mark_for_cleanup(self, target_path): + """Mark a directory for cleanup without blocking the worker.""" + with self.lock: + if target_path.exists() and target_path.is_dir(): + self.pending_cleanups.add(target_path) + return True + return False + + def perform_cleanup(self, store_failed_only=False): + """Perform deferred cleanup of all marked directories.""" + if not store_failed_only: + self._handle_no_cleanup() + return + + with self.lock: + cleanup_list = list(self.pending_cleanups) + self.pending_cleanups.clear() + + if cleanup_list: + self._cleanup_directories(cleanup_list) + else: + print("๐Ÿ’พ Perfect optimization: No cleanup needed (all tests failed)") + + def _handle_no_cleanup(self): + """Handle case where cleanup optimization is disabled.""" + with self.lock: + total_items = len(self.pending_cleanups) + self.pending_cleanups.clear() + + if total_items > 0: + print(f"๐Ÿ’พ All {total_items} test images preserved for review") + print(" ๐Ÿ’ก Use --store-failed-only to enable artifact size optimization") + + def _cleanup_directories(self, cleanup_list): + """Clean up the list of directories with progress tracking.""" + print( + f"๐Ÿงน Cleaning up {len(cleanup_list)} successful test directories (--store-failed-only enabled)..." + ) + success_count = 0 + + for i, target in enumerate(cleanup_list, 1): + # Update cleanup progress bar + percentage = int((i / len(cleanup_list)) * 100) + bar_width = 20 + filled_width = int((percentage / 100) * bar_width) + bar = ( + "=" * filled_width + + (">" if filled_width < bar_width else "") + + " " + * (bar_width - filled_width - (1 if filled_width < bar_width else 0)) + ) + + try: + if target.exists() and target.is_dir(): + shutil.rmtree(target) + success_count += 1 + status = "โœ“" + else: + status = "~" + except (FileNotFoundError, OSError, PermissionError): + status = "~" + except Exception as e: + status = "โœ—" + + cleanup_line = f"\rCleanup: [{bar}] {percentage:3d}% ({i}/{len(cleanup_list)}) {status}" + print(cleanup_line, end="", flush=True) + + print() # New line after progress bar + print( + f"โœ… Cleanup completed: {success_count}/{len(cleanup_list)} directories removed" + ) + if success_count < len(cleanup_list): + print( + f" Note: {len(cleanup_list) - success_count} directories were already removed or inaccessible" + ) + print("๐Ÿ’พ Artifact optimization: Only failed tests preserved for debugging") + + def get_pending_count(self): + """Get the number of directories pending cleanup.""" + with self.lock: + return len(self.pending_cleanups) + + +# Global cleanup manager instance +cleanup_manager = CleanupManager() + + +def get_cleanup_manager(): + """Get the global cleanup manager instance.""" + return cleanup_manager diff --git a/ultraplot/tests/mpl_plugin/core.py b/ultraplot/tests/mpl_plugin/core.py new file mode 100644 index 00000000..c09dd2da --- /dev/null +++ b/ultraplot/tests/mpl_plugin/core.py @@ -0,0 +1,99 @@ +""" +Core plugin module for enhanced matplotlib testing. + +This module contains the main StoreFailedMplPlugin class that coordinates +all matplotlib test functionality including progress tracking, cleanup management, +and HTML report generation. +""" + +import re +import pytest +from pathlib import Path + +from .progress import get_progress_tracker +from .cleanup import get_cleanup_manager +from .utils import create_nodeid_to_path_mapping, validate_config_paths + + +class StoreFailedMplPlugin: + """ + Main plugin class for enhanced matplotlib image comparison testing. + + This plugin provides: + - Real-time progress tracking with visual progress bars + - Deferred cleanup to eliminate race conditions + - Thread-safe artifact optimization + - Failed test tracking for HTML report generation + """ + + def __init__(self, config): + self.config = config + + # Validate and set up paths + paths = validate_config_paths(config) + self.result_dir = paths["results"] + self.baseline_dir = paths["baseline"] + + # Track failed mpl tests for HTML report generation + self.failed_mpl_tests = set() + + # Get global managers + self.progress_tracker = get_progress_tracker() + self.cleanup_manager = get_cleanup_manager() + + # Only show initialization message if MPL tests will be run + if any("--mpl" in str(arg) for arg in getattr(config, "args", [])): + print(f"Store Failed MPL Plugin initialized") + print(f"Result dir: {self.result_dir}") + + 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) + + def _remove_success(self, report: pytest.TestReport): + """Mark successful test images for deferred cleanup to eliminate blocking.""" + + # Only perform cleanup if --store-failed-only is enabled + if not self.config.getoption("--store-failed-only", False): + return + + # Convert nodeid to filesystem path + name = create_nodeid_to_path_mapping(report.nodeid) + target = (self.result_dir / name).absolute() + + # Mark for deferred cleanup (non-blocking) + if self.cleanup_manager.mark_for_cleanup(target): + print(".", end="", flush=True) + + @pytest.hookimpl(trylast=True) + def pytest_runtest_logreport(self, report): + """Hook that processes each test report.""" + # Track failed mpl tests and handle successful ones + if report.when == "call" and self._has_mpl_marker(report): + try: + # Update progress tracking + if report.outcome == "failed": + self.failed_mpl_tests.add(report.nodeid) + self.progress_tracker.increment_processed(failed=True) + else: + self.progress_tracker.increment_processed(failed=False) + # Mark successful tests for cleanup (if enabled) + self._remove_success(report) + + except Exception as e: + # Log but don't fail on processing errors + print(f"Warning: Error during test processing for {report.nodeid}: {e}") + + def get_failed_tests(self): + """Get the set of failed test nodeids.""" + return self.failed_mpl_tests.copy() + + def get_stats(self): + """Get current test statistics.""" + return self.progress_tracker.get_stats() + + def finalize(self): + """Finalize progress tracking and perform cleanup.""" + self.progress_tracker.finalize_progress() + store_failed_only = self.config.getoption("--store-failed-only", False) + self.cleanup_manager.perform_cleanup(store_failed_only) diff --git a/ultraplot/tests/mpl_plugin/progress.py b/ultraplot/tests/mpl_plugin/progress.py new file mode 100644 index 00000000..be22f275 --- /dev/null +++ b/ultraplot/tests/mpl_plugin/progress.py @@ -0,0 +1,80 @@ +""" +Progress tracking module for matplotlib test execution. + +This module provides real-time progress bars and visual feedback for matplotlib +image comparison tests, including success/failure counters and completion percentages. +""" + +import threading + + +class ProgressTracker: + """Manages progress tracking and visual feedback for matplotlib tests.""" + + def __init__(self): + self.total_tests = 0 + self.processed_tests = 0 + self.failed_tests = 0 + self.lock = threading.Lock() + + def set_total_tests(self, total): + """Set the total number of matplotlib tests expected.""" + with self.lock: + self.total_tests = total + + def increment_processed(self, failed=False): + """Increment the processed test counter.""" + with self.lock: + self.processed_tests += 1 + if failed: + self.failed_tests += 1 + self._update_progress_bar() + + def _update_progress_bar(self): + """Update the progress bar with current test status.""" + if self.total_tests == 0: + return + + percentage = int((self.processed_tests / self.total_tests) * 100) + success_count = self.processed_tests - self.failed_tests + + # Create progress bar: [=========> ] 67% (45/67) | โœ“32 โœ—13 + bar_width = 20 + filled_width = int((percentage / 100) * bar_width) + bar = ( + "=" * filled_width + + (">" if filled_width < bar_width else "") + + " " * (bar_width - filled_width - (1 if filled_width < bar_width else 0)) + ) + + progress_line = f"\rMPL Tests: [{bar}] {percentage:3d}% ({self.processed_tests}/{self.total_tests}) | โœ“{success_count} โœ—{self.failed_tests}" + print(progress_line, end="", flush=True) + + def finalize_progress(self): + """Finalize the progress bar and show summary.""" + print() # New line after progress bar + success_count = self.processed_tests - self.failed_tests + + if self.failed_tests > 0: + print(f"๐Ÿ“Š MPL Summary: {success_count} passed, {self.failed_tests} failed") + else: + print(f"๐Ÿ“Š MPL Summary: All {success_count} tests passed!") + + def get_stats(self): + """Get current test statistics.""" + with self.lock: + return { + "total": self.total_tests, + "processed": self.processed_tests, + "failed": self.failed_tests, + "passed": self.processed_tests - self.failed_tests, + } + + +# Global progress tracker instance +progress_tracker = ProgressTracker() + + +def get_progress_tracker(): + """Get the global progress tracker instance.""" + return progress_tracker diff --git a/ultraplot/tests/mpl_plugin/reporting.py b/ultraplot/tests/mpl_plugin/reporting.py new file mode 100644 index 00000000..6086da3f --- /dev/null +++ b/ultraplot/tests/mpl_plugin/reporting.py @@ -0,0 +1,642 @@ +""" +HTML reporting module for matplotlib test results. + +This module provides comprehensive HTML report generation with interactive features, +including visual comparisons, filtering capabilities, and responsive design. +""" + +import os +import shutil +from pathlib import Path +from datetime import datetime + +from .utils import ( + extract_test_name_from_filename, + categorize_image_file, + get_results_directory, +) + + +class HTMLReportGenerator: + """Generates interactive HTML reports for matplotlib test results.""" + + def __init__(self, config): + self.config = config + self.template_dir = Path(__file__).parent / "templates" + self.results_dir = get_results_directory(config) + # Ensure template directory exists + if not self.template_dir.exists(): + print(f"Warning: Template directory not found: {self.template_dir}") + + def generate_report(self, failed_tests_set): + """Generate the complete HTML report.""" + if not self._should_generate_report(): + return + + print("\nGenerating HTML report for image comparison tests...") + print( + "Note: When using --store-failed-only, only failed tests will be included in the report" + ) + + test_results = self._process_test_results() + if not test_results: + print("No test results found for HTML report generation") + return + + # Generate display names and mark failed tests + self._enhance_test_results(test_results, failed_tests_set) + + # Copy template files to results directory + self._copy_template_assets() + + # Generate HTML content + html_content = self._generate_html_content(test_results) + + # Write the report + report_path = self.results_dir / "index.html" + report_path.parent.mkdir(parents=True, exist_ok=True) + + with open(report_path, "w") as f: + f.write(html_content) + + print(f"HTML report generated at: {report_path}") + print(f"Template directory: {self.template_dir}") + print(f"Results directory: {self.results_dir}") + print("Open the report in a web browser to view the results.") + + def _should_generate_report(self): + """Check if HTML report should be generated.""" + if not self.results_dir.exists(): + print(f"Results directory not found: {self.results_dir}") + return False + return True + + def _copy_template_assets(self): + """Copy CSS and JS files to results directory.""" + try: + # Copy CSS file + css_src = self.template_dir / "styles.css" + css_dst = self.results_dir / "styles.css" + if css_src.exists(): + shutil.copy2(css_src, css_dst) + print(f"Copied CSS to: {css_dst}") + else: + print(f"Warning: CSS template not found at: {css_src}") + + # Copy JS file + js_src = self.template_dir / "scripts.js" + js_dst = self.results_dir / "scripts.js" + if js_src.exists(): + shutil.copy2(js_src, js_dst) + print(f"Copied JS to: {js_dst}") + else: + print(f"Warning: JS template not found at: {js_src}") + except Exception as e: + print(f"Error copying template assets: {e}") + + def _load_template(self, template_name): + """Load a template file.""" + template_path = self.template_dir / template_name + print(f"Attempting to load template: {template_path}") + print(f"Template exists: {template_path.exists()}") + try: + with open(template_path, "r", encoding="utf-8") as f: + content = f.read() + print( + f"Successfully loaded template: {template_path} ({len(content)} chars)" + ) + return content + except FileNotFoundError: + print( + f"Warning: Template {template_name} not found at {template_path}, using fallback" + ) + return None + except Exception as e: + print(f"Error loading template {template_name}: {e}") + return None + + def _process_test_results(self): + """Process test result files and organize by test.""" + test_results = {} + + # Recursively search for all PNG files + for image_file in self.results_dir.rglob("*.png"): + rel_path = image_file.relative_to(self.results_dir) + parent_dir = rel_path.parent if rel_path.parent != Path(".") else None + filename = image_file.name + + # Skip hash files + if "hash" in filename: + continue + + # Handle pytest-mpl directory structure + if parent_dir: + test_name = str(parent_dir) + + if test_name not in test_results: + test_results[test_name] = { + "baseline": None, + "result": None, + "diff": None, + "path": parent_dir, + } + + # Categorize files based on pytest-mpl naming convention + if filename == "baseline.png": + test_results[test_name]["baseline"] = image_file + elif filename == "result.png": + test_results[test_name]["result"] = image_file + elif filename == "result-failed-diff.png": + test_results[test_name]["diff"] = image_file + else: + # Fallback for files in root directory (legacy naming) + test_id = image_file.stem + test_name = extract_test_name_from_filename(filename, test_id) + image_type = categorize_image_file(filename, test_id) + + if test_name not in test_results: + test_results[test_name] = { + "baseline": None, + "result": None, + "diff": None, + "path": parent_dir, + } + + if image_type == "baseline": + test_results[test_name]["baseline"] = image_file + elif image_type == "diff": + test_results[test_name]["diff"] = image_file + elif image_type == "result" and not test_results[test_name]["result"]: + test_results[test_name]["result"] = image_file + + return test_results + + def _enhance_test_results(self, test_results, failed_tests_set): + """Add display names and test status to results.""" + for test_name, data in test_results.items(): + # Generate display name + if data["path"]: + data["display_name"] = test_name.replace("/", ".").replace("\\", ".") + else: + data["display_name"] = test_name + + # Mark as failed if tracked during test execution + data["test_failed"] = any( + any( + pattern in nodeid + for pattern in [ + test_name.replace(".", "::"), + test_name.replace( + "ultraplot.tests.", "ultraplot/tests/" + ).replace(".", "::"), + f"ultraplot/tests/{test_name.split('.')[-2]}.py::{test_name.split('.')[-1]}", + ] + ) + for nodeid in failed_tests_set + ) + + def _generate_html_content(self, test_results): + """Generate the complete HTML content with enhanced inline styling.""" + # Calculate statistics + total_tests = len(test_results) + failed_tests = sum( + 1 + for data in test_results.values() + if data.get("test_failed", False) or data.get("diff") + ) + passed_tests = sum( + 1 + for data in test_results.values() + if data.get("baseline") + and data.get("result") + and not data.get("test_failed", False) + ) + unknown_tests = total_tests - failed_tests - passed_tests + + # Generate test cases HTML + test_cases_html = self._generate_all_test_cases(test_results) + + # Enhanced CSS styling + css_content = """""" + + # Enhanced JavaScript + js_content = """""" + + # Generate timestamp + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Build HTML + html_content = f""" + + + + + UltraPlot Image Comparison Report + {css_content} + + +
+
+

UltraPlot Image Comparison Report

+
+
+ {total_tests} + Total Tests +
+
+ {failed_tests} + Failed +
+
+ {passed_tests} + Passed +
+
+ {unknown_tests} + Unknown +
+
+
+ +
+ + + + +
+ +
+ {test_cases_html} +
+ +
Report generated on {timestamp}
+
+ {js_content} + +""" + + return html_content + + def _generate_all_test_cases(self, test_results): + """Generate HTML for all test cases.""" + test_cases_html = [] + + # Sort tests by display name + sorted_tests = sorted( + test_results.items(), key=lambda x: x[1].get("display_name", x[0]) + ) + + for test_name, data in sorted_tests: + test_case_html = self._generate_test_case_html(test_name, data) + test_cases_html.append(test_case_html) + + return "\n".join(test_cases_html) + + def _generate_test_case_html(self, test_name, data): + """Generate HTML for a single test case.""" + display_name = data.get("display_name", test_name) + + # Determine test status + if data.get("test_failed", False) or data.get("diff"): + status = "failed" + status_text = "FAILED" + elif ( + data.get("baseline") + and data.get("result") + and not data.get("test_failed", False) + ): + status = "passed" + status_text = "PASSED" + else: + status = "unknown" + status_text = "UNKNOWN" + + # Generate image columns + image_columns = [] + + # Add baseline image column + if data.get("baseline"): + rel_path = data["baseline"].relative_to(self.results_dir) + image_columns.append( + f""" +
+

Baseline (Expected)

+ Baseline image +
""" + ) + else: + image_columns.append( + """ +
+

Baseline (Expected)

+
No baseline image
+
""" + ) + + # Add result image column + if data.get("result"): + rel_path = data["result"].relative_to(self.results_dir) + image_columns.append( + f""" +
+

Result (Actual)

+ Result image +
""" + ) + else: + image_columns.append( + """ +
+

Result (Actual)

+
No result image
+
""" + ) + + # Add diff image column (only if it exists) + if data.get("diff"): + rel_path = data["diff"].relative_to(self.results_dir) + image_columns.append( + f""" +
+

Difference

+ Difference image +
""" + ) + + image_columns_html = "\n".join(image_columns) + + return f""" +
+
+
{display_name}
+
{status_text}
+
+
+
+ {image_columns_html} +
+
+
""" + + def _generate_fallback_html(self, test_results): + """Generate fallback HTML if templates are not available.""" + # Calculate statistics + total_tests = len(test_results) + failed_tests = sum( + 1 + for data in test_results.values() + if data.get("test_failed", False) or data.get("diff") + ) + passed_tests = sum( + 1 + for data in test_results.values() + if data.get("baseline") + and data.get("result") + and not data.get("test_failed", False) + ) + unknown_tests = total_tests - failed_tests - passed_tests + + # Try to load external CSS for better styling + css_content = "" + css_template = self._load_template("styles.css") + if css_template: + css_content = f"" + else: + css_content = """""" + + html_parts = [ + "", + "", + "", + " ", + " ", + " UltraPlot Image Comparison Report", + css_content, + "", + "", + "
", + "

UltraPlot Image Comparison Report

", + "
", + f"

Total: {total_tests} Passed: {passed_tests} Failed: {failed_tests} Unknown: {unknown_tests}

", + "
", + "
", + " ", + " ", + " ", + " ", + "
", + ] + + # Add test cases + for test_name, data in sorted(test_results.items()): + html_parts.append(self._generate_test_case_html(test_name, data)) + + # Try to load external JavaScript or use inline fallback + js_content = "" + js_template = self._load_template("scripts.js") + if js_template: + js_content = f"" + else: + js_content = """""" + + # Add footer with JavaScript and timestamp + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + html_parts.extend( + [ + f"
Report generated on {timestamp}
", + "
", + js_content, + "", + "", + ] + ) + + return "\n".join(html_parts) diff --git a/ultraplot/tests/mpl_plugin/utils.py b/ultraplot/tests/mpl_plugin/utils.py new file mode 100644 index 00000000..b38d21df --- /dev/null +++ b/ultraplot/tests/mpl_plugin/utils.py @@ -0,0 +1,131 @@ +""" +Utility functions for matplotlib test processing. + +This module provides helper functions for file processing, test name extraction, +and other common operations used throughout the MPL plugin. +""" + +import re +from pathlib import Path + + +def extract_test_name_from_filename(filename, test_id): + """Extract test name from various pytest-mpl filename patterns.""" + # Handle different pytest-mpl filename patterns + if filename.endswith("-expected.png"): + return test_id.replace("-expected", "") + elif filename.endswith("-failed-diff.png"): + return test_id.replace("-failed-diff", "") + elif filename.endswith("-result.png"): + return test_id.replace("-result", "") + elif filename.endswith("-actual.png"): + return test_id.replace("-actual", "") + else: + # Remove common result suffixes if present + possible_test_name = test_id + for suffix in ["-result", "-actual", "-diff"]: + if possible_test_name.endswith(suffix): + possible_test_name = possible_test_name.replace(suffix, "") + return possible_test_name + + +def categorize_image_file(filename, test_id): + """Categorize an image file based on its filename pattern.""" + if filename.endswith("-expected.png"): + return "baseline" + elif filename.endswith("-failed-diff.png"): + return "diff" + elif filename.endswith("-result.png") or filename.endswith("-actual.png"): + return "result" + else: + # Default assumption for uncategorized files + return "result" + + +def get_results_directory(config): + """Get the results directory path from config.""" + results_path = ( + getattr(config.option, "mpl_results_path", None) + or getattr(config, "_mpl_results_path", None) + or "./mpl-results" + ) + return Path(results_path) + + +def should_generate_html_report(config): + """Determine if HTML report should be generated.""" + # Check if matplotlib comparison tests are being used + if hasattr(config.option, "mpl_results_path"): + return True + if hasattr(config, "_mpl_results_path"): + return True + # Check if any mpl_image_compare markers were collected + if hasattr(config, "_mpl_image_compare_found"): + return True + return False + + +def get_failed_mpl_tests(config): + """Get set of failed mpl test nodeids from the plugin.""" + # Look for our plugin instance + for plugin in config.pluginmanager.get_plugins(): + if hasattr(plugin, "failed_mpl_tests"): + return plugin.failed_mpl_tests + return set() + + +def create_nodeid_to_path_mapping(nodeid): + """Convert pytest nodeid to filesystem path pattern.""" + pattern = r"(?P::|/)|\[|\]|\.py" + name = re.sub( + pattern, + lambda m: "." if m.group("sep") else "_" if m.group(0) == "[" else "", + nodeid, + ) + return name + + +def safe_path_conversion(path_input): + """Safely convert path input to Path object, handling None values.""" + if path_input is None: + return None + return Path(path_input) + + +def count_mpl_tests(items): + """Count the number of matplotlib image comparison tests in the item list.""" + return sum( + 1 + for item in items + if any(mark.name == "mpl_image_compare" for mark in item.own_markers) + ) + + +def is_mpl_test(item): + """Check if a test item is a matplotlib image comparison test.""" + return any(mark.name == "mpl_image_compare" for mark in item.own_markers) + + +def format_file_size(size_bytes): + """Format file size in human-readable format.""" + if size_bytes == 0: + return "0 bytes" + + size_names = ["bytes", "KB", "MB", "GB"] + i = 0 + while size_bytes >= 1024 and i < len(size_names) - 1: + size_bytes /= 1024.0 + i += 1 + + return f"{size_bytes:.1f} {size_names[i]}" + + +def validate_config_paths(config): + """Validate and normalize configuration paths.""" + results_path = config.getoption("--mpl-results-path", None) or "./results" + baseline_path = config.getoption("--mpl-baseline-path", None) or "./baseline" + + return { + "results": Path(results_path), + "baseline": Path(baseline_path), + } From b39e542dd24687a6e7182f5277bf31bf04edb422 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 15:29:39 +0200 Subject: [PATCH 22/47] rm more remnants --- ultraplot/config.py | 13 ------------- ultraplot/tests/conftest.py | 9 --------- 2 files changed, 22 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index fbdeb1f0..6cdc5812 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -840,10 +840,6 @@ def __delitem__(self, key): # noqa: U100 def __delattr__(self, attr): # noqa: U100 raise RuntimeError("rc settings cannot be deleted.") -<<<<<<< HEAD -======= - ->>>>>>> e674a3b6 (restore files) def __getitem__(self, key): """ Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation @@ -864,10 +860,7 @@ def __setitem__(self, key, value): kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) -<<<<<<< HEAD -======= ->>>>>>> e674a3b6 (restore files) def __getattr__(self, attr): """ @@ -927,7 +920,6 @@ def __exit__(self, *args): # noqa: U100 context = self._context[-1] for key, value in context.rc_old.items(): kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) -<<<<<<< HEAD self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) @@ -971,11 +963,6 @@ def _init(self, *, local, user, default, skip_cycle=False): continue self.load(path) -======= - self.rc_ultraplot.update(kw_ultraplot) - self.rc_matplotlib.update(kw_matplotlib) - del self._context[-1] ->>>>>>> e674a3b6 (restore files) def _validate_key(self, key, value=None): """ diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 2855c98d..9d7d353b 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -9,15 +9,6 @@ - Each thread gets independent, deterministic RNG instances - Compatible with pytest-xdist parallel execution - Clean separation of concerns - tests explicitly declare RNG dependencies -<<<<<<< HEAD -======= - -Matplotlib rcParams Safety: -- Automatic rcParams isolation for all tests prevents interference -- Tests that modify matplotlib settings are automatically isolated -- Dedicated rcparams_isolation fixture for explicit isolation needs -- Thread-safe for parallel execution with pytest-xdist ->>>>>>> e674a3b6 (restore files) """ import threading, os, shutil, pytest, re From 7a7d62b51568e9bddfbaf136c75bd187fd3fdcec Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 15:30:46 +0200 Subject: [PATCH 23/47] rm more remnants --- ultraplot/config.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 6cdc5812..feff2f0e 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -840,6 +840,7 @@ def __delitem__(self, key): # noqa: U100 def __delattr__(self, attr): # noqa: U100 raise RuntimeError("rc settings cannot be deleted.") + def __getitem__(self, key): """ Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation @@ -861,7 +862,6 @@ def __setitem__(self, key, value): self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) - def __getattr__(self, attr): """ Return an `rc_matplotlib` or `rc_ultraplot` setting using "dot" notation @@ -924,9 +924,6 @@ def __exit__(self, *args): # noqa: U100 self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) del self._context[-1] - rc_ultraplot.update(kw_ultraplot) - rc_matplotlib.update(kw_matplotlib) - del self._context[-1] def _init(self, *, local, user, default, skip_cycle=False): """ @@ -963,7 +960,6 @@ def _init(self, *, local, user, default, skip_cycle=False): continue self.load(path) - def _validate_key(self, key, value=None): """ Validate setting names and handle `rc_ultraplot` deprecations. From b92306c233af366b393c3f0d9f586c2dfcc1daf5 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 15:31:53 +0200 Subject: [PATCH 24/47] rm more remnants --- ultraplot/config.py | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index feff2f0e..4b21bf96 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -925,41 +925,6 @@ def __exit__(self, *args): # noqa: U100 self.rc_matplotlib.update(kw_matplotlib) del self._context[-1] - def _init(self, *, local, user, default, skip_cycle=False): - """ - Initialize the configurator. - """ - # 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) - def _validate_key(self, key, value=None): """ Validate setting names and handle `rc_ultraplot` deprecations. From 7b728fe7f5a78e81506c8271abdeacd27d110a4c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 20 Jun 2025 18:16:51 +0200 Subject: [PATCH 25/47] rm global rc dicts --- ultraplot/__init__.py | 4 +--- ultraplot/config.py | 9 --------- ultraplot/figure.py | 12 +++++++----- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 87da7d8b..798e072f 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -98,10 +98,8 @@ # Validate color names now that colors are registered # NOTE: This updates all settings with 'color' in name (harmless if it's not a color) -from .config import rc_ultraplot, rc_matplotlib - rcsetup.VALIDATE_REGISTERED_COLORS = True -for _src in (rc_ultraplot, rc_matplotlib): +for _src in (rc.rc_ultraplot, rc.rc_matplotlib): for _key in _src: # loop through unsynced properties if "color" not in _key: continue diff --git a/ultraplot/config.py b/ultraplot/config.py index 4b21bf96..77ff20da 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -54,8 +54,6 @@ def get_ipython(): __all__ = [ "Configurator", "rc", - "rc_ultraplot", - "rc_matplotlib", "use_style", "config_inline_backend", "register_cmaps", @@ -1854,13 +1852,6 @@ def changed(self): #: settings. See the :ref:`configuration guide ` for details. rc = Configurator(skip_cycle=True) -#: A dictionary-like container of ultraplot settings. Assignments are -#: validated and restricted to recognized setting names. -rc_ultraplot = rc.rc_ultraplot -#: A dictionary-like container of matplotlib settings. Assignments are -#: validated and restricted to recognized setting names. -rc_matplotlib = rc.rc_matplotlib - # Deprecated RcConfigurator = warnings._rename_objs( "0.8.0", diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 645ac79a..0361a3e4 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -23,7 +23,7 @@ from . import axes as paxes from . import constructor from . import gridspec as pgridspec -from .config import rc, rc_matplotlib +from .config import rc from .internals import ic # noqa: F401 from .internals import ( _not_none, @@ -710,21 +710,23 @@ def __init__( warnings._warn_ultraplot( "Ignoring constrained_layout=True. " + self._tight_message ) - if rc_matplotlib.get("figure.autolayout", False): + if rc.rc_matplotlib.get("figure.autolayout", False): warnings._warn_ultraplot( "Setting rc['figure.autolayout'] to False. " + self._tight_message ) - if rc_matplotlib.get("figure.constrained_layout.use", False): + if rc.rc_matplotlib.get("figure.constrained_layout.use", False): warnings._warn_ultraplot( "Setting rc['figure.constrained_layout.use'] to False. " + self._tight_message # noqa: E501 ) try: - rc_matplotlib["figure.autolayout"] = False # this is rcParams + rc.rc_matplotlib["figure.autolayout"] = False # this is rcParams except KeyError: pass try: - rc_matplotlib["figure.constrained_layout.use"] = False # this is rcParams + rc.rc_matplotlib["figure.constrained_layout.use"] = ( + False # this is rcParams + ) except KeyError: pass self._tight_active = _not_none(tight, rc["subplots.tight"]) From 53a816d34fdde949278c50662e6cea3c5992af2c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 21 Jun 2025 11:46:06 +0200 Subject: [PATCH 26/47] stash --- ultraplot/config.py | 7 +--- ultraplot/internals/rcsetup.py | 70 ++++++++++++++++++++-------------- 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 77ff20da..fa0fcf26 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -784,10 +784,7 @@ def _init(self, *, local, user, default, skip_cycle=False): @property def _context(self): - if not hasattr(self._thread_local, "_context"): - # Initialize context as an empty list - self._thread_local._context = [] - return self._thread_local._context + return self._get_thread_local_copy("_context", []) @_context.setter def _context(self, value): @@ -807,7 +804,7 @@ def rc_matplotlib(self): @property def rc_ultraplot(self): return self._get_thread_local_copy( - "rc_ultraplot", rcsetup._rc_ultraplot_default + "rc_ultraplot", rcsetup._rc_ultraplot_default.copy(skip_validation=True) ) def __repr__(self): diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 6c7c9a96..76d215b3 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -528,24 +528,15 @@ class _RcParams(MutableMapping, dict): # NOTE: By omitting __delitem__ in MutableMapping we effectively # disable mutability. Also disables deleting items with pop(). - def __init__(self, source, validate): - import threading - self._thread_props = threading.local() # for thread-local properties + def __init__(self, data=None, validate=None, lazy_keys=None): + self._validate = validate or {} + self._lazy_keys = set(lazy_keys or []) + self._unvalidated = {} - self._validate = validate - for key, value in source.items(): - self.__setitem__(key, value) # trigger validation - - @property - def _validate(self): - if not hasattr(self._thread_props, "_validate"): - self._thread_props._validate = _rc_ultraplot_validate - return self._thread_props._validate.copy() - - @_validate.setter - def _validate(self, value): - self._thread_props._validate = value + if data: + for key, value in data.items(): + self[key] = value def __repr__(self): return RcParams.__repr__(self) @@ -563,17 +554,35 @@ def __iter__(self): def __getitem__(self, key): key, _ = self._check_key(key) + + # Perform lazy validation if required + if key in self._unvalidated: + raw_value = self._unvalidated.pop(key) + try: + validated = self._validate[key](raw_value) + except (ValueError, TypeError) as error: + raise ValueError( + f"Lazy validation failed for {key!r}: {error}" + ) from None + dict.__setitem__(self, key, validated) + return dict.__getitem__(self, key) def __setitem__(self, key, value): key, value = self._check_key(key, value) + if key not in self._validate: raise KeyError(f"Invalid rc key {key!r}.") - try: - value = self._validate[key](value) - except (ValueError, TypeError) as error: - raise ValueError(f"Key {key}: {error}") from None - if key is not None: + + if key in self._lazy_keys: + # Store unvalidated + self._unvalidated[key] = value + dict.__setitem__(self, key, value) + else: + try: + value = self._validate[key](value) + except (ValueError, TypeError) as error: + raise ValueError(f"Key {key!r}: {error}") from None dict.__setitem__(self, key, value) @staticmethod @@ -601,14 +610,17 @@ def _check_key(key, value=None): ) return key, value - def copy(self): - # Access validation dict from thread-local if available, else fallback - validate = getattr(self._thread_props, "_validate", None) - if validate is None: - # fallback: guess it from another thread (e.g., first one that set it) - validate = dict() - source = dict(self) - return _RcParams(source.copy(), validate.copy()) + def copy(self, skip_validation=False): + new = _RcParams( + data=dict(self), + validate=self._validate, + lazy_keys=self._lazy_keys, + ) + if skip_validation: + new._unvalidated = dict(self) + else: + new._unvalidated = self._unvalidated.copy() + return new # Borrow validators from matplotlib and construct some new ones From 186b7444ece0d369e7ee4fd2113fcc9a10470711 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 26 Jun 2025 12:39:40 +0200 Subject: [PATCH 27/47] move validation to initializion --- ultraplot/config.py | 40 ++++++++++++++++++++++++---------- ultraplot/internals/rcsetup.py | 39 ++++----------------------------- 2 files changed, 33 insertions(+), 46 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index fa0fcf26..fe61fd07 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -742,16 +742,21 @@ def __init__(self, local=True, user=True, default=True, **kwargs): """ import threading - self._thread_local = threading.local() + # Initialize threading first to avoid recursion issues + super().__setattr__("_thread_local", threading.local()) + super().__setattr__("_initialized", False) self._init(local=local, user=user, default=default, **kwargs) + super().__setattr__("_initialized", True) def _init(self, *, local, user, default, skip_cycle=False): """ Initialize the configurator. Note: this is also used to reset the class. """ - # Always remove context objects - self._context.clear() + # Always remove context objects - use direct access to avoid recursion + if hasattr(self, "_thread_local"): + context = self._get_thread_local_copy("_context", []) + context.clear() # Update from default settings # NOTE: see _remove_blacklisted_style_params bugfix @@ -803,9 +808,14 @@ def rc_matplotlib(self): @property def rc_ultraplot(self): - return self._get_thread_local_copy( - "rc_ultraplot", rcsetup._rc_ultraplot_default.copy(skip_validation=True) - ) + if not hasattr(self._thread_local, "rc_ultraplot"): + # Initialize with a copy of the default ultraplot settings + # NOTE: skip_validation=True is necessary to avoid warnings + # about deprecated rc parameters. + self._thread_local.rc_ultraplot = rcsetup._rc_ultraplot_default.copy( + skip_validation=True + ) + return self._thread_local.rc_ultraplot def __repr__(self): cls = type("rc", (dict,), {}) # temporary class with short name @@ -872,10 +882,14 @@ def __setattr__(self, attr, value): Modify an `rc_matplotlib` or `rc_ultraplot` setting using "dot" notation (e.g., ``uplt.rc.name = value``). """ - if attr[:1] == "_": + if attr[:1] == "_" or attr in ("_thread_local", "_initialized"): super().__setattr__(attr, value) else: - self.__setitem__(attr, value) + # Check if we're initialized to avoid recursion during __init__ + if not getattr(self, "_initialized", False): + super().__setattr__(attr, value) + else: + self.__setitem__(attr, value) def __enter__(self): """ @@ -932,9 +946,13 @@ def _validate_key(self, key, value=None): key = key.lower() if "." not in key: key = rcsetup._rc_nodots.get(key, key) - key, value = self.rc_ultraplot._check_key( - key, value - ) # may issue deprecation warning + # Use the raw thread-local copy of rc_ultraplot instead of the property getter + if not hasattr(self._thread_local, "rc_ultraplot"): + self._thread_local.rc_ultraplot = rcsetup._rc_ultraplot_default.copy( + skip_validation=True + ) + rc_ultraplot = self._thread_local.rc_ultraplot + key, value = rc_ultraplot._check_key(key, value) return key, value def _validate_value(self, key, value): diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 76d215b3..01fc146c 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -529,13 +529,12 @@ class _RcParams(MutableMapping, dict): # NOTE: By omitting __delitem__ in MutableMapping we effectively # disable mutability. Also disables deleting items with pop(). - def __init__(self, data=None, validate=None, lazy_keys=None): + def __init__(self, data=None, validate=None): self._validate = validate or {} - self._lazy_keys = set(lazy_keys or []) - self._unvalidated = {} - if data: for key, value in data.items(): + if key not in self._validate: + raise KeyError(f"Inalid rc {key!r}.") self[key] = value def __repr__(self): @@ -554,36 +553,11 @@ def __iter__(self): def __getitem__(self, key): key, _ = self._check_key(key) - - # Perform lazy validation if required - if key in self._unvalidated: - raw_value = self._unvalidated.pop(key) - try: - validated = self._validate[key](raw_value) - except (ValueError, TypeError) as error: - raise ValueError( - f"Lazy validation failed for {key!r}: {error}" - ) from None - dict.__setitem__(self, key, validated) - return dict.__getitem__(self, key) def __setitem__(self, key, value): key, value = self._check_key(key, value) - - if key not in self._validate: - raise KeyError(f"Invalid rc key {key!r}.") - - if key in self._lazy_keys: - # Store unvalidated - self._unvalidated[key] = value - dict.__setitem__(self, key, value) - else: - try: - value = self._validate[key](value) - except (ValueError, TypeError) as error: - raise ValueError(f"Key {key!r}: {error}") from None - dict.__setitem__(self, key, value) + dict.__setitem__(self, key, value) @staticmethod def _check_key(key, value=None): @@ -614,12 +588,7 @@ def copy(self, skip_validation=False): new = _RcParams( data=dict(self), validate=self._validate, - lazy_keys=self._lazy_keys, ) - if skip_validation: - new._unvalidated = dict(self) - else: - new._unvalidated = self._unvalidated.copy() return new From 48519b3791586da52aa6907fee2d2529cac9f96c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 26 Jun 2025 12:39:52 +0200 Subject: [PATCH 28/47] clean up results prior to testin --- ultraplot/tests/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 9d7d353b..165c53e4 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -157,6 +157,13 @@ def pytest_configure(config): logging.getLogger("matplotlib").setLevel(logging.ERROR) logging.getLogger("ultraplot").setLevel(logging.WARNING) + results_path = Path(config.getoption("--results-path", default="results")) + if results_path.exists(): + import shutil + + shutil.rmtree(results_path) + results_path.mkdir(parents=True, exist_ok=True) + try: # Always register the plugin - it provides enhanced functionality beyond just cleanup config.pluginmanager.register(StoreFailedMplPlugin(config)) From cac0c87594f7b87f47e6cc209884f874d3e37bf0 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 26 Jun 2025 12:40:04 +0200 Subject: [PATCH 29/47] add more threads --- ultraplot/tests/test_thread_safety.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index 1202adb7..cbb0d079 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -13,7 +13,7 @@ def modify_rc_on_thread(prop: str, value=None, with_context=True): assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" -def _spawn_and_run_threads(func, n=30, **kwargs): +def _spawn_and_run_threads(func, n=100, **kwargs): options = kwargs.pop("options") workers = [] exceptions = [] From 8ca56c84b610b69837c519d1ec9047ff83a3a76d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 26 Jun 2025 14:25:05 +0200 Subject: [PATCH 30/47] add some rc unittests --- ultraplot/tests/test_rcsetup.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 ultraplot/tests/test_rcsetup.py diff --git a/ultraplot/tests/test_rcsetup.py b/ultraplot/tests/test_rcsetup.py new file mode 100644 index 00000000..d305e66e --- /dev/null +++ b/ultraplot/tests/test_rcsetup.py @@ -0,0 +1,23 @@ +import ultraplot as uplt, pytest + + +def test_rc_repr(): + """ + Test representation for internal consistency + """ + default = uplt.internals.rcsetup._rc_ultraplot_default + + tmp = uplt.internals.rcsetup._RcParams( + data=default, validate=uplt.internals.rcsetup._rc_ultraplot_validate + ) + s = uplt.rc.rc_ultraplot.__repr__() + ss = uplt.rc.rc_ultraplot.__repr__() + assert s == ss + + +def test_rc_init_invalid_key(): + # If we add a key that does not exist, a key error is raised on init + default = uplt.internals.rcsetup._rc_ultraplot_default.copy() + default["doesnotexist"] = "test" + with pytest.raises(KeyError): + uplt.internals.rcsetup._RcParams(data=default) From 0175f52d2e59a62d6701ec00cf068e2a43f7e143 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 26 Jun 2025 14:37:09 +0200 Subject: [PATCH 31/47] test uplt tight layout settings raises warning --- ultraplot/config.py | 4 +++- ultraplot/tests/test_rcsetup.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index fe61fd07..76f2844c 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -863,6 +863,9 @@ def __setitem__(self, key, value): Modify an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation (e.g., ``uplt.rc[name] = value``). """ + key, value = self._validate_key( + key, value + ) # might issue ultraplot removed/renamed error kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) @@ -929,7 +932,6 @@ def __exit__(self, *args): # noqa: U100 context = self._context[-1] for key, value in context.rc_old.items(): kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) - self.rc_ultraplot.update(kw_ultraplot) self.rc_matplotlib.update(kw_matplotlib) del self._context[-1] diff --git a/ultraplot/tests/test_rcsetup.py b/ultraplot/tests/test_rcsetup.py index d305e66e..d6d857d6 100644 --- a/ultraplot/tests/test_rcsetup.py +++ b/ultraplot/tests/test_rcsetup.py @@ -21,3 +21,19 @@ def test_rc_init_invalid_key(): default["doesnotexist"] = "test" with pytest.raises(KeyError): uplt.internals.rcsetup._RcParams(data=default) + + +def test_tight_layout_warnings(): + """ + Tight layout is disabled in ultraplot as we provide our own layout engine. Setting these values should raise a warning. + """ + with pytest.warns(uplt.warnings.UltraPlotWarning) as record: + fig, ax = uplt.subplots(tight_layout=True) + uplt.close(fig) + fig, ax = uplt.subplots(constrained_layout=True) + uplt.close(fig) + # need to check why the number of errors are much larger + # than 2 + assert ( + len(record) >= 2 + ), f"Expected two warnings for tight layout settings, got {len(record)}" From deb4c805244ee0e04b59d7c7656dedc29f9ea884 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 26 Jun 2025 14:40:19 +0200 Subject: [PATCH 32/47] add explicit validator to unittest --- ultraplot/tests/test_rcsetup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ultraplot/tests/test_rcsetup.py b/ultraplot/tests/test_rcsetup.py index d6d857d6..6c36edf3 100644 --- a/ultraplot/tests/test_rcsetup.py +++ b/ultraplot/tests/test_rcsetup.py @@ -20,7 +20,9 @@ def test_rc_init_invalid_key(): default = uplt.internals.rcsetup._rc_ultraplot_default.copy() default["doesnotexist"] = "test" with pytest.raises(KeyError): - uplt.internals.rcsetup._RcParams(data=default) + uplt.internals.rcsetup._RcParams( + data=default, validate=uplt.internals.rcsetup._rc_ultraplot_validate + ) def test_tight_layout_warnings(): From 799b62cf658cc44db9c38e18f4837edc199f6f72 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 26 Jun 2025 16:39:54 +0200 Subject: [PATCH 33/47] add context modfiications on single thread --- ultraplot/tests/test_config.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index 6950ec16..f5d9246f 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -17,3 +17,29 @@ def test_wrong_keyword_reset(): fig, ax = uplt.subplots(proj="cyl") ax.format(coastcolor="black") fig.canvas.draw() + + +def test_configurator_update_and_reset(): + """ + Test updating a configuration key and resetting configuration. + """ + config = uplt.rc + # Update a configuration key + config["coastcolor"] = "red" + assert config["coastcolor"] == "red" + # Reset configuration; after reset the key should not remain as "red" + config.reset() + assert config["coastcolor"] != "red" + + +def test_context_manager_local_changes(): + """ + Test that changes made in a local context do not persist globally. + """ + config = uplt.rc + # Save original value if present, else None + original = config["coastcolor"] if "coastcolor" in config else None + with config.context(coastcolor="blue"): + assert config["coastcolor"] == "blue" + # After the context, the change should be reverted to original + assert config["coastcolor"] == original From ff0f98b0a57bca04787811e3ebe09a36cea89c8b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 08:41:33 +0200 Subject: [PATCH 34/47] emulate load with sleep --- ultraplot/tests/test_thread_safety.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index cbb0d079..f85150ae 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -1,10 +1,12 @@ import ultraplot as uplt, threading, pytest, warnings +import time, random def modify_rc_on_thread(prop: str, value=None, with_context=True): """ Apply arbitrary rc parameters in a thread-safe manner. """ + time.sleep(random.uniform(0, 0.001)) if with_context: with uplt.rc.context(**{prop: value}): assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" From 8d48b50fdd0d1acadcd6727bac45157e863edc2d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 08:42:01 +0200 Subject: [PATCH 35/47] adjust sleep timing --- ultraplot/tests/test_thread_safety.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index f85150ae..4f5a9735 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -6,7 +6,7 @@ def modify_rc_on_thread(prop: str, value=None, with_context=True): """ Apply arbitrary rc parameters in a thread-safe manner. """ - time.sleep(random.uniform(0, 0.001)) + time.sleep(random.uniform(0, 0.01)) if with_context: with uplt.rc.context(**{prop: value}): assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" From 41e03e375b8c35772a59a784f4d5abae15d7dc08 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 08:51:31 +0200 Subject: [PATCH 36/47] improve thread safety test --- ultraplot/tests/test_thread_safety.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index 4f5a9735..4790bb14 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -6,7 +6,7 @@ def modify_rc_on_thread(prop: str, value=None, with_context=True): """ Apply arbitrary rc parameters in a thread-safe manner. """ - time.sleep(random.uniform(0, 0.01)) + time.sleep(random.uniform(0, 0.001)) if with_context: with uplt.rc.context(**{prop: value}): assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" @@ -20,8 +20,11 @@ def _spawn_and_run_threads(func, n=100, **kwargs): workers = [] exceptions = [] + start_barrier = threading.Barrier(n) + def wrapped_func(**kw): try: + start_barrier.wait() func(**kw) except Exception as e: exceptions.append(e) @@ -66,6 +69,11 @@ def test_setting_rc(prop, options, with_context): options=options, with_context=with_context, ) - assert ( - uplt.rc[prop] == value - ), f"Failed to reset {value=} after threads finished, {uplt.rc[prop]=}." + if with_context: + assert ( + uplt.rc[prop] == value + ), f"Failed {with_context=} to reset {value=} after threads finished, {uplt.rc[prop]=}." + else: + # without a context, the value should assume + # the last value set by the threads + uplt.rc[prop] != value From 7cece9095ff8030e0f855ade19c2ac6d9d74ad0d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 09:03:00 +0200 Subject: [PATCH 37/47] remove keyerror in matplotlib and update tests to prevent tight layout or constrained layout from working --- ultraplot/figure.py | 8 +------- ultraplot/tests/test_figure.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 0361a3e4..ad7f9c0f 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -714,21 +714,15 @@ def __init__( warnings._warn_ultraplot( "Setting rc['figure.autolayout'] to False. " + self._tight_message ) + rc.rc_matplotlib["figure.autolayout"] = False # this is rcParams if rc.rc_matplotlib.get("figure.constrained_layout.use", False): warnings._warn_ultraplot( "Setting rc['figure.constrained_layout.use'] to False. " + self._tight_message # noqa: E501 ) - try: - rc.rc_matplotlib["figure.autolayout"] = False # this is rcParams - except KeyError: - pass - try: rc.rc_matplotlib["figure.constrained_layout.use"] = ( False # this is rcParams ) - except KeyError: - pass self._tight_active = _not_none(tight, rc["subplots.tight"]) # Translate share settings diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 68d58505..65d8f2f7 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -134,3 +134,32 @@ def test_toggle_input_axis_sharing(): fig = uplt.figure() with pytest.warns(uplt.internals.warnings.UltraPlotWarning): fig._toggle_axis_sharing(which="does not exist") + + +def test_warning_on_constrained_layout(): + """ + Test that a warning is raised when constrained layout is used with shared axes. + """ + with pytest.warns(uplt.internals.warnings.UltraPlotWarning): + fig, ax = uplt.subplots(ncols=2, nrows=2, share="all", constrained_layout=True) + + # This should be unset; we therefore warn + uplt.rc.rc_matplotlib["figure.constrained_layout.use"] = True + with pytest.warns(uplt.internals.warnings.UltraPlotWarning): + fig, ax = uplt.subplots(ncols=2, nrows=2, share="all", constrained_layout=True) + uplt.close(fig) + + +def test_warning_on_tight_layout(): + """ + Test that a warning is raised when tight layout is used with shared axes. + """ + with pytest.warns(uplt.internals.warnings.UltraPlotWarning): + fig, ax = uplt.subplots(ncols=2, nrows=2, share="all", tight_layout=True) + + # This should be unset; we therefore warn + uplt.rc.rc_matplotlib["figure.autolayout"] = True + with pytest.warns(uplt.internals.warnings.UltraPlotWarning): + fig, ax = uplt.subplots(ncols=2, nrows=2, share="all", tight_layout=True) + + uplt.close(fig) From 97696b344facb551c201fc02bc193cf6e7da5133 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 09:16:24 +0200 Subject: [PATCH 38/47] modify test to warn on rc setting --- ultraplot/tests/test_figure.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 65d8f2f7..c9077c02 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -146,7 +146,11 @@ def test_warning_on_constrained_layout(): # This should be unset; we therefore warn uplt.rc.rc_matplotlib["figure.constrained_layout.use"] = True with pytest.warns(uplt.internals.warnings.UltraPlotWarning): - fig, ax = uplt.subplots(ncols=2, nrows=2, share="all", constrained_layout=True) + fig, ax = uplt.subplots( + ncols=2, + nrows=2, + share="all", + ) uplt.close(fig) @@ -160,6 +164,10 @@ def test_warning_on_tight_layout(): # This should be unset; we therefore warn uplt.rc.rc_matplotlib["figure.autolayout"] = True with pytest.warns(uplt.internals.warnings.UltraPlotWarning): - fig, ax = uplt.subplots(ncols=2, nrows=2, share="all", tight_layout=True) + fig, ax = uplt.subplots( + ncols=2, + nrows=2, + share="all", + ) uplt.close(fig) From 41840a850dd0013a695fa1c6396f41ef2fdcb728 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 09:17:05 +0200 Subject: [PATCH 39/47] add another close --- ultraplot/tests/test_figure.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index c9077c02..9e776626 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -142,6 +142,7 @@ def test_warning_on_constrained_layout(): """ with pytest.warns(uplt.internals.warnings.UltraPlotWarning): fig, ax = uplt.subplots(ncols=2, nrows=2, share="all", constrained_layout=True) + uplt.close(fig) # This should be unset; we therefore warn uplt.rc.rc_matplotlib["figure.constrained_layout.use"] = True @@ -160,6 +161,7 @@ def test_warning_on_tight_layout(): """ with pytest.warns(uplt.internals.warnings.UltraPlotWarning): fig, ax = uplt.subplots(ncols=2, nrows=2, share="all", tight_layout=True) + uplt.close(fig) # This should be unset; we therefore warn uplt.rc.rc_matplotlib["figure.autolayout"] = True From 1c463c50e6f268d461b93a2d6c3c27c51ae15dab Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 09:43:11 +0200 Subject: [PATCH 40/47] add lock on warnings --- ultraplot/tests/test_thread_safety.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index 4790bb14..275e041e 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -21,13 +21,16 @@ def _spawn_and_run_threads(func, n=100, **kwargs): exceptions = [] start_barrier = threading.Barrier(n) + exceptions_lock = threading.Lock() def wrapped_func(**kw): try: start_barrier.wait() func(**kw) except Exception as e: - exceptions.append(e) + with exceptions_lock: + # Store the exception in a thread-safe manner + exceptions.append(e) for worker in range(n): kw = kwargs.copy() From ea6cd6a4d52d3c19131a84e38e287c78bd781c12 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 10:06:56 +0200 Subject: [PATCH 41/47] add timeout to check for deadlock --- ultraplot/tests/test_thread_safety.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index 275e041e..38b37661 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -42,7 +42,9 @@ def wrapped_func(**kw): with warnings.catch_warnings(record=True) as record: warnings.simplefilter("always") # catch all warnings for w in workers: - w.join() + w.join(timeout=30.0) + if w.is_alive(): + raise RuntimeError(f"Thread {w.name} did not finish in time, {kwargs=}") if exceptions: raise RuntimeError( From fbe66014b16429ab01c9efb3d6bf0983c5307ea1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 10:24:05 +0200 Subject: [PATCH 42/47] ensure thread safety --- ultraplot/config.py | 17 +++++++++-------- ultraplot/tests/test_thread_safety.py | 5 +++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 76f2844c..a16c6152 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -747,6 +747,7 @@ def __init__(self, local=True, user=True, default=True, **kwargs): super().__setattr__("_initialized", False) self._init(local=local, user=user, default=default, **kwargs) super().__setattr__("_initialized", True) + self._lock = threading.Lock() def _init(self, *, local, user, default, skip_cycle=False): """ @@ -912,14 +913,14 @@ def __enter__(self): except Exception as e: self.__exit__() raise e - - for rc_dict, kw_new in zip( - (self.rc_ultraplot, self.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 + with self._lock: # ensure thread safety + for rc_dict, kw_new in zip( + (self.rc_ultraplot, self.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 """ diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index 38b37661..f31ef089 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -8,8 +8,9 @@ def modify_rc_on_thread(prop: str, value=None, with_context=True): """ time.sleep(random.uniform(0, 0.001)) if with_context: - with uplt.rc.context(**{prop: value}): - assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" + for i in range(10): + with uplt.rc.context(**{prop: value}): + assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" else: uplt.rc[prop] = value assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" From 4aa7c92725ce7543ae01ad6f430335e0049c7fc6 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 10:34:58 +0200 Subject: [PATCH 43/47] unify calls --- ultraplot/config.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index a16c6152..491bece6 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -745,9 +745,9 @@ def __init__(self, local=True, user=True, default=True, **kwargs): # Initialize threading first to avoid recursion issues super().__setattr__("_thread_local", threading.local()) super().__setattr__("_initialized", False) + self._lock = threading.Lock() self._init(local=local, user=user, default=default, **kwargs) super().__setattr__("_initialized", True) - self._lock = threading.Lock() def _init(self, *, local, user, default, skip_cycle=False): """ @@ -800,7 +800,7 @@ def _context(self, value): def _get_thread_local_copy(self, attr, source): if not hasattr(self._thread_local, attr): # Initialize with a copy of the source dictionary - setattr(self._thread_local, attr, source) + setattr(self._thread_local, attr, source.copy()) return getattr(self._thread_local, attr) @property @@ -809,14 +809,9 @@ def rc_matplotlib(self): @property def rc_ultraplot(self): - if not hasattr(self._thread_local, "rc_ultraplot"): - # Initialize with a copy of the default ultraplot settings - # NOTE: skip_validation=True is necessary to avoid warnings - # about deprecated rc parameters. - self._thread_local.rc_ultraplot = rcsetup._rc_ultraplot_default.copy( - skip_validation=True - ) - return self._thread_local.rc_ultraplot + return self._get_thread_local_copy( + "rc_ultraplot", rcsetup._rc_ultraplot_default + ) def __repr__(self): cls = type("rc", (dict,), {}) # temporary class with short name @@ -913,14 +908,14 @@ def __enter__(self): except Exception as e: self.__exit__() raise e - with self._lock: # ensure thread safety - for rc_dict, kw_new in zip( - (self.rc_ultraplot, self.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 + + for rc_dict, kw_new in zip( + (self.rc_ultraplot, self.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 """ From 07677a2cb50bfa2ee4c6bc616f7cf1b75fa0cebc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 10:35:34 +0200 Subject: [PATCH 44/47] rm lock --- ultraplot/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 491bece6..35306cf3 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -745,7 +745,6 @@ def __init__(self, local=True, user=True, default=True, **kwargs): # Initialize threading first to avoid recursion issues super().__setattr__("_thread_local", threading.local()) super().__setattr__("_initialized", False) - self._lock = threading.Lock() self._init(local=local, user=user, default=default, **kwargs) super().__setattr__("_initialized", True) From c38d03fa32f6244d007af5e2c8670178338f0e2b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 10:45:18 +0200 Subject: [PATCH 45/47] simplify check key --- ultraplot/config.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 35306cf3..7b6feac1 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -944,12 +944,7 @@ def _validate_key(self, key, value=None): if "." not in key: key = rcsetup._rc_nodots.get(key, key) # Use the raw thread-local copy of rc_ultraplot instead of the property getter - if not hasattr(self._thread_local, "rc_ultraplot"): - self._thread_local.rc_ultraplot = rcsetup._rc_ultraplot_default.copy( - skip_validation=True - ) - rc_ultraplot = self._thread_local.rc_ultraplot - key, value = rc_ultraplot._check_key(key, value) + key, value = self.rc_ultraplot._check_key(key, value) return key, value def _validate_value(self, key, value): From 42d3c1c4ba5fc5ffc22de686306693030b9f5102 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 10:49:16 +0200 Subject: [PATCH 46/47] rm timeout and reduce workers --- ultraplot/tests/test_thread_safety.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index f31ef089..2cf5106f 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -6,7 +6,6 @@ def modify_rc_on_thread(prop: str, value=None, with_context=True): """ Apply arbitrary rc parameters in a thread-safe manner. """ - time.sleep(random.uniform(0, 0.001)) if with_context: for i in range(10): with uplt.rc.context(**{prop: value}): @@ -16,7 +15,7 @@ def modify_rc_on_thread(prop: str, value=None, with_context=True): assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" -def _spawn_and_run_threads(func, n=100, **kwargs): +def _spawn_and_run_threads(func, n=10, **kwargs): options = kwargs.pop("options") workers = [] exceptions = [] @@ -43,7 +42,7 @@ def wrapped_func(**kw): with warnings.catch_warnings(record=True) as record: warnings.simplefilter("always") # catch all warnings for w in workers: - w.join(timeout=30.0) + w.join() if w.is_alive(): raise RuntimeError(f"Thread {w.name} did not finish in time, {kwargs=}") From 98a5b839fb7e69cc2d865c5e82def68687ea1e3d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 28 Jun 2025 11:32:26 +0200 Subject: [PATCH 47/47] separate plugin source --- environment.yml | 1 + ultraplot/tests/conftest.py | 9 +- ultraplot/tests/mpl_plugin/__init__.py | 30 -- ultraplot/tests/mpl_plugin/cleanup.py | 109 ---- ultraplot/tests/mpl_plugin/core.py | 99 ---- ultraplot/tests/mpl_plugin/progress.py | 80 --- ultraplot/tests/mpl_plugin/reporting.py | 642 ------------------------ ultraplot/tests/mpl_plugin/utils.py | 131 ----- ultraplot/tests/test_thread_safety.py | 2 +- 9 files changed, 7 insertions(+), 1096 deletions(-) delete mode 100644 ultraplot/tests/mpl_plugin/__init__.py delete mode 100644 ultraplot/tests/mpl_plugin/cleanup.py delete mode 100644 ultraplot/tests/mpl_plugin/core.py delete mode 100644 ultraplot/tests/mpl_plugin/progress.py delete mode 100644 ultraplot/tests/mpl_plugin/reporting.py delete mode 100644 ultraplot/tests/mpl_plugin/utils.py diff --git a/environment.yml b/environment.yml index dc35dc6b..04961d4a 100644 --- a/environment.yml +++ b/environment.yml @@ -30,3 +30,4 @@ dependencies: - pyarrow - pip: - git+https://github.com/ultraplot/UltraTheme.git + - git+https://github.com/ultraplot/UltraImageCompare.git diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 165c53e4..29bc10b9 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -19,20 +19,21 @@ from pathlib import Path from datetime import datetime + # Import the modular MPL plugin components -from ultraplot.tests.mpl_plugin import ( +from pytest_ultraimagecompare import ( StoreFailedMplPlugin, ProgressTracker, CleanupManager, HTMLReportGenerator, ) -from ultraplot.tests.mpl_plugin.utils import ( +from pytest_ultraimagecompare.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 +from pytest_ultraimagecompare.progress import get_progress_tracker +from pytest_ultraimagecompare.cleanup import get_cleanup_manager SEED = 51423 diff --git a/ultraplot/tests/mpl_plugin/__init__.py b/ultraplot/tests/mpl_plugin/__init__.py deleted file mode 100644 index e5326d58..00000000 --- a/ultraplot/tests/mpl_plugin/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -MPL Plugin Module for Enhanced Matplotlib Testing - -This module provides enhanced functionality for matplotlib image comparison tests, -including progress tracking, artifact optimization, and HTML report generation. - -The module is structured as follows: -- core.py: Main plugin class and core functionality -- progress.py: Progress bar and visual feedback -- cleanup.py: Deferred cleanup and artifact optimization -- reporting.py: HTML report generation -- utils.py: Utility functions and helpers -""" - -from .core import StoreFailedMplPlugin -from .progress import ProgressTracker -from .cleanup import CleanupManager -from .reporting import HTMLReportGenerator -from .utils import extract_test_name_from_filename, categorize_image_file - -__all__ = [ - "StoreFailedMplPlugin", - "ProgressTracker", - "CleanupManager", - "HTMLReportGenerator", - "extract_test_name_from_filename", - "categorize_image_file", -] - -__version__ = "1.0.0" diff --git a/ultraplot/tests/mpl_plugin/cleanup.py b/ultraplot/tests/mpl_plugin/cleanup.py deleted file mode 100644 index 4947c0ee..00000000 --- a/ultraplot/tests/mpl_plugin/cleanup.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Cleanup management module for matplotlib test artifacts. - -This module provides deferred cleanup functionality to optimize artifact sizes -and eliminate race conditions in parallel test execution. -""" - -import shutil -import threading -from pathlib import Path - - -class CleanupManager: - """Manages deferred cleanup of successful test artifacts.""" - - def __init__(self): - self.pending_cleanups = set() - self.lock = threading.Lock() - - def mark_for_cleanup(self, target_path): - """Mark a directory for cleanup without blocking the worker.""" - with self.lock: - if target_path.exists() and target_path.is_dir(): - self.pending_cleanups.add(target_path) - return True - return False - - def perform_cleanup(self, store_failed_only=False): - """Perform deferred cleanup of all marked directories.""" - if not store_failed_only: - self._handle_no_cleanup() - return - - with self.lock: - cleanup_list = list(self.pending_cleanups) - self.pending_cleanups.clear() - - if cleanup_list: - self._cleanup_directories(cleanup_list) - else: - print("๐Ÿ’พ Perfect optimization: No cleanup needed (all tests failed)") - - def _handle_no_cleanup(self): - """Handle case where cleanup optimization is disabled.""" - with self.lock: - total_items = len(self.pending_cleanups) - self.pending_cleanups.clear() - - if total_items > 0: - print(f"๐Ÿ’พ All {total_items} test images preserved for review") - print(" ๐Ÿ’ก Use --store-failed-only to enable artifact size optimization") - - def _cleanup_directories(self, cleanup_list): - """Clean up the list of directories with progress tracking.""" - print( - f"๐Ÿงน Cleaning up {len(cleanup_list)} successful test directories (--store-failed-only enabled)..." - ) - success_count = 0 - - for i, target in enumerate(cleanup_list, 1): - # Update cleanup progress bar - percentage = int((i / len(cleanup_list)) * 100) - bar_width = 20 - filled_width = int((percentage / 100) * bar_width) - bar = ( - "=" * filled_width - + (">" if filled_width < bar_width else "") - + " " - * (bar_width - filled_width - (1 if filled_width < bar_width else 0)) - ) - - try: - if target.exists() and target.is_dir(): - shutil.rmtree(target) - success_count += 1 - status = "โœ“" - else: - status = "~" - except (FileNotFoundError, OSError, PermissionError): - status = "~" - except Exception as e: - status = "โœ—" - - cleanup_line = f"\rCleanup: [{bar}] {percentage:3d}% ({i}/{len(cleanup_list)}) {status}" - print(cleanup_line, end="", flush=True) - - print() # New line after progress bar - print( - f"โœ… Cleanup completed: {success_count}/{len(cleanup_list)} directories removed" - ) - if success_count < len(cleanup_list): - print( - f" Note: {len(cleanup_list) - success_count} directories were already removed or inaccessible" - ) - print("๐Ÿ’พ Artifact optimization: Only failed tests preserved for debugging") - - def get_pending_count(self): - """Get the number of directories pending cleanup.""" - with self.lock: - return len(self.pending_cleanups) - - -# Global cleanup manager instance -cleanup_manager = CleanupManager() - - -def get_cleanup_manager(): - """Get the global cleanup manager instance.""" - return cleanup_manager diff --git a/ultraplot/tests/mpl_plugin/core.py b/ultraplot/tests/mpl_plugin/core.py deleted file mode 100644 index c09dd2da..00000000 --- a/ultraplot/tests/mpl_plugin/core.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -Core plugin module for enhanced matplotlib testing. - -This module contains the main StoreFailedMplPlugin class that coordinates -all matplotlib test functionality including progress tracking, cleanup management, -and HTML report generation. -""" - -import re -import pytest -from pathlib import Path - -from .progress import get_progress_tracker -from .cleanup import get_cleanup_manager -from .utils import create_nodeid_to_path_mapping, validate_config_paths - - -class StoreFailedMplPlugin: - """ - Main plugin class for enhanced matplotlib image comparison testing. - - This plugin provides: - - Real-time progress tracking with visual progress bars - - Deferred cleanup to eliminate race conditions - - Thread-safe artifact optimization - - Failed test tracking for HTML report generation - """ - - def __init__(self, config): - self.config = config - - # Validate and set up paths - paths = validate_config_paths(config) - self.result_dir = paths["results"] - self.baseline_dir = paths["baseline"] - - # Track failed mpl tests for HTML report generation - self.failed_mpl_tests = set() - - # Get global managers - self.progress_tracker = get_progress_tracker() - self.cleanup_manager = get_cleanup_manager() - - # Only show initialization message if MPL tests will be run - if any("--mpl" in str(arg) for arg in getattr(config, "args", [])): - print(f"Store Failed MPL Plugin initialized") - print(f"Result dir: {self.result_dir}") - - 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) - - def _remove_success(self, report: pytest.TestReport): - """Mark successful test images for deferred cleanup to eliminate blocking.""" - - # Only perform cleanup if --store-failed-only is enabled - if not self.config.getoption("--store-failed-only", False): - return - - # Convert nodeid to filesystem path - name = create_nodeid_to_path_mapping(report.nodeid) - target = (self.result_dir / name).absolute() - - # Mark for deferred cleanup (non-blocking) - if self.cleanup_manager.mark_for_cleanup(target): - print(".", end="", flush=True) - - @pytest.hookimpl(trylast=True) - def pytest_runtest_logreport(self, report): - """Hook that processes each test report.""" - # Track failed mpl tests and handle successful ones - if report.when == "call" and self._has_mpl_marker(report): - try: - # Update progress tracking - if report.outcome == "failed": - self.failed_mpl_tests.add(report.nodeid) - self.progress_tracker.increment_processed(failed=True) - else: - self.progress_tracker.increment_processed(failed=False) - # Mark successful tests for cleanup (if enabled) - self._remove_success(report) - - except Exception as e: - # Log but don't fail on processing errors - print(f"Warning: Error during test processing for {report.nodeid}: {e}") - - def get_failed_tests(self): - """Get the set of failed test nodeids.""" - return self.failed_mpl_tests.copy() - - def get_stats(self): - """Get current test statistics.""" - return self.progress_tracker.get_stats() - - def finalize(self): - """Finalize progress tracking and perform cleanup.""" - self.progress_tracker.finalize_progress() - store_failed_only = self.config.getoption("--store-failed-only", False) - self.cleanup_manager.perform_cleanup(store_failed_only) diff --git a/ultraplot/tests/mpl_plugin/progress.py b/ultraplot/tests/mpl_plugin/progress.py deleted file mode 100644 index be22f275..00000000 --- a/ultraplot/tests/mpl_plugin/progress.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Progress tracking module for matplotlib test execution. - -This module provides real-time progress bars and visual feedback for matplotlib -image comparison tests, including success/failure counters and completion percentages. -""" - -import threading - - -class ProgressTracker: - """Manages progress tracking and visual feedback for matplotlib tests.""" - - def __init__(self): - self.total_tests = 0 - self.processed_tests = 0 - self.failed_tests = 0 - self.lock = threading.Lock() - - def set_total_tests(self, total): - """Set the total number of matplotlib tests expected.""" - with self.lock: - self.total_tests = total - - def increment_processed(self, failed=False): - """Increment the processed test counter.""" - with self.lock: - self.processed_tests += 1 - if failed: - self.failed_tests += 1 - self._update_progress_bar() - - def _update_progress_bar(self): - """Update the progress bar with current test status.""" - if self.total_tests == 0: - return - - percentage = int((self.processed_tests / self.total_tests) * 100) - success_count = self.processed_tests - self.failed_tests - - # Create progress bar: [=========> ] 67% (45/67) | โœ“32 โœ—13 - bar_width = 20 - filled_width = int((percentage / 100) * bar_width) - bar = ( - "=" * filled_width - + (">" if filled_width < bar_width else "") - + " " * (bar_width - filled_width - (1 if filled_width < bar_width else 0)) - ) - - progress_line = f"\rMPL Tests: [{bar}] {percentage:3d}% ({self.processed_tests}/{self.total_tests}) | โœ“{success_count} โœ—{self.failed_tests}" - print(progress_line, end="", flush=True) - - def finalize_progress(self): - """Finalize the progress bar and show summary.""" - print() # New line after progress bar - success_count = self.processed_tests - self.failed_tests - - if self.failed_tests > 0: - print(f"๐Ÿ“Š MPL Summary: {success_count} passed, {self.failed_tests} failed") - else: - print(f"๐Ÿ“Š MPL Summary: All {success_count} tests passed!") - - def get_stats(self): - """Get current test statistics.""" - with self.lock: - return { - "total": self.total_tests, - "processed": self.processed_tests, - "failed": self.failed_tests, - "passed": self.processed_tests - self.failed_tests, - } - - -# Global progress tracker instance -progress_tracker = ProgressTracker() - - -def get_progress_tracker(): - """Get the global progress tracker instance.""" - return progress_tracker diff --git a/ultraplot/tests/mpl_plugin/reporting.py b/ultraplot/tests/mpl_plugin/reporting.py deleted file mode 100644 index 6086da3f..00000000 --- a/ultraplot/tests/mpl_plugin/reporting.py +++ /dev/null @@ -1,642 +0,0 @@ -""" -HTML reporting module for matplotlib test results. - -This module provides comprehensive HTML report generation with interactive features, -including visual comparisons, filtering capabilities, and responsive design. -""" - -import os -import shutil -from pathlib import Path -from datetime import datetime - -from .utils import ( - extract_test_name_from_filename, - categorize_image_file, - get_results_directory, -) - - -class HTMLReportGenerator: - """Generates interactive HTML reports for matplotlib test results.""" - - def __init__(self, config): - self.config = config - self.template_dir = Path(__file__).parent / "templates" - self.results_dir = get_results_directory(config) - # Ensure template directory exists - if not self.template_dir.exists(): - print(f"Warning: Template directory not found: {self.template_dir}") - - def generate_report(self, failed_tests_set): - """Generate the complete HTML report.""" - if not self._should_generate_report(): - return - - print("\nGenerating HTML report for image comparison tests...") - print( - "Note: When using --store-failed-only, only failed tests will be included in the report" - ) - - test_results = self._process_test_results() - if not test_results: - print("No test results found for HTML report generation") - return - - # Generate display names and mark failed tests - self._enhance_test_results(test_results, failed_tests_set) - - # Copy template files to results directory - self._copy_template_assets() - - # Generate HTML content - html_content = self._generate_html_content(test_results) - - # Write the report - report_path = self.results_dir / "index.html" - report_path.parent.mkdir(parents=True, exist_ok=True) - - with open(report_path, "w") as f: - f.write(html_content) - - print(f"HTML report generated at: {report_path}") - print(f"Template directory: {self.template_dir}") - print(f"Results directory: {self.results_dir}") - print("Open the report in a web browser to view the results.") - - def _should_generate_report(self): - """Check if HTML report should be generated.""" - if not self.results_dir.exists(): - print(f"Results directory not found: {self.results_dir}") - return False - return True - - def _copy_template_assets(self): - """Copy CSS and JS files to results directory.""" - try: - # Copy CSS file - css_src = self.template_dir / "styles.css" - css_dst = self.results_dir / "styles.css" - if css_src.exists(): - shutil.copy2(css_src, css_dst) - print(f"Copied CSS to: {css_dst}") - else: - print(f"Warning: CSS template not found at: {css_src}") - - # Copy JS file - js_src = self.template_dir / "scripts.js" - js_dst = self.results_dir / "scripts.js" - if js_src.exists(): - shutil.copy2(js_src, js_dst) - print(f"Copied JS to: {js_dst}") - else: - print(f"Warning: JS template not found at: {js_src}") - except Exception as e: - print(f"Error copying template assets: {e}") - - def _load_template(self, template_name): - """Load a template file.""" - template_path = self.template_dir / template_name - print(f"Attempting to load template: {template_path}") - print(f"Template exists: {template_path.exists()}") - try: - with open(template_path, "r", encoding="utf-8") as f: - content = f.read() - print( - f"Successfully loaded template: {template_path} ({len(content)} chars)" - ) - return content - except FileNotFoundError: - print( - f"Warning: Template {template_name} not found at {template_path}, using fallback" - ) - return None - except Exception as e: - print(f"Error loading template {template_name}: {e}") - return None - - def _process_test_results(self): - """Process test result files and organize by test.""" - test_results = {} - - # Recursively search for all PNG files - for image_file in self.results_dir.rglob("*.png"): - rel_path = image_file.relative_to(self.results_dir) - parent_dir = rel_path.parent if rel_path.parent != Path(".") else None - filename = image_file.name - - # Skip hash files - if "hash" in filename: - continue - - # Handle pytest-mpl directory structure - if parent_dir: - test_name = str(parent_dir) - - if test_name not in test_results: - test_results[test_name] = { - "baseline": None, - "result": None, - "diff": None, - "path": parent_dir, - } - - # Categorize files based on pytest-mpl naming convention - if filename == "baseline.png": - test_results[test_name]["baseline"] = image_file - elif filename == "result.png": - test_results[test_name]["result"] = image_file - elif filename == "result-failed-diff.png": - test_results[test_name]["diff"] = image_file - else: - # Fallback for files in root directory (legacy naming) - test_id = image_file.stem - test_name = extract_test_name_from_filename(filename, test_id) - image_type = categorize_image_file(filename, test_id) - - if test_name not in test_results: - test_results[test_name] = { - "baseline": None, - "result": None, - "diff": None, - "path": parent_dir, - } - - if image_type == "baseline": - test_results[test_name]["baseline"] = image_file - elif image_type == "diff": - test_results[test_name]["diff"] = image_file - elif image_type == "result" and not test_results[test_name]["result"]: - test_results[test_name]["result"] = image_file - - return test_results - - def _enhance_test_results(self, test_results, failed_tests_set): - """Add display names and test status to results.""" - for test_name, data in test_results.items(): - # Generate display name - if data["path"]: - data["display_name"] = test_name.replace("/", ".").replace("\\", ".") - else: - data["display_name"] = test_name - - # Mark as failed if tracked during test execution - data["test_failed"] = any( - any( - pattern in nodeid - for pattern in [ - test_name.replace(".", "::"), - test_name.replace( - "ultraplot.tests.", "ultraplot/tests/" - ).replace(".", "::"), - f"ultraplot/tests/{test_name.split('.')[-2]}.py::{test_name.split('.')[-1]}", - ] - ) - for nodeid in failed_tests_set - ) - - def _generate_html_content(self, test_results): - """Generate the complete HTML content with enhanced inline styling.""" - # Calculate statistics - total_tests = len(test_results) - failed_tests = sum( - 1 - for data in test_results.values() - if data.get("test_failed", False) or data.get("diff") - ) - passed_tests = sum( - 1 - for data in test_results.values() - if data.get("baseline") - and data.get("result") - and not data.get("test_failed", False) - ) - unknown_tests = total_tests - failed_tests - passed_tests - - # Generate test cases HTML - test_cases_html = self._generate_all_test_cases(test_results) - - # Enhanced CSS styling - css_content = """""" - - # Enhanced JavaScript - js_content = """""" - - # Generate timestamp - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - # Build HTML - html_content = f""" - - - - - UltraPlot Image Comparison Report - {css_content} - - -
-
-

UltraPlot Image Comparison Report

-
-
- {total_tests} - Total Tests -
-
- {failed_tests} - Failed -
-
- {passed_tests} - Passed -
-
- {unknown_tests} - Unknown -
-
-
- -
- - - - -
- -
- {test_cases_html} -
- -
Report generated on {timestamp}
-
- {js_content} - -""" - - return html_content - - def _generate_all_test_cases(self, test_results): - """Generate HTML for all test cases.""" - test_cases_html = [] - - # Sort tests by display name - sorted_tests = sorted( - test_results.items(), key=lambda x: x[1].get("display_name", x[0]) - ) - - for test_name, data in sorted_tests: - test_case_html = self._generate_test_case_html(test_name, data) - test_cases_html.append(test_case_html) - - return "\n".join(test_cases_html) - - def _generate_test_case_html(self, test_name, data): - """Generate HTML for a single test case.""" - display_name = data.get("display_name", test_name) - - # Determine test status - if data.get("test_failed", False) or data.get("diff"): - status = "failed" - status_text = "FAILED" - elif ( - data.get("baseline") - and data.get("result") - and not data.get("test_failed", False) - ): - status = "passed" - status_text = "PASSED" - else: - status = "unknown" - status_text = "UNKNOWN" - - # Generate image columns - image_columns = [] - - # Add baseline image column - if data.get("baseline"): - rel_path = data["baseline"].relative_to(self.results_dir) - image_columns.append( - f""" -
-

Baseline (Expected)

- Baseline image -
""" - ) - else: - image_columns.append( - """ -
-

Baseline (Expected)

-
No baseline image
-
""" - ) - - # Add result image column - if data.get("result"): - rel_path = data["result"].relative_to(self.results_dir) - image_columns.append( - f""" -
-

Result (Actual)

- Result image -
""" - ) - else: - image_columns.append( - """ -
-

Result (Actual)

-
No result image
-
""" - ) - - # Add diff image column (only if it exists) - if data.get("diff"): - rel_path = data["diff"].relative_to(self.results_dir) - image_columns.append( - f""" -
-

Difference

- Difference image -
""" - ) - - image_columns_html = "\n".join(image_columns) - - return f""" -
-
-
{display_name}
-
{status_text}
-
-
-
- {image_columns_html} -
-
-
""" - - def _generate_fallback_html(self, test_results): - """Generate fallback HTML if templates are not available.""" - # Calculate statistics - total_tests = len(test_results) - failed_tests = sum( - 1 - for data in test_results.values() - if data.get("test_failed", False) or data.get("diff") - ) - passed_tests = sum( - 1 - for data in test_results.values() - if data.get("baseline") - and data.get("result") - and not data.get("test_failed", False) - ) - unknown_tests = total_tests - failed_tests - passed_tests - - # Try to load external CSS for better styling - css_content = "" - css_template = self._load_template("styles.css") - if css_template: - css_content = f"" - else: - css_content = """""" - - html_parts = [ - "", - "", - "", - " ", - " ", - " UltraPlot Image Comparison Report", - css_content, - "", - "", - "
", - "

UltraPlot Image Comparison Report

", - "
", - f"

Total: {total_tests} Passed: {passed_tests} Failed: {failed_tests} Unknown: {unknown_tests}

", - "
", - "
", - " ", - " ", - " ", - " ", - "
", - ] - - # Add test cases - for test_name, data in sorted(test_results.items()): - html_parts.append(self._generate_test_case_html(test_name, data)) - - # Try to load external JavaScript or use inline fallback - js_content = "" - js_template = self._load_template("scripts.js") - if js_template: - js_content = f"" - else: - js_content = """""" - - # Add footer with JavaScript and timestamp - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - html_parts.extend( - [ - f"
Report generated on {timestamp}
", - "
", - js_content, - "", - "", - ] - ) - - return "\n".join(html_parts) diff --git a/ultraplot/tests/mpl_plugin/utils.py b/ultraplot/tests/mpl_plugin/utils.py deleted file mode 100644 index b38d21df..00000000 --- a/ultraplot/tests/mpl_plugin/utils.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Utility functions for matplotlib test processing. - -This module provides helper functions for file processing, test name extraction, -and other common operations used throughout the MPL plugin. -""" - -import re -from pathlib import Path - - -def extract_test_name_from_filename(filename, test_id): - """Extract test name from various pytest-mpl filename patterns.""" - # Handle different pytest-mpl filename patterns - if filename.endswith("-expected.png"): - return test_id.replace("-expected", "") - elif filename.endswith("-failed-diff.png"): - return test_id.replace("-failed-diff", "") - elif filename.endswith("-result.png"): - return test_id.replace("-result", "") - elif filename.endswith("-actual.png"): - return test_id.replace("-actual", "") - else: - # Remove common result suffixes if present - possible_test_name = test_id - for suffix in ["-result", "-actual", "-diff"]: - if possible_test_name.endswith(suffix): - possible_test_name = possible_test_name.replace(suffix, "") - return possible_test_name - - -def categorize_image_file(filename, test_id): - """Categorize an image file based on its filename pattern.""" - if filename.endswith("-expected.png"): - return "baseline" - elif filename.endswith("-failed-diff.png"): - return "diff" - elif filename.endswith("-result.png") or filename.endswith("-actual.png"): - return "result" - else: - # Default assumption for uncategorized files - return "result" - - -def get_results_directory(config): - """Get the results directory path from config.""" - results_path = ( - getattr(config.option, "mpl_results_path", None) - or getattr(config, "_mpl_results_path", None) - or "./mpl-results" - ) - return Path(results_path) - - -def should_generate_html_report(config): - """Determine if HTML report should be generated.""" - # Check if matplotlib comparison tests are being used - if hasattr(config.option, "mpl_results_path"): - return True - if hasattr(config, "_mpl_results_path"): - return True - # Check if any mpl_image_compare markers were collected - if hasattr(config, "_mpl_image_compare_found"): - return True - return False - - -def get_failed_mpl_tests(config): - """Get set of failed mpl test nodeids from the plugin.""" - # Look for our plugin instance - for plugin in config.pluginmanager.get_plugins(): - if hasattr(plugin, "failed_mpl_tests"): - return plugin.failed_mpl_tests - return set() - - -def create_nodeid_to_path_mapping(nodeid): - """Convert pytest nodeid to filesystem path pattern.""" - pattern = r"(?P::|/)|\[|\]|\.py" - name = re.sub( - pattern, - lambda m: "." if m.group("sep") else "_" if m.group(0) == "[" else "", - nodeid, - ) - return name - - -def safe_path_conversion(path_input): - """Safely convert path input to Path object, handling None values.""" - if path_input is None: - return None - return Path(path_input) - - -def count_mpl_tests(items): - """Count the number of matplotlib image comparison tests in the item list.""" - return sum( - 1 - for item in items - if any(mark.name == "mpl_image_compare" for mark in item.own_markers) - ) - - -def is_mpl_test(item): - """Check if a test item is a matplotlib image comparison test.""" - return any(mark.name == "mpl_image_compare" for mark in item.own_markers) - - -def format_file_size(size_bytes): - """Format file size in human-readable format.""" - if size_bytes == 0: - return "0 bytes" - - size_names = ["bytes", "KB", "MB", "GB"] - i = 0 - while size_bytes >= 1024 and i < len(size_names) - 1: - size_bytes /= 1024.0 - i += 1 - - return f"{size_bytes:.1f} {size_names[i]}" - - -def validate_config_paths(config): - """Validate and normalize configuration paths.""" - results_path = config.getoption("--mpl-results-path", None) or "./results" - baseline_path = config.getoption("--mpl-baseline-path", None) or "./baseline" - - return { - "results": Path(results_path), - "baseline": Path(baseline_path), - } diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py index 2cf5106f..9ba3c0b1 100644 --- a/ultraplot/tests/test_thread_safety.py +++ b/ultraplot/tests/test_thread_safety.py @@ -15,7 +15,7 @@ def modify_rc_on_thread(prop: str, value=None, with_context=True): assert uplt.rc[prop] == value, f"Thread {id} failed to set rc params" -def _spawn_and_run_threads(func, n=10, **kwargs): +def _spawn_and_run_threads(func, n=100, **kwargs): options = kwargs.pop("options") workers = [] exceptions = []