Skip to content

Commit 5ad50b9

Browse files
committed
feat: conversion hook, AxesTuple subclass, get/setattr (#456)
1 parent 8bdc389 commit 5ad50b9

File tree

5 files changed

+191
-17
lines changed

5 files changed

+191
-17
lines changed

docs/CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@
55

66
#### User changes
77

8-
* Arbitrary items can be set on an axis or histogram. [#450][]
8+
* Arbitrary items can be set on an axis or histogram. [#450][], [#456][]
9+
* Subclasses can customize the conversion procedure. [#456][]
910

1011
#### Bug fixes
1112

1213
* Fixed reading pickles from boost-histogram 0.6-0.8 [#445][]
1314
* Minor correctness fix [#446][]
1415
* Accidental install of typing on Python 3.5+ fixed
16+
* Scalar ND fill fixed [#453][]
1517

1618
#### Developer changes
1719
* Updated to Boost 1.74 [#442][]
1820
* CMake installs version.py now too [#449][]
1921
* Updated setuptools infrastructure no longer requires NumPy [#451][]
22+
* Some basic clang-tidy checks are now being run [#455][]
2023

2124

2225
[#442]: https://github.com/scikit-hep/boost-histogram/pull/442
@@ -25,6 +28,9 @@
2528
[#449]: https://github.com/scikit-hep/boost-histogram/pull/449
2629
[#450]: https://github.com/scikit-hep/boost-histogram/pull/450
2730
[#451]: https://github.com/scikit-hep/boost-histogram/pull/451
31+
[#453]: https://github.com/scikit-hep/boost-histogram/pull/453
32+
[#455]: https://github.com/scikit-hep/boost-histogram/pull/455
33+
[#456]: https://github.com/scikit-hep/boost-histogram/pull/456
2834

2935

3036
## Version 0.10

src/boost_histogram/_internal/axis.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,16 @@ def __getattr__(self, item):
4545
elif item == "metadata":
4646
return None
4747
else:
48-
raise AttributeError(
49-
"'{}' object has no attribute '{}'".format(type(self).__name__, item)
48+
msg = "'{}' object has no attribute '{}' in {}".format(
49+
type(self).__name__, item, set(self._ax.metadata)
5050
)
51+
raise AttributeError(msg)
5152

5253
def __setattr__(self, item, value):
5354
if item == "_ax":
5455
Axis.__dict__[item].__set__(self, value)
55-
return
56-
57-
self._ax.metadata[item] = value
56+
else:
57+
self._ax.metadata[item] = value
5858

5959
def __dir__(self):
6060
metadata = list(self._ax.metadata)

src/boost_histogram/_internal/axistuple.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,6 @@ class AxesTuple(tuple):
4949
def size(self):
5050
return tuple(s.size for s in self)
5151

52-
@property
53-
def metadata(self):
54-
return tuple(s.metadata for s in self)
55-
56-
@metadata.setter
57-
def metadata(self, values):
58-
for s, v in zip(self, values):
59-
s.metadata = v
60-
6152
@property
6253
def extent(self):
6354
return tuple(s.extent for s in self)
@@ -102,6 +93,12 @@ def __getitem__(self, item):
10293
result = super(AxesTuple, self).__getitem__(item)
10394
return self.__class__(result) if isinstance(result, tuple) else result
10495

96+
def __getattr__(self, attr):
97+
return self.__class__(s.__getattr__(attr) for s in self)
98+
99+
def __setattr__(self, attr, values):
100+
return self.__class__(s.__setattr__(attr, v) for s, v in zip(self, values))
101+
105102
# Python 2 support - remove after 1.0
106103
def __getslice__(self, start, stop):
107104
result = super(AxesTuple, self).__getslice__(start, stop)

src/boost_histogram/_internal/hist.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,11 @@ def __init__(self, *axes, **kwargs):
112112
self.axes = self._generate_axes_()
113113
return
114114

115-
# If we construct with another Histogram, support that too
115+
# If we construct with another Histogram as the only positional argument,
116+
# support that too
116117
if len(axes) == 1 and isinstance(axes[0], Histogram):
117118
self.__init__(axes[0]._hist)
118-
self.metadata = axes[0].metadata
119+
self._from_histogram_object(axes[0])
119120
return
120121

121122
# Keyword only trick (change when Python2 is dropped)
@@ -151,6 +152,31 @@ def __init__(self, *axes, **kwargs):
151152

152153
raise TypeError("Unsupported storage")
153154

155+
def _from_histogram_object(self, h):
156+
self.__dict__ = copy.copy(h.__dict__)
157+
self.axes = self._generate_axes_()
158+
for ax in self.axes:
159+
ax._ax.metadata = copy.copy(ax._ax.metadata)
160+
161+
# Allow custom behavior on either "from" or "to"
162+
h._export_bh_(self)
163+
self._import_bh_()
164+
165+
def _import_bh_(self):
166+
"""
167+
If any post-processing is needed to pass a histogram between libraries, a
168+
subclass can implement it here. self is the new instance in the current
169+
(converted-to) class.
170+
"""
171+
172+
@classmethod
173+
def _export_bh_(cls, self):
174+
"""
175+
If any preparation is needed to pass a histogram between libraries, a subclass can
176+
implement it here. cls is the current class being converted from, and self is the
177+
instance in the class being converted to.
178+
"""
179+
154180
def _generate_axes_(self):
155181
"""
156182
This is called to fill in the axes. Subclasses can override it if they need

tests/test_minihist_title.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# -*- coding: utf-8 -*-
2+
# The point of this test is to make sure that the infrastructure for supporting
3+
# custom attributes, like title in Hist, is working.
4+
5+
import pytest
6+
import boost_histogram as bh
7+
8+
9+
# First, make a new family to identify your library
10+
CUSTOM_FAMILY = object()
11+
12+
13+
# Add named axes
14+
class NamedAxesTuple(bh.axis.AxesTuple):
15+
__slots__ = ()
16+
17+
def _get_index_by_name(self, name):
18+
if isinstance(name, str):
19+
for i, ax in enumerate(self):
20+
if ax.name == name:
21+
return i
22+
raise KeyError("{} not found in axes".format(name))
23+
else:
24+
return name
25+
26+
def __getitem__(self, item):
27+
if isinstance(item, slice):
28+
item = slice(
29+
self._get_index_by_name(item.start),
30+
self._get_index_by_name(item.stop),
31+
self._get_index_by_name(item.step),
32+
)
33+
else:
34+
item = self._get_index_by_name(item)
35+
36+
return super(NamedAxesTuple, self).__getitem__(item)
37+
38+
@property
39+
def name(self):
40+
"""
41+
The names of the axes. May be empty strings.
42+
"""
43+
return tuple(ax.name for ax in self)
44+
45+
46+
# When you subclass Histogram or an Axes, you should register your family so
47+
# boost-histogram will know what to convert C++ objects into.
48+
49+
50+
class AxesMixin(object):
51+
__slots__ = ()
52+
53+
@property
54+
def name(self):
55+
"""
56+
Get the name for the Regular axis
57+
"""
58+
return self._ax.metadata.get("name", "")
59+
60+
61+
# The order of the mixin is important here - it must be first
62+
# to override bh.axis.Regular
63+
@bh.utils.set_family(CUSTOM_FAMILY)
64+
class Regular(bh.axis.Regular, AxesMixin):
65+
__slots__ = ()
66+
67+
def __init__(
68+
self, bins, start, stop, name,
69+
):
70+
71+
super(Regular, self).__init__(
72+
bins, start, stop,
73+
)
74+
75+
self._ax.metadata["name"] = name
76+
77+
78+
@bh.utils.set_family(CUSTOM_FAMILY)
79+
class CustomHist(bh.Histogram):
80+
def _generate_axes_(self):
81+
return NamedAxesTuple(self._axis(i) for i in range(self.ndim))
82+
83+
def __init__(self, *args, **kwargs):
84+
super(CustomHist, self).__init__(*args, **kwargs)
85+
valid_names = [ax.name for ax in self.axes if ax.name]
86+
if len(valid_names) != len(set(valid_names)):
87+
msg = "{} instance cannot contain axes with duplicated names".format(
88+
self.__class__.__name__
89+
)
90+
raise KeyError(msg)
91+
92+
93+
def test_hist_creation():
94+
hist_1 = CustomHist(Regular(10, 0, 1, name="a"), Regular(20, 0, 4, name="b"))
95+
assert hist_1.axes[0].name == "a"
96+
assert hist_1.axes[1].name == "b"
97+
98+
hist_2 = CustomHist(Regular(10, 0, 1, name=""), Regular(20, 0, 4, name=""))
99+
assert hist_2.axes[0].name == ""
100+
assert hist_2.axes[1].name == ""
101+
102+
with pytest.raises(KeyError):
103+
CustomHist(Regular(10, 0, 1, name="a"), Regular(20, 0, 4, name="a"))
104+
105+
106+
def test_hist_index():
107+
hist_1 = CustomHist(Regular(10, 0, 1, name="a"), Regular(20, 0, 4, name="b"))
108+
assert hist_1.axes[0].name == "a"
109+
assert hist_1.axes[1].name == "b"
110+
111+
112+
def test_hist_convert():
113+
hist_1 = CustomHist(Regular(10, 0, 1, name="a"), Regular(20, 0, 4, name="b"))
114+
hist_bh = bh.Histogram(hist_1)
115+
116+
assert type(hist_bh.axes[0]) == bh.axis.Regular
117+
assert hist_bh.axes[0].name == "a"
118+
assert hist_bh.axes[1].name == "b"
119+
120+
hist_2 = CustomHist(hist_bh)
121+
122+
assert type(hist_2.axes[0]) == Regular
123+
assert hist_2.axes[0].name == "a"
124+
assert hist_2.axes[1].name == "b"
125+
126+
# Just verify no-op status
127+
hist_3 = CustomHist(hist_1)
128+
129+
assert type(hist_3.axes[0]) == Regular
130+
assert hist_3.axes[0].name == "a"
131+
assert hist_3.axes[1].name == "b"
132+
133+
134+
def test_access():
135+
hist = CustomHist(Regular(10, 0, 1, name="a"), Regular(20, 0, 4, name="b"))
136+
137+
assert hist.axes["a"] == hist.axes[0]
138+
assert hist.axes["b"] == hist.axes[1]
139+
140+
from_bh = bh.Histogram(bh.axis.Regular(10, 0, 1), bh.axis.Regular(20, 0, 4))
141+
from_bh.axes.name = "a", "b"
142+
hist_conv = CustomHist(from_bh)
143+
144+
assert hist_conv.axes["a"] == hist_conv.axes[0]
145+
assert hist_conv.axes["b"] == hist_conv.axes[1]

0 commit comments

Comments
 (0)