From 8700d1f2dd0190e6f390f6a57d01eae7cb174fc6 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 26 Oct 2025 01:00:26 +0200 Subject: [PATCH 1/9] init --- ultraplot/internals/rcsetup.py | 81 ++++++++++++++++++++++++------ ultraplot/tests/test_config.py | 91 ++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 16 deletions(-) diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 7439f35cf..151e5ef1f 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -4,6 +4,7 @@ """ import functools import re, matplotlib as mpl +import threading from collections.abc import MutableMapping from numbers import Integral, Real @@ -562,16 +563,42 @@ 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. + + Example + ------- + >>> with rc_params: + ... rc_params['key'] = 'value' # Thread-local change + ... # Changes are automatically cleaned up when exiting the context """ # 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() + self._local.changes = {} # Initialize thread-local storage + # 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 +614,33 @@ 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 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: + # Store in both thread-local storage and main dictionary + self._local.changes[key] = value + 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 +662,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: + if key not in self._local.changes: + source[key] = dict.__getitem__(self, key) + # Add thread-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..d73faf88f 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -1,5 +1,7 @@ import ultraplot as uplt, pytest import importlib +import threading +import time def test_wrong_keyword_reset(): @@ -96,6 +98,95 @@ def test_dev_version_skipped(mock_urlopen, mock_version, mock_print): mock_print.assert_not_called() +def test_rcparams_thread_safety(): + """ + Test that _RcParams is thread-safe when accessed concurrently. + Each thread works with its own unique key to verify proper isolation. + Thread-local changes are properly managed with context manager. + """ + # Create a new _RcParams instance for testing + from ultraplot.internals.rcsetup import _RcParams + + # Initialize with base keys + base_keys = {f"base_key_{i}": f"base_value_{i}" for i in range(3)} + rc_params = _RcParams(base_keys, {k: lambda x: x for k in base_keys}) + + # Number of threads and operations per thread + num_threads = 5 + operations_per_thread = 20 + + # Each thread will work with its own unique key + thread_keys = {} + + def worker(thread_id): + """Thread function that works with its own unique key using context manager.""" + # Each thread gets its own unique key + thread_key = f"thread_{thread_id}_key" + thread_keys[thread_id] = thread_key + + # Use context manager to ensure proper thread-local cleanup + with rc_params: + # Initialize the key with a base value + rc_params[thread_key] = f"initial_{thread_id}" + + # Perform operations + for i in range(operations_per_thread): + try: + # Read the current value + current = rc_params[thread_key] + + # Update with new value + new_value = f"thread_{thread_id}_value_{i}" + rc_params[thread_key] = new_value + + # Verify the update worked + assert rc_params[thread_key] == new_value + + # Also read some base keys to test mixed access + if i % 5 == 0: + base_key = f"base_key_{i % 3}" + base_value = rc_params[base_key] + assert isinstance(base_value, str) + + except Exception as e: + 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 each thread's key exists and has the expected final value + for thread_id in range(num_threads): + thread_key = thread_keys[thread_id] + assert thread_key in rc_params, f"Thread {thread_id}'s key was lost" + final_value = rc_params[thread_key] + assert final_value == f"thread_{thread_id}_value_{operations_per_thread - 1}" + + # Verify base keys are still intact + for key, expected_value in base_keys.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 thread-local changes are properly merged + # Create a copy to verify the copy includes thread-local changes + rc_copy = rc_params.copy() + assert len(rc_copy) == len(base_keys) + num_threads, "Copy doesn't include all keys" + + # Verify all keys are in the copy + for key in base_keys: + assert key in rc_copy, f"Base key {key} missing from copy" + for thread_id in range(num_threads): + thread_key = thread_keys[thread_id] + assert thread_key in rc_copy, f"Thread {thread_id}'s key missing from copy" + + @pytest.mark.parametrize( "cycle, raises_error", [ From 31ec92bbf92d246a1a963c9bc94cafa25ccec913 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 26 Oct 2025 01:33:35 +0200 Subject: [PATCH 2/9] change tests to thread local changes --- ultraplot/tests/test_config.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index d73faf88f..74587e666 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -8,31 +8,27 @@ 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)) - + rc.load(str(rc_file)) assert uplt.rc["cycle"] == "colorblind" @@ -208,6 +204,8 @@ 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 From 69a623da085ed6eca04d0e74386bd24ac80652f1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 26 Oct 2025 01:38:24 +0200 Subject: [PATCH 3/9] add tests for codecov --- ultraplot/tests/test_config.py | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index 74587e666..24f0930f2 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -209,3 +209,73 @@ def test_cycle_rc_setting(cycle, raises_error): else: 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" From 6fce59a1d84cde5c321fc9b6d3b7e4bd2a789369 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 26 Oct 2025 02:08:15 +0200 Subject: [PATCH 4/9] bump --- ultraplot/tests/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index 24f0930f2..8c35c2083 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -29,7 +29,7 @@ def test_cycle_in_rc_file(tmp_path): rc_file = tmp_path / "test.rc" rc_file.write_text(rc_content) rc.load(str(rc_file)) - assert uplt.rc["cycle"] == "colorblind" + assert rc["cycle"] == "colorblind" import io From 65411e3d85e9ac585b6a1508de7a5bc839ad76b3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 4 Nov 2025 06:53:08 +0100 Subject: [PATCH 5/9] fix logical back to make setting params local to thread --- ultraplot/internals/rcsetup.py | 47 ++++++++++++------- ultraplot/tests/test_config.py | 82 +++++++++++++++++++--------------- 2 files changed, 78 insertions(+), 51 deletions(-) diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 151e5ef1f..3455fee4a 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -3,11 +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 @@ -21,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 @@ -567,11 +570,22 @@ class _RcParams(MutableMapping, dict): 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'] = 'value' # Thread-local change - ... # Changes are automatically cleaned up when exiting the context + ... 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 @@ -580,7 +594,7 @@ def __init__(self, source, validate): self._validate = validate self._lock = threading.RLock() self._local = threading.local() - self._local.changes = {} # Initialize thread-local storage + # 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: @@ -616,8 +630,8 @@ def __iter__(self): def __getitem__(self, key): with self._lock: key, _ = self._check_key(key) - # Check thread-local storage first - if key in self._local.changes: + # 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) @@ -636,9 +650,12 @@ def __setitem__(self, key, value): except (ValueError, TypeError) as error: raise ValueError(f"Key {key}: {error}") from None if key is not None: - # Store in both thread-local storage and main dictionary - self._local.changes[key] = value - dict.__setitem__(self, key, value) + # 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 @@ -673,10 +690,10 @@ def copy(self): source = {} # Start with global values for key in self: - if key not in self._local.changes: - source[key] = dict.__getitem__(self, key) - # Add thread-local changes - source.update(self._local.changes) + 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 8c35c2083..638105210 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -1,8 +1,11 @@ -import ultraplot as uplt, pytest import importlib import threading import time +import pytest + +import ultraplot as uplt + def test_wrong_keyword_reset(): """ @@ -33,8 +36,9 @@ def test_cycle_in_rc_file(tmp_path): 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 @@ -97,8 +101,8 @@ def test_dev_version_skipped(mock_urlopen, mock_version, mock_print): def test_rcparams_thread_safety(): """ Test that _RcParams is thread-safe when accessed concurrently. - Each thread works with its own unique key to verify proper isolation. - Thread-local changes are properly managed with context manager. + Thread-local changes inside context managers are isolated and don't persist. + Changes outside context managers are global and persistent. """ # Create a new _RcParams instance for testing from ultraplot.internals.rcsetup import _RcParams @@ -111,31 +115,29 @@ def test_rcparams_thread_safety(): num_threads = 5 operations_per_thread = 20 - # Each thread will work with its own unique key - thread_keys = {} + # Track successful thread completions + thread_success = {} def worker(thread_id): - """Thread function that works with its own unique key using context manager.""" - # Each thread gets its own unique key + """Thread function that makes thread-local changes that don't persist.""" thread_key = f"thread_{thread_id}_key" - thread_keys[thread_id] = thread_key - # Use context manager to ensure proper thread-local cleanup - with rc_params: - # Initialize the key with a base value - rc_params[thread_key] = f"initial_{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] = f"initial_{thread_id}" - # Perform operations - for i in range(operations_per_thread): - try: + # Perform operations + for i in range(operations_per_thread): # Read the current value current = rc_params[thread_key] - # Update with new value + # Update with new value (thread-local) new_value = f"thread_{thread_id}_value_{i}" rc_params[thread_key] = new_value - # Verify the update worked + # Verify the update worked within this thread assert rc_params[thread_key] == new_value # Also read some base keys to test mixed access @@ -143,9 +145,19 @@ def worker(thread_id): base_key = f"base_key_{i % 3}" base_value = rc_params[base_key] assert isinstance(base_value, str) + assert base_value == f"base_value_{i % 3}" - except Exception as e: - raise AssertionError(f"Thread {thread_id} failed: {str(e)}") + # After exiting context, thread-local changes should be gone + # The key should NOT exist in the global rc_params + assert ( + thread_key not in rc_params + ), f"Thread {thread_id}'s key 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 = [] @@ -158,29 +170,27 @@ def worker(thread_id): for t in threads: t.join() - # Verify each thread's key exists and has the expected final value + # Verify all threads completed successfully for thread_id in range(num_threads): - thread_key = thread_keys[thread_id] - assert thread_key in rc_params, f"Thread {thread_id}'s key was lost" - final_value = rc_params[thread_key] - assert final_value == f"thread_{thread_id}_value_{operations_per_thread - 1}" + assert thread_success.get( + thread_id, False + ), f"Thread {thread_id} did not complete successfully" - # Verify base keys are still intact + # Verify base keys are still intact and unchanged for key, expected_value in base_keys.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 thread-local changes are properly merged - # Create a copy to verify the copy includes thread-local changes - rc_copy = rc_params.copy() - assert len(rc_copy) == len(base_keys) + num_threads, "Copy doesn't include all keys" + # Verify that ONLY base keys exist (no thread keys should persist) + assert len(rc_params) == len( + base_keys + ), f"Expected {len(base_keys)} keys, found {len(rc_params)}" - # Verify all keys are in the copy - for key in base_keys: - assert key in rc_copy, f"Base key {key} missing from copy" - for thread_id in range(num_threads): - thread_key = thread_keys[thread_id] - assert thread_key in rc_copy, f"Thread {thread_id}'s key missing from copy" + # Test that global changes (outside context) DO persist + test_key = "global_test_key" + rc_params[test_key] = "global_value" + assert test_key in rc_params + assert rc_params[test_key] == "global_value" @pytest.mark.parametrize( From c73c89b690aebdc79c5084c7e5692ce72d04d6ae Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 4 Nov 2025 07:10:14 +0100 Subject: [PATCH 6/9] add thread safety wrapper fro mpl.rcParams --- ultraplot/config.py | 133 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 4 deletions(-) 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. From ba07b9ffe53409814a5473d685816a5f13020ce8 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 4 Nov 2025 07:11:20 +0100 Subject: [PATCH 7/9] unify safety tests + helpers --- ultraplot/tests/test_config.py | 155 ++++++++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 20 deletions(-) diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index 638105210..ee3523646 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -98,18 +98,125 @@ def test_dev_version_skipped(mock_urlopen, mock_version, mock_print): mock_print.assert_not_called() -def test_rcparams_thread_safety(): +# 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 is thread-safe when accessed concurrently. + 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 """ - # Create a new _RcParams instance for testing - from ultraplot.internals.rcsetup import _RcParams + # Setup rc_params object + rc_params = setup_fn() - # Initialize with base keys - base_keys = {f"base_key_{i}": f"base_value_{i}" for i in range(3)} - rc_params = _RcParams(base_keys, {k: lambda x: x for k in base_keys}) + # 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 @@ -120,21 +227,18 @@ def test_rcparams_thread_safety(): def worker(thread_id): """Thread function that makes thread-local changes that don't persist.""" - thread_key = f"thread_{thread_id}_key" + 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] = f"initial_{thread_id}" + rc_params[thread_key] = initial_value # Perform operations for i in range(operations_per_thread): - # Read the current value - current = rc_params[thread_key] - # Update with new value (thread-local) - new_value = f"thread_{thread_id}_value_{i}" + new_value = value_fn(i) rc_params[thread_key] = new_value # Verify the update worked within this thread @@ -142,16 +246,27 @@ def worker(thread_id): # Also read some base keys to test mixed access if i % 5 == 0: - base_key = f"base_key_{i % 3}" + base_key, expected_value = base_keys_fn(rc_params)[0] base_value = rc_params[base_key] - assert isinstance(base_value, str) - assert base_value == f"base_value_{i % 3}" + 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 - # The key should NOT exist in the global rc_params - assert ( - thread_key not in rc_params - ), f"Thread {thread_id}'s key persisted (should be thread-local only)" + 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 From 0a935ec87def0e5ef8925ebaeeae24e2518a33cd Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 4 Nov 2025 07:13:46 +0100 Subject: [PATCH 8/9] missed some chunks --- ultraplot/tests/test_config.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index ee3523646..d7029feeb 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -292,20 +292,24 @@ def worker(thread_id): ), f"Thread {thread_id} did not complete successfully" # Verify base keys are still intact and unchanged - for key, expected_value in base_keys.items(): + 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 (no thread keys should persist) - assert len(rc_params) == len( - base_keys - ), f"Expected {len(base_keys)} keys, found {len(rc_params)}" + # 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 = "global_test_key" - rc_params[test_key] = "global_value" + 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] == "global_value" + assert rc_params[test_key] == test_value + + # Cleanup if needed + cleanup_fn() @pytest.mark.parametrize( From 0b4afb86efd64e81d3b600bf6c80a47097cd67a1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 4 Nov 2025 07:14:43 +0100 Subject: [PATCH 9/9] update docs and add example --- docs/configuration.rst | 83 +++++++++++++++++++ docs/thread_safety_example.py | 148 ++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 docs/thread_safety_example.py 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