Skip to content

Commit 6229785

Browse files
timhoffmy9c
andcommitted
Add Spines class as a container for all Axes spines
Co-authored-by: yech1990 <yech1990@gmail.com>
1 parent e7601e0 commit 6229785

File tree

4 files changed

+177
-1
lines changed

4 files changed

+177
-1
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
``Axes.spines``
2+
---------------
3+
4+
``Axes.spines`` is now a dedicated container class `.Spines` for a set of
5+
`.Spine`\s instead of an ``OrderedDict``. On top of dict-like access,
6+
``Axes.spines`` now also supports some ``pandas.Series``-like features.
7+
8+
Accessing single elements by item or by attribute
9+
10+
ax.spines['top'].set_visible(False)
11+
ax.spines.top.set_visible(False)
12+
13+
Accessing a subset of items::
14+
15+
ax.spines[['top', 'right']].set_visible(False)
16+
17+
Accessing all items simultaneously::
18+
19+
ax.spines[:].set_visible(False)

lib/matplotlib/axes/_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ def __init__(self, fig, rect,
535535
# placeholder for any colorbars added that use this axes.
536536
# (see colorbar.py):
537537
self._colorbars = []
538-
self.spines = self._gen_axes_spines()
538+
self.spines = mspines.Spines.from_dict(self._gen_axes_spines())
539539

540540
# this call may differ for non-sep axes, e.g., polar
541541
self._init_axis()

lib/matplotlib/spines.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from collections.abc import MutableMapping
2+
import functools
3+
14
import numpy as np
25

36
import matplotlib
@@ -534,3 +537,114 @@ def set_color(self, c):
534537
"""
535538
self.set_edgecolor(c)
536539
self.stale = True
540+
541+
542+
class SpinesProxy:
543+
"""
544+
A proxy to broadcast ``set_*`` method calls to all contained `.Spines`.
545+
546+
The proxy cannot be used for any other operations on its members.
547+
548+
The supported methods are determined dynamically based on the contained
549+
spines. If not all spines support a given method, it's executed only on
550+
the subset of spines that support it.
551+
"""
552+
def __init__(self, spine_dict):
553+
self._spine_dict = spine_dict
554+
555+
def __getattr__(self, name):
556+
broadcast_targets = [spine for spine in self._spine_dict.values()
557+
if hasattr(spine, name)]
558+
if not name.startswith('set_') or not broadcast_targets:
559+
raise AttributeError(
560+
f"'SpinesProxy' object has no attribute '{name}'")
561+
562+
def x(_targets, _funcname, *args, **kwargs):
563+
for spine in _targets:
564+
getattr(spine, _funcname)(*args, **kwargs)
565+
x = functools.partial(x, broadcast_targets, name)
566+
x.__doc__ = broadcast_targets[0].__doc__
567+
return x
568+
569+
def __dir__(self):
570+
names = []
571+
for spine in self._spine_dict.values():
572+
names.extend(name
573+
for name in dir(spine) if name.startswith('set_'))
574+
return list(sorted(set(names)))
575+
576+
577+
class Spines(MutableMapping):
578+
r"""
579+
The container of all `.Spine`\s in an Axes.
580+
581+
The interface is dict-like mapping names (e.g. 'left') to `.Spine` objects.
582+
Additionally it implements some pandas.Series-like features like accessing
583+
elements by attribute::
584+
585+
spines['top'].set_visible(False)
586+
spines.top.set_visible(False)
587+
588+
Multiple spines can be addressed simultaneously by passing a list. This
589+
will return a `SpinesProxy` that broadcasts all ``set_*`` calls to it's
590+
members::
591+
592+
spines[['top', 'right']].set_visible(False)
593+
594+
Use an open slice to address all spines::
595+
596+
spines[:].set_visible(False)
597+
598+
"""
599+
def __init__(self, **kwargs):
600+
self._dict = kwargs
601+
self.all = SpinesProxy(self._dict)
602+
603+
@classmethod
604+
def from_dict(cls, d):
605+
return cls(**d)
606+
607+
def __getstate__(self):
608+
return self._dict
609+
610+
def __setstate__(self, state):
611+
self.__init__(**state)
612+
613+
def __getattr__(self, name):
614+
try:
615+
return self._dict[name]
616+
except KeyError:
617+
raise ValueError(
618+
f"'Spines' object does not contain a '{name}' spine")
619+
620+
def __getitem__(self, key):
621+
if isinstance(key, list):
622+
unknown_keys = [k for k in key if k not in self._dict]
623+
if unknown_keys:
624+
raise KeyError(', '.join(unknown_keys))
625+
return SpinesProxy({k: v for k, v in self._dict.items()
626+
if k in key})
627+
if isinstance(key, tuple):
628+
raise ValueError('Multiple spines must be passed as a single list')
629+
if isinstance(key, slice):
630+
if key.start is None and key.stop is None and key.step is None:
631+
return SpinesProxy(self._dict)
632+
else:
633+
raise ValueError(
634+
'Spines does not support slicing except for the fully '
635+
'open slice [:] to access all spines.')
636+
return self._dict[key]
637+
638+
def __setitem__(self, key, value):
639+
# TODO: Do we want to deprecate adding spines?
640+
self._dict[key] = value
641+
642+
def __delitem__(self, key):
643+
# TODO: Do we want to deprecate deleting spines?
644+
del self._dict[key]
645+
646+
def __iter__(self):
647+
return iter(self._dict)
648+
649+
def __len__(self):
650+
return len(self._dict)

lib/matplotlib/tests/test_spines.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,52 @@
11
import numpy as np
2+
import pytest
23

34
import matplotlib.pyplot as plt
5+
from matplotlib.spines import Spines
46
from matplotlib.testing.decorators import check_figures_equal, image_comparison
57

68

9+
def test_spine_class():
10+
"""Test Spines and SpinesProxy in isolation."""
11+
class SpineMock:
12+
def __init__(self):
13+
self.val = None
14+
15+
def set_val(self, val):
16+
self.val = val
17+
18+
spines_dict = {
19+
'left': SpineMock(),
20+
'right': SpineMock(),
21+
'top': SpineMock(),
22+
'bottom': SpineMock(),
23+
}
24+
spines = Spines(**spines_dict)
25+
26+
assert spines['left'] is spines_dict['left']
27+
assert spines.left is spines_dict['left']
28+
29+
spines[['left', 'right']].set_val('x')
30+
assert spines.left.val == 'x'
31+
assert spines.right.val == 'x'
32+
assert spines.top.val is None
33+
assert spines.bottom.val is None
34+
35+
spines[:].set_val('y')
36+
assert all(spine.val == 'y' for spine in spines.values())
37+
38+
with pytest.raises(KeyError, match='foo'):
39+
spines['foo']
40+
with pytest.raises(KeyError, match='foo, bar'):
41+
spines[['left', 'foo', 'right', 'bar']]
42+
with pytest.raises(ValueError, match='single list'):
43+
spines['left', 'right']
44+
with pytest.raises(ValueError, match='Spines does not support slicing'):
45+
spines['left':'right']
46+
with pytest.raises(ValueError, match='Spines does not support slicing'):
47+
spines['top':]
48+
49+
750
@image_comparison(['spines_axes_positions'])
851
def test_spines_axes_positions():
952
# SF bug 2852168

0 commit comments

Comments
 (0)