diff --git a/environment.yml b/environment.yml index dc35dc6b1..04961d4a5 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/__init__.py b/ultraplot/__init__.py index 87da7d8b5..798e072f1 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 dc0458d6d..7b6feac1e 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", @@ -375,7 +373,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 \ `__ @@ -735,19 +733,104 @@ class Configurator(MutableMapping, dict): on import. See the :ref:`user guide ` for details. """ + @docstring._snippet_manager + def __init__(self, local=True, user=True, default=True, **kwargs): + """ + Parameters + ---------- + %(rc.params)s + """ + import threading + + # 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 - 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 + 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): + return self._get_thread_local_copy("_context", []) + + @_context.setter + def _context(self, 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.copy()) + return getattr(self._thread_local, attr) + + @property + def rc_matplotlib(self): + return self._get_thread_local_copy("rc_matplotlib", mpl.rcParams) + + @property + 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 rc_ultraplot.items() if "." not in key}) - return type(rc_matplotlib).__repr__(src).strip()[:-1] + ",\n ...\n })" + 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 rc_ultraplot.items() if "." not in key}) - return type(rc_matplotlib).__str__(src) + "\n..." + 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 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))) @@ -758,16 +841,6 @@ def __delitem__(self, key): # noqa: U100 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): - """ - Parameters - ---------- - %(rc.params)s - """ - self._context = [] - self._init(local=local, user=user, default=default, **kwargs) - def __getitem__(self, key): """ Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation @@ -775,19 +848,22 @@ 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): """ 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) - 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): """ @@ -804,10 +880,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): """ @@ -829,7 +909,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(): @@ -847,47 +927,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) - 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. - """ - # 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): + def _validate_key(self, key, value=None): """ Validate setting names and handle `rc_ultraplot` deprecations. """ @@ -899,13 +943,11 @@ 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 - ) # may issue deprecation warning + # Use the raw thread-local copy of rc_ultraplot instead of the property getter + key, value = self.rc_ultraplot._check_key(key, value) 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. """ @@ -917,11 +959,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 @@ -935,9 +977,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: @@ -973,16 +1015,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"): @@ -1013,14 +1055,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 } ) @@ -1029,9 +1071,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 @@ -1040,9 +1082,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 @@ -1051,9 +1093,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 @@ -1812,13 +1854,6 @@ 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. diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 645ac79a9..ad7f9c0ff 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,23 +710,19 @@ 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): + 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_matplotlib["figure.autolayout"] = False # this is rcParams - except KeyError: - pass - try: - rc_matplotlib["figure.constrained_layout.use"] = False # this is rcParams - except KeyError: - pass + rc.rc_matplotlib["figure.constrained_layout.use"] = ( + False # this is rcParams + ) self._tight_active = _not_none(tight, rc["subplots.tight"]) # Translate share settings diff --git a/ultraplot/internals/__init__.py b/ultraplot/internals/__init__.py index 7a7ea9381..c4b1d932a 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 e719d1f96..01fc146c1 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -528,10 +528,14 @@ 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): - self._validate = validate - for key, value in source.items(): - self.__setitem__(key, value) # trigger validation + + def __init__(self, data=None, validate=None): + self._validate = validate or {} + 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): return RcParams.__repr__(self) @@ -553,14 +557,7 @@ def __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: - dict.__setitem__(self, key, value) + dict.__setitem__(self, key, value) @staticmethod def _check_key(key, value=None): @@ -587,9 +584,12 @@ 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) + def copy(self, skip_validation=False): + new = _RcParams( + data=dict(self), + validate=self._validate, + ) + return new # Borrow validators from matplotlib and construct some new ones diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 0a76ac245..29bc10b91 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -1,6 +1,39 @@ +""" +Conftest.py for UltraPlot testing with modular MPL plugin architecture. + +This file provides essential test fixtures and integrates the enhanced matplotlib +testing functionality through a clean, modular plugin system. + +Thread-Safe Random Number Generation: +- Provides explicit RNG fixtures for test functions that need random numbers +- Each thread gets independent, deterministic RNG instances +- Compatible with pytest-xdist parallel execution +- Clean separation of concerns - tests explicitly declare RNG dependencies +""" + +import threading, os, shutil, pytest, re +import numpy as np, ultraplot as uplt +import warnings, logging + import os, shutil, pytest, re, numpy as np, ultraplot as uplt from pathlib import Path -import warnings, logging +from datetime import datetime + + +# Import the modular MPL plugin components +from pytest_ultraimagecompare import ( + StoreFailedMplPlugin, + ProgressTracker, + CleanupManager, + HTMLReportGenerator, +) +from pytest_ultraimagecompare.utils import ( + count_mpl_tests, + should_generate_html_report, + get_failed_mpl_tests, +) +from pytest_ultraimagecompare.progress import get_progress_tracker +from pytest_ultraimagecompare.cleanup import get_cleanup_manager SEED = 51423 @@ -8,64 +41,67 @@ @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) +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 + uplt.rc.reset() + return np.random.default_rng(SEED) @pytest.fixture(autouse=True) def close_figures_after_test(): 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): @@ -77,10 +113,89 @@ 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) + - 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) + + 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: - 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) diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index 84debe9cc..899c5ee80 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -575,6 +575,7 @@ def test_networks(rng): inax = ax.inset_axes([*pos, 0.2, 0.2], zoom=0) layout_kw = {} if layout in ("random", "arf", "forceatlas2"): + layout_kw = dict(seed=SEED) inax.graph( diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index 6950ec16c..f5d9246f5 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 diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 68d58505c..9e7766269 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -134,3 +134,42 @@ 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) + uplt.close(fig) + + # 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", + ) + 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) + uplt.close(fig) + + # 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", + ) + + uplt.close(fig) diff --git a/ultraplot/tests/test_rcsetup.py b/ultraplot/tests/test_rcsetup.py new file mode 100644 index 000000000..6c36edf3f --- /dev/null +++ b/ultraplot/tests/test_rcsetup.py @@ -0,0 +1,41 @@ +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, validate=uplt.internals.rcsetup._rc_ultraplot_validate + ) + + +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)}" diff --git a/ultraplot/tests/test_thread_safety.py b/ultraplot/tests/test_thread_safety.py new file mode 100644 index 000000000..9ba3c0b12 --- /dev/null +++ b/ultraplot/tests/test_thread_safety.py @@ -0,0 +1,84 @@ +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. + """ + if with_context: + 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" + + +def _spawn_and_run_threads(func, n=100, **kwargs): + options = kwargs.pop("options") + workers = [] + exceptions = [] + + start_barrier = threading.Barrier(n) + exceptions_lock = threading.Lock() + + def wrapped_func(**kw): + try: + start_barrier.wait() + func(**kw) + except Exception as e: + with exceptions_lock: + # Store the exception in a thread-safe manner + 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 w.is_alive(): + raise RuntimeError(f"Thread {w.name} did not finish in time, {kwargs=}") + + if exceptions: + raise RuntimeError( + f"Thread raised exception: {exceptions[0]} with {kwargs=}" + ) from exceptions[0] + + if record: + raise RuntimeError("Thread raised a warning") + + +@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_rc(prop, options, with_context): + """ + Test the thread safety of a context setting + """ + value = uplt.rc[prop] + _spawn_and_run_threads( + modify_rc_on_thread, + prop=prop, + options=options, + with_context=with_context, + ) + 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