diff --git a/docs/configuration.rst b/docs/configuration.rst index 16974ba30..024ac5eb1 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -19,6 +19,10 @@ stored in :obj:`~ultraplot.config.rc_matplotlib` and :ref:`ultraplot settings ` stored in :obj:`~ultraplot.config.rc_ultraplot`. +Both :obj:`~ultraplot.config.rc_matplotlib` and :obj:`~ultraplot.config.rc_ultraplot` +are **thread-safe** and support **thread-local isolation** via context managers. +See :ref:`thread-safety and context managers ` for details. + To change global settings on-the-fly, simply update :obj:`~ultraplot.config.rc` using either dot notation or as you would any other dictionary: @@ -51,6 +55,85 @@ to the :func:`~ultraplot.config.Configurator.context` command: with uplt.rc.context({'name1': value1, 'name2': value2}): fig, ax = uplt.subplots() +See :ref:`thread-safety and context managers ` for important +information about thread-local isolation and parallel testing. + +.. _ug_rcthreadsafe: + +Thread-safety and context managers +----------------------------------- + +Both :obj:`~ultraplot.config.rc_matplotlib` and :obj:`~ultraplot.config.rc_ultraplot` +are **thread-safe** and support **thread-local isolation** through context managers. +This is particularly useful for parallel testing or multi-threaded applications. + +**Global changes** (outside context managers) are persistent and visible to all threads: + +.. code-block:: python + + import ultraplot as uplt + + # Global change - persists and affects all threads + uplt.rc['font.size'] = 12 + uplt.rc_matplotlib['axes.grid'] = True + +**Thread-local changes** (inside context managers) are isolated and temporary: + +.. code-block:: python + + import ultraplot as uplt + + original_size = uplt.rc['font.size'] # e.g., 10 + + with uplt.rc_matplotlib: + # This change is ONLY visible in the current thread + uplt.rc_matplotlib['font.size'] = 20 + print(uplt.rc_matplotlib['font.size']) # 20 + + # After exiting context, change is discarded + print(uplt.rc_matplotlib['font.size']) # 10 (back to original) + +This is especially useful for **parallel test execution**, where each test thread +can modify settings without affecting other threads or the main thread: + +.. code-block:: python + + import threading + import ultraplot as uplt + + def test_worker(thread_id): + """Each thread can have isolated settings.""" + with uplt.rc_matplotlib: + # Thread-specific settings + uplt.rc_matplotlib['font.size'] = 10 + thread_id * 2 + uplt.rc['axes.grid'] = True + + # Create plots, run tests, etc. + fig, ax = uplt.subplots() + # ... + + # Settings automatically restored after context exit + + # Run tests in parallel - no interference between threads + threads = [threading.Thread(target=test_worker, args=(i,)) for i in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + +**Key points:** + +* Changes **outside** a context manager are **global and persistent** +* Changes **inside** a context manager (``with rc:`` or ``with rc_matplotlib:``) are **thread-local and temporary** +* Thread-local changes are automatically discarded when the context exits +* Each thread sees its own isolated copy of settings within a context +* This works for both :obj:`~ultraplot.config.rc`, :obj:`~ultraplot.config.rc_matplotlib`, and :obj:`~ultraplot.config.rc_ultraplot` + +.. note:: + + A complete working example demonstrating thread-safe configuration usage + can be found in ``docs/thread_safety_example.py``. + In all of these examples, if the setting name contains dots, you can simply omit the dots. For example, to change the diff --git a/docs/thread_safety_example.py b/docs/thread_safety_example.py new file mode 100644 index 000000000..5b4a7682d --- /dev/null +++ b/docs/thread_safety_example.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +Thread-Safe Configuration +========================== + +This example demonstrates the thread-safe behavior of UltraPlot's +configuration system, showing how settings can be isolated per-thread +using context managers. +""" + +import threading +import time + +import ultraplot as uplt + +# %% +# Global vs Thread-Local Changes +# ------------------------------- +# Changes outside a context manager are global and persistent. +# Changes inside a context manager are thread-local and temporary. + +# Store original font size +original_size = uplt.rc["font.size"] +print(f"Original font size: {original_size}") + +# Global change (persistent) +uplt.rc["font.size"] = 12 +print(f"After global change: {uplt.rc['font.size']}") + +# Thread-local change (temporary) +with uplt.rc_matplotlib: + uplt.rc_matplotlib["font.size"] = 20 + print(f"Inside context: {uplt.rc_matplotlib['font.size']}") + +# After context, reverts to previous value +print(f"After context: {uplt.rc_matplotlib['font.size']}") + +# Restore original +uplt.rc["font.size"] = original_size + +# %% +# Parallel Thread Testing +# ------------------------ +# Each thread can have its own isolated settings when using context managers. + + +def create_plot_in_thread(thread_id, results): + """Create a plot with thread-specific settings.""" + with uplt.rc_matplotlib: + # Each thread uses different settings + thread_font_size = 8 + thread_id * 2 + uplt.rc_matplotlib["font.size"] = thread_font_size + uplt.rc["axes.grid"] = thread_id % 2 == 0 # Grid on/off alternating + + # Verify settings are isolated + actual_size = uplt.rc_matplotlib["font.size"] + results[thread_id] = { + "expected": thread_font_size, + "actual": actual_size, + "isolated": (actual_size == thread_font_size), + } + + # Small delay to increase chance of interference if not thread-safe + time.sleep(0.1) + + # Create a simple plot + fig, ax = uplt.subplots(figsize=(3, 2)) + ax.plot([1, 2, 3], [1, 2, 3]) + ax.format( + title=f"Thread {thread_id}", + xlabel="x", + ylabel="y", + ) + uplt.close(fig) # Clean up + + # After context, settings are restored + print(f"Thread {thread_id}: Settings isolated = {results[thread_id]['isolated']}") + + +# Run threads in parallel +results = {} +threads = [ + threading.Thread(target=create_plot_in_thread, args=(i, results)) for i in range(5) +] + +print("\nRunning parallel threads with isolated settings...") +for t in threads: + t.start() +for t in threads: + t.join() + +# Verify all threads had isolated settings +all_isolated = all(r["isolated"] for r in results.values()) +print(f"\nAll threads had isolated settings: {all_isolated}") + +# %% +# Use Case: Parallel Testing +# --------------------------- +# This is particularly useful for running tests in parallel where each +# test needs different matplotlib/ultraplot settings. + + +def run_test_with_settings(test_id, settings): + """Run a test with specific settings.""" + with uplt.rc_matplotlib: + # Apply test-specific settings + uplt.rc.update(settings) + + # Run test code + fig, axs = uplt.subplots(ncols=2, figsize=(6, 2)) + axs[0].plot([1, 2, 3], [1, 4, 2]) + axs[1].scatter([1, 2, 3], [2, 1, 3]) + axs.format(suptitle=f"Test {test_id}") + + # Verify settings + print(f"Test {test_id}: font.size = {uplt.rc['font.size']}") + + uplt.close(fig) # Clean up + + +# Different tests with different settings +test_settings = [ + {"font.size": 10, "axes.grid": True}, + {"font.size": 14, "axes.grid": False}, + {"font.size": 12, "axes.titleweight": "bold"}, +] + +print("\nRunning parallel tests with different settings...") +test_threads = [ + threading.Thread(target=run_test_with_settings, args=(i, settings)) + for i, settings in enumerate(test_settings) +] + +for t in test_threads: + t.start() +for t in test_threads: + t.join() + +print("\nAll tests completed without interference!") + +# %% +# Important Notes +# --------------- +# 1. Changes outside context managers are global and affect all threads +# 2. Changes inside context managers are thread-local and temporary +# 3. Context managers automatically clean up when exiting +# 4. This works for rc, rc_matplotlib, and rc_ultraplot +# 5. Perfect for parallel test execution and multi-threaded applications diff --git a/ultraplot/config.py b/ultraplot/config.py index 388285bcc..9a1802ea0 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -14,10 +14,11 @@ import os import re import sys +import threading from collections import namedtuple from collections.abc import MutableMapping from numbers import Real - +from typing import Any, Callable, Dict import cycler import matplotlib as mpl @@ -27,9 +28,7 @@ import matplotlib.style.core as mstyle import numpy as np from matplotlib import RcParams -from typing import Callable, Any, Dict -from .internals import ic # noqa: F401 from .internals import ( _not_none, _pop_kwargs, @@ -37,6 +36,7 @@ _translate_grid, _version_mpl, docstring, + ic, # noqa: F401 rcsetup, warnings, ) @@ -1842,9 +1842,134 @@ def changed(self): _init_user_folders() _init_user_file() + +class _ThreadSafeRcParams(MutableMapping): + """ + Thread-safe wrapper for matplotlib.rcParams with thread-local isolation support. + + This wrapper ensures that matplotlib's rcParams can be safely accessed from + multiple threads, and provides thread-local isolation when used as a context manager. + + Thread-local isolation: + - Inside a context manager (`with rc_matplotlib:`), changes are isolated to the current thread + - These changes do not affect the global rc_matplotlib or other threads + - When the context exits, thread-local changes are discarded + - Outside a context manager, changes are global and persistent + + Example + ------- + >>> # Global change (persistent) + >>> rc_matplotlib['font.size'] = 12 + + >>> # Thread-local change (temporary, isolated) + >>> with rc_matplotlib: + ... rc_matplotlib['font.size'] = 20 # Only visible in this thread + ... print(rc_matplotlib['font.size']) # 20 + >>> print(rc_matplotlib['font.size']) # 12 (thread-local change discarded) + """ + + def __init__(self, rcparams): + self._rcparams = rcparams + self._lock = threading.RLock() + self._local = threading.local() + + def __enter__(self): + """Context manager entry - initialize thread-local storage.""" + if not hasattr(self._local, "changes"): + self._local.changes = {} + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - clean up thread-local storage.""" + if hasattr(self._local, "changes"): + del self._local.changes + + def __repr__(self): + with self._lock: + return repr(self._rcparams) + + def __str__(self): + with self._lock: + return str(self._rcparams) + + def __len__(self): + with self._lock: + return len(self._rcparams) + + def __iter__(self): + with self._lock: + return iter(self._rcparams) + + def __getitem__(self, key): + with self._lock: + # Check thread-local storage first (if in a context) + if hasattr(self._local, "changes") and key in self._local.changes: + return self._local.changes[key] + # Check global rcParams + return self._rcparams[key] + + def __setitem__(self, key, value): + with self._lock: + # If in a context (thread-local storage exists), store there only + # Otherwise, store in the main rcParams (global, persistent) + if hasattr(self._local, "changes"): + self._local.changes[key] = value + else: + self._rcparams[key] = value + + def __delitem__(self, key): + with self._lock: + if hasattr(self._local, "changes") and key in self._local.changes: + del self._local.changes[key] + else: + del self._rcparams[key] + + def __contains__(self, key): + with self._lock: + if hasattr(self._local, "changes") and key in self._local.changes: + return True + return key in self._rcparams + + def keys(self): + with self._lock: + return self._rcparams.keys() + + def values(self): + with self._lock: + return self._rcparams.values() + + def items(self): + with self._lock: + return self._rcparams.items() + + def get(self, key, default=None): + with self._lock: + if hasattr(self._local, "changes") and key in self._local.changes: + return self._local.changes[key] + return self._rcparams.get(key, default) + + def copy(self): + with self._lock: + result = self._rcparams.copy() + # Add thread-local changes (if in a context) + if hasattr(self._local, "changes"): + result.update(self._local.changes) + return result + + def update(self, *args, **kwargs): + with self._lock: + if hasattr(self._local, "changes"): + # In a context - update thread-local storage + self._local.changes.update(*args, **kwargs) + else: + # Outside context - update global rcParams + self._rcparams.update(*args, **kwargs) + + #: A dictionary-like container of matplotlib settings. Assignments are #: validated and restricted to recognized setting names. -rc_matplotlib = mpl.rcParams # PEP8 4 lyfe +#: This is a thread-safe wrapper around matplotlib.rcParams with thread-local isolation. +rc_matplotlib = _ThreadSafeRcParams(mpl.rcParams) #: A dictionary-like container of ultraplot settings. Assignments are #: validated and restricted to recognized setting names. diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 7439f35cf..3455fee4a 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -3,10 +3,12 @@ Utilities for global configuration. """ import functools -import re, matplotlib as mpl +import re +import threading from collections.abc import MutableMapping from numbers import Integral, Real +import matplotlib as mpl import matplotlib.rcsetup as msetup import numpy as np from cycler import Cycler @@ -20,8 +22,10 @@ else: from matplotlib.fontconfig_pattern import parse_fontconfig_pattern -from . import ic # noqa: F401 -from . import warnings +from . import ( + ic, # noqa: F401 + warnings, +) from .versions import _version_mpl # Regex for "probable" unregistered named colors. Try to retain warning message for @@ -562,16 +566,53 @@ def _yaml_table(rcdict, comment=True, description=False): class _RcParams(MutableMapping, dict): """ - A simple dictionary with locked inputs and validated assignments. + A thread-safe dictionary with validated assignments and thread-local storage used to store the configuration of UltraPlot. + + It uses reentrant locks (RLock) to ensure that multiple threads can safely read and write to the configuration without causing data corruption. + + Thread-local isolation: + - Inside a context manager (`with rc_params:`), changes are isolated to the current thread + - These changes do not affect the global rc_params or other threads + - When the context exits, thread-local changes are discarded + - Outside a context manager, changes are global and persistent + + Example + ------- + >>> # Global change (persistent) + >>> rc_params['key'] = 'global_value' + + >>> # Thread-local change (temporary, isolated) + >>> with rc_params: + ... rc_params['key'] = 'thread_local_value' # Only visible in this thread + ... print(rc_params['key']) # 'thread_local_value' + >>> print(rc_params['key']) # 'global_value' (thread-local change discarded) """ # NOTE: By omitting __delitem__ in MutableMapping we effectively # disable mutability. Also disables deleting items with pop(). def __init__(self, source, validate): self._validate = validate + self._lock = threading.RLock() + self._local = threading.local() + # Don't initialize changes here - it will be created in __enter__ when needed + # Register all initial keys in the validation dictionary + for key in source: + if key not in validate: + validate[key] = lambda x: x # Default validator for key, value in source.items(): self.__setitem__(key, value) # trigger validation + def __enter__(self): + """Context manager entry - initialize thread-local storage if needed.""" + if not hasattr(self._local, "changes"): + self._local.changes = {} + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - clean up thread-local storage.""" + if hasattr(self._local, "changes"): + del self._local.changes + def __repr__(self): return RcParams.__repr__(self) @@ -587,22 +628,36 @@ def __iter__(self): yield from sorted(dict.__iter__(self)) def __getitem__(self, key): - key, _ = self._check_key(key) - return dict.__getitem__(self, key) + with self._lock: + key, _ = self._check_key(key) + # Check thread-local storage first (if in a context) + if hasattr(self._local, "changes") and key in self._local.changes: + return self._local.changes[key] + # Check global dictionary (will raise KeyError if not found) + 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: - dict.__setitem__(self, key, value) - - @staticmethod - def _check_key(key, value=None): + with self._lock: + key, value = self._check_key(key, value) + # Validate the value + try: + value = self._validate[key](value) + except KeyError: + # If key doesn't exist in validation, add it with default validator + self._validate[key] = lambda x: x + # Re-validate with new validator + value = self._validate[key](value) + except (ValueError, TypeError) as error: + raise ValueError(f"Key {key}: {error}") from None + if key is not None: + # If in a context (thread-local storage exists), store there only + # Otherwise, store in the main dictionary (global, persistent) + if hasattr(self._local, "changes"): + self._local.changes[key] = value + else: + dict.__setitem__(self, key, value) + + def _check_key(self, key, value=None): # NOTE: If we assigned from the Configurator then the deprecated key will # still propagate to the same 'children' as the new key. # NOTE: This also translates values for special cases of renamed keys. @@ -624,10 +679,21 @@ def _check_key(key, value=None): f"The rc setting {key!r} was removed in version {version}." + (info and " " + info) ) + # Register new keys in the validation dictionary + if key not in self._validate: + self._validate[key] = lambda x: x # Default validator return key, value def copy(self): - source = {key: dict.__getitem__(self, key) for key in self} + with self._lock: + # Create a copy that includes both global and thread-local changes + source = {} + # Start with global values + for key in self: + source[key] = dict.__getitem__(self, key) + # Add thread-local changes (if in a context) + if hasattr(self._local, "changes"): + source.update(self._local.changes) return _RcParams(source, self._validate) diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index 11a308b56..d7029feeb 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -1,42 +1,44 @@ -import ultraplot as uplt, pytest import importlib +import threading +import time + +import pytest + +import ultraplot as uplt def test_wrong_keyword_reset(): """ The context should reset after a failed attempt. """ - # Init context - uplt.rc.context() - config = uplt.rc - # Set a wrong key - with pytest.raises(KeyError): - config._get_item_dicts("non_existing_key", "non_existing_value") - # Set a known good value - config._get_item_dicts("coastcolor", "black") - # Confirm we can still plot - fig, ax = uplt.subplots(proj="cyl") - ax.format(coastcolor="black") - fig.canvas.draw() + # Use context manager for temporary rc changes + # Use context manager with direct value setting + with uplt.rc.context(coastcolor="black"): + # Set a wrong key + with pytest.raises(KeyError): + uplt.rc._get_item_dicts("non_existing_key", "non_existing_value") + # Confirm we can still plot + fig, ax = uplt.subplots(proj="cyl") + ax.format(coastcolor="black") + fig.canvas.draw() def test_cycle_in_rc_file(tmp_path): """ Test that loading an rc file correctly overwrites the cycle setting. """ + rc = uplt.config.Configurator() rc_content = "cycle: colorblind" rc_file = tmp_path / "test.rc" rc_file.write_text(rc_content) - - # Load the file directly. This should overwrite any existing settings. - uplt.rc.load(str(rc_file)) - - assert uplt.rc["cycle"] == "colorblind" + rc.load(str(rc_file)) + assert rc["cycle"] == "colorblind" import io -from unittest.mock import patch, MagicMock from importlib.metadata import PackageNotFoundError +from unittest.mock import MagicMock, patch + from ultraplot.utils import check_for_update @@ -96,6 +98,220 @@ def test_dev_version_skipped(mock_urlopen, mock_version, mock_print): mock_print.assert_not_called() +# Helper functions for parameterized thread-safety test +def _setup_ultraplot_rcparams(): + """Create a new _RcParams instance for testing.""" + from ultraplot.internals.rcsetup import _RcParams + + base_keys = {f"base_key_{i}": f"base_value_{i}" for i in range(3)} + validators = {k: lambda x: x for k in base_keys} + return _RcParams(base_keys, validators) + + +def _setup_matplotlib_rcparams(): + """Get the rc_matplotlib wrapper.""" + from ultraplot.config import rc_matplotlib + + return rc_matplotlib + + +def _ultraplot_thread_keys(thread_id): + """Generate thread-specific keys for ultraplot (custom keys allowed).""" + return ( + f"thread_{thread_id}_key", # key + f"initial_{thread_id}", # initial_value + lambda i: f"thread_{thread_id}_value_{i}", # value_fn + ) + + +def _matplotlib_thread_keys(thread_id): + """Generate thread-specific keys for matplotlib (must use valid keys).""" + return ( + "font.size", # key - must be valid matplotlib param + 10 + thread_id * 2, # initial_value - unique per thread + lambda i: 10 + thread_id * 2, # value_fn - same value in loop + ) + + +def _ultraplot_base_keys_check(rc_params): + """Return list of base keys to verify for ultraplot.""" + return [(f"base_key_{i % 3}", f"base_value_{i % 3}") for i in range(20)] + + +def _matplotlib_base_keys_check(rc_matplotlib): + """Return list of base keys to verify for matplotlib.""" + return [("font.size", rc_matplotlib["font.size"])] + + +def _ultraplot_global_test(rc_params): + """Return test key/value for global change test (ultraplot).""" + return ( + "global_test_key", # key + "global_value", # value + lambda: None, # cleanup_fn - no cleanup needed + ) + + +def _matplotlib_global_test(rc_matplotlib): + """Return test key/value for global change test (matplotlib).""" + original_value = rc_matplotlib._rcparams["font.size"] + return ( + "font.size", # key + 99, # value + lambda: rc_matplotlib.__setitem__("font.size", original_value), # cleanup_fn + ) + + +@pytest.mark.parametrize( + "rc_type,setup_fn,thread_keys_fn,base_keys_fn,global_test_fn", + [ + ( + "ultraplot", + _setup_ultraplot_rcparams, + _ultraplot_thread_keys, + _ultraplot_base_keys_check, + _ultraplot_global_test, + ), + ( + "matplotlib", + _setup_matplotlib_rcparams, + _matplotlib_thread_keys, + _matplotlib_base_keys_check, + _matplotlib_global_test, + ), + ], +) +def test_rcparams_thread_safety( + rc_type, setup_fn, thread_keys_fn, base_keys_fn, global_test_fn +): + """ + Test that rcParams (both _RcParams and rc_matplotlib) are thread-safe with thread-local isolation. + + This parameterized test verifies thread-safety for both ultraplot's _RcParams and matplotlib's + rc_matplotlib wrapper. The key difference is that _RcParams allows custom keys while rc_matplotlib + must use valid matplotlib parameter keys. + + Thread-local changes inside context managers are isolated and don't persist. + Changes outside context managers are global and persistent. + + Parameters + ---------- + rc_type : str + Either "ultraplot" or "matplotlib" to identify which rcParams is being tested + setup_data : callable + Function that returns the rc_params object to test + thread_keys_fn : callable + Function that takes thread_id and returns (key, initial_value, value_fn) + - For ultraplot: custom keys like "thread_0_key" + - For matplotlib: valid keys like "font.size" + base_keys_check_fn : callable + Function that returns list of (key, expected_value) tuples to verify + global_test_fn : callable + Function that returns (key, value, cleanup_fn) for testing global changes + """ + # Setup rc_params object + rc_params = setup_fn() + + # Store original values for base keys (before any modifications) + if rc_type == "matplotlib": + original_values = {key: rc_params[key] for key, _ in base_keys_fn(rc_params)} + else: + original_values = {f"base_key_{i}": f"base_value_{i}" for i in range(3)} + + # Number of threads and operations per thread + num_threads = 5 + operations_per_thread = 20 + + # Track successful thread completions + thread_success = {} + + def worker(thread_id): + """Thread function that makes thread-local changes that don't persist.""" + thread_key, initial_value, value_fn = thread_keys_fn(thread_id) + + try: + # Use context manager for thread-local changes + with rc_params: + # Initialize the key with a base value (thread-local) + rc_params[thread_key] = initial_value + + # Perform operations + for i in range(operations_per_thread): + # Update with new value (thread-local) + new_value = value_fn(i) + rc_params[thread_key] = new_value + + # Verify the update worked within this thread + assert rc_params[thread_key] == new_value + + # Also read some base keys to test mixed access + if i % 5 == 0: + base_key, expected_value = base_keys_fn(rc_params)[0] + base_value = rc_params[base_key] + assert isinstance(base_value, (str, int, float, list)) + if rc_type == "ultraplot": + assert base_value == expected_value + + # Small delay for matplotlib to increase chance of race conditions + if rc_type == "matplotlib": + time.sleep(0.001) + + # After exiting context, thread-local changes should be gone + if rc_type == "ultraplot": + # For ultraplot, custom keys should not exist + assert ( + thread_key not in rc_params + ), f"Thread {thread_id}'s key persisted (should be thread-local only)" + else: + # For matplotlib, value should revert to original + assert ( + rc_params[thread_key] == original_values[thread_key] + ), f"Thread {thread_id}'s change persisted (should be thread-local only)" + + thread_success[thread_id] = True + + except Exception as e: + thread_success[thread_id] = False + raise AssertionError(f"Thread {thread_id} failed: {str(e)}") + + # Create and start threads + threads = [] + for i in range(num_threads): + t = threading.Thread(target=worker, args=(i,)) + threads.append(t) + t.start() + + # Wait for all threads to complete + for t in threads: + t.join() + + # Verify all threads completed successfully + for thread_id in range(num_threads): + assert thread_success.get( + thread_id, False + ), f"Thread {thread_id} did not complete successfully" + + # Verify base keys are still intact and unchanged + for key, expected_value in original_values.items(): + assert key in rc_params, f"Base key {key} was lost" + assert rc_params[key] == expected_value, f"Base key {key} value was corrupted" + + # Verify that ONLY base keys exist for ultraplot (no thread keys should persist) + if rc_type == "ultraplot": + assert len(rc_params) == len( + original_values + ), f"Expected {len(original_values)} keys, found {len(rc_params)}" + + # Test that global changes (outside context) DO persist + test_key, test_value, cleanup_fn = global_test_fn(rc_params) + rc_params[test_key] = test_value + assert test_key in rc_params + assert rc_params[test_key] == test_value + + # Cleanup if needed + cleanup_fn() + + @pytest.mark.parametrize( "cycle, raises_error", [ @@ -117,6 +333,78 @@ def test_cycle_rc_setting(cycle, raises_error): """ if raises_error: with pytest.raises(ValueError): - uplt.rc["cycle"] = cycle + with uplt.rc.context(cycle=cycle): + pass else: - uplt.rc["cycle"] = cycle + with uplt.rc.context(cycle=cycle): + pass + + +def test_rc_check_key(): + """ + Test the _check_key method in _RcParams + """ + from ultraplot.internals.rcsetup import _RcParams + + # Create a test instance + rc_params = _RcParams({"test_key": "test_value"}, {"test_key": lambda x: x}) + + # Test valid key + key, value = rc_params._check_key("test_key", "new_value") + assert key == "test_key" + assert value == "new_value" + + # Test new key (should be registered with default validator) + key, value = rc_params._check_key("new_key", "new_value") + assert key == "new_key" + assert value == "new_value" + assert "new_key" in rc_params._validate + + +def test_rc_repr(): + """ + Test the __repr__ method in _RcParams + """ + from ultraplot.internals.rcsetup import _RcParams + + # Create a test instance + rc_params = _RcParams({"test_key": "test_value"}, {"test_key": lambda x: x}) + + # Test __repr__ + repr_str = repr(rc_params) + assert "RcParams" in repr_str + assert "test_key" in repr_str + + +def test_rc_validators(): + """ + Test validators in _RcParams + """ + from ultraplot.internals.rcsetup import _RcParams + + # Create a test instance with various validators + validators = { + "int_val": lambda x: int(x), + "float_val": lambda x: float(x), + "str_val": lambda x: str(x), + } + rc_params = _RcParams( + {"int_val": 1, "float_val": 1.0, "str_val": "test"}, validators + ) + + # Test valid values + rc_params["int_val"] = 2 + assert rc_params["int_val"] == 2 + + rc_params["float_val"] = 2.5 + assert rc_params["float_val"] == 2.5 + + rc_params["str_val"] = "new_value" + assert rc_params["str_val"] == "new_value" + + # Test invalid values + with pytest.raises(ValueError): + rc_params["int_val"] = "not_an_int" + + with pytest.raises(ValueError): + rc_params["float_val"] = "not_a_float"