diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d9318d638..3c3c30ca4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,18 +5,21 @@ #### User changes -* Arbitrary items can be set on an axis or histogram. [#450][] +* Arbitrary items can be set on an axis or histogram. [#450][], [#456][] +* Subclasses can customize the conversion procedure. [#456][] #### Bug fixes * Fixed reading pickles from boost-histogram 0.6-0.8 [#445][] * Minor correctness fix [#446][] * Accidental install of typing on Python 3.5+ fixed +* Scalar ND fill fixed [#453][] #### Developer changes * Updated to Boost 1.74 [#442][] * CMake installs version.py now too [#449][] * Updated setuptools infrastructure no longer requires NumPy [#451][] +* Some basic clang-tidy checks are now being run [#455][] [#442]: https://github.com/scikit-hep/boost-histogram/pull/442 @@ -25,6 +28,9 @@ [#449]: https://github.com/scikit-hep/boost-histogram/pull/449 [#450]: https://github.com/scikit-hep/boost-histogram/pull/450 [#451]: https://github.com/scikit-hep/boost-histogram/pull/451 +[#453]: https://github.com/scikit-hep/boost-histogram/pull/453 +[#455]: https://github.com/scikit-hep/boost-histogram/pull/455 +[#456]: https://github.com/scikit-hep/boost-histogram/pull/456 ## Version 0.10 diff --git a/src/boost_histogram/_internal/axistuple.py b/src/boost_histogram/_internal/axestuple.py similarity index 93% rename from src/boost_histogram/_internal/axistuple.py rename to src/boost_histogram/_internal/axestuple.py index 6702a39b2..2c703d89c 100644 --- a/src/boost_histogram/_internal/axistuple.py +++ b/src/boost_histogram/_internal/axestuple.py @@ -49,15 +49,6 @@ class AxesTuple(tuple): def size(self): return tuple(s.size for s in self) - @property - def metadata(self): - return tuple(s.metadata for s in self) - - @metadata.setter - def metadata(self, values): - for s, v in zip(self, values): - s.metadata = v - @property def extent(self): return tuple(s.extent for s in self) @@ -102,6 +93,12 @@ def __getitem__(self, item): result = super(AxesTuple, self).__getitem__(item) return self.__class__(result) if isinstance(result, tuple) else result + def __getattr__(self, attr): + return self.__class__(s.__getattr__(attr) for s in self) + + def __setattr__(self, attr, values): + return self.__class__(s.__setattr__(attr, v) for s, v in zip(self, values)) + # Python 2 support - remove after 1.0 def __getslice__(self, start, stop): result = super(AxesTuple, self).__getslice__(start, stop) diff --git a/src/boost_histogram/_internal/axis.py b/src/boost_histogram/_internal/axis.py index dfd4039c4..9403c1a32 100644 --- a/src/boost_histogram/_internal/axis.py +++ b/src/boost_histogram/_internal/axis.py @@ -45,16 +45,16 @@ def __getattr__(self, item): elif item == "metadata": return None else: - raise AttributeError( - "'{}' object has no attribute '{}'".format(type(self).__name__, item) + msg = "'{}' object has no attribute '{}' in {}".format( + type(self).__name__, item, set(self._ax.metadata) ) + raise AttributeError(msg) def __setattr__(self, item, value): if item == "_ax": Axis.__dict__[item].__set__(self, value) - return - - self._ax.metadata[item] = value + else: + self._ax.metadata[item] = value def __dir__(self): metadata = list(self._ax.metadata) diff --git a/src/boost_histogram/_internal/hist.py b/src/boost_histogram/_internal/hist.py index 17119105e..60175fd17 100644 --- a/src/boost_histogram/_internal/hist.py +++ b/src/boost_histogram/_internal/hist.py @@ -10,7 +10,7 @@ from .. import _core from .axis import Axis -from .axistuple import AxesTuple +from .axestuple import AxesTuple from .kwargs import KWArgs from .sig_tools import inject_signature from .six import string_types @@ -112,10 +112,11 @@ def __init__(self, *axes, **kwargs): self.axes = self._generate_axes_() return - # If we construct with another Histogram, support that too + # If we construct with another Histogram as the only positional argument, + # support that too if len(axes) == 1 and isinstance(axes[0], Histogram): self.__init__(axes[0]._hist) - self.metadata = axes[0].metadata + self._from_histogram_object(axes[0]) return # Keyword only trick (change when Python2 is dropped) @@ -151,6 +152,31 @@ def __init__(self, *axes, **kwargs): raise TypeError("Unsupported storage") + def _from_histogram_object(self, h): + self.__dict__ = copy.copy(h.__dict__) + self.axes = self._generate_axes_() + for ax in self.axes: + ax._ax.metadata = copy.copy(ax._ax.metadata) + + # Allow custom behavior on either "from" or "to" + h._export_bh_(self) + self._import_bh_() + + def _import_bh_(self): + """ + If any post-processing is needed to pass a histogram between libraries, a + subclass can implement it here. self is the new instance in the current + (converted-to) class. + """ + + @classmethod + def _export_bh_(cls, self): + """ + If any preparation is needed to pass a histogram between libraries, a subclass can + implement it here. cls is the current class being converted from, and self is the + instance in the class being converted to. + """ + def _generate_axes_(self): """ This is called to fill in the axes. Subclasses can override it if they need diff --git a/src/boost_histogram/axis/__init__.py b/src/boost_histogram/axis/__init__.py index fb657a47e..2b6a6e655 100644 --- a/src/boost_histogram/axis/__init__.py +++ b/src/boost_histogram/axis/__init__.py @@ -10,7 +10,7 @@ StrCategory, Boolean, ) -from .._internal.axistuple import ArrayTuple, AxesTuple +from .._internal.axestuple import ArrayTuple, AxesTuple from .._core.axis import options from . import transform diff --git a/tests/test_histogram_indexing.py b/tests/test_histogram_indexing.py index 3d3a1f9da..1925f5bf2 100644 --- a/tests/test_histogram_indexing.py +++ b/tests/test_histogram_indexing.py @@ -331,7 +331,7 @@ def test_pick_int_category(): def test_axes_tuple(): h = bh.Histogram(bh.axis.Regular(10, 0, 1)) - assert isinstance(h.axes[:1], bh._internal.axistuple.AxesTuple) + assert isinstance(h.axes[:1], bh._internal.axestuple.AxesTuple) assert isinstance(h.axes[0], bh.axis.Regular) (before,) = h.axes.centers[:1] @@ -344,7 +344,7 @@ def test_axes_tuple_Nd(): h = bh.Histogram( bh.axis.Integer(0, 5), bh.axis.Integer(0, 4), bh.axis.Integer(0, 6) ) - assert isinstance(h.axes[:2], bh._internal.axistuple.AxesTuple) + assert isinstance(h.axes[:2], bh._internal.axestuple.AxesTuple) assert isinstance(h.axes[1], bh.axis.Integer) b1, b2 = h.axes.centers[1:3] diff --git a/tests/test_minihist_title.py b/tests/test_minihist_title.py new file mode 100644 index 000000000..1d060bf48 --- /dev/null +++ b/tests/test_minihist_title.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# The point of this test is to make sure that the infrastructure for supporting +# custom attributes, like title in Hist, is working. + +import pytest +import boost_histogram as bh + + +# First, make a new family to identify your library +CUSTOM_FAMILY = object() + + +# Add named axes +class NamedAxesTuple(bh.axis.AxesTuple): + __slots__ = () + + def _get_index_by_name(self, name): + if isinstance(name, str): + for i, ax in enumerate(self): + if ax.name == name: + return i + raise KeyError("{} not found in axes".format(name)) + else: + return name + + def __getitem__(self, item): + if isinstance(item, slice): + item = slice( + self._get_index_by_name(item.start), + self._get_index_by_name(item.stop), + self._get_index_by_name(item.step), + ) + else: + item = self._get_index_by_name(item) + + return super(NamedAxesTuple, self).__getitem__(item) + + @property + def name(self): + """ + The names of the axes. May be empty strings. + """ + return tuple(ax.name for ax in self) + + +# When you subclass Histogram or an Axes, you should register your family so +# boost-histogram will know what to convert C++ objects into. + + +class AxesMixin(object): + __slots__ = () + + @property + def name(self): + """ + Get the name for the Regular axis + """ + return self._ax.metadata.get("name", "") + + +# The order of the mixin is important here - it must be first +# to override bh.axis.Regular +@bh.utils.set_family(CUSTOM_FAMILY) +class Regular(bh.axis.Regular, AxesMixin): + __slots__ = () + + def __init__( + self, bins, start, stop, name, + ): + + super(Regular, self).__init__( + bins, start, stop, + ) + + self._ax.metadata["name"] = name + + +@bh.utils.set_family(CUSTOM_FAMILY) +class CustomHist(bh.Histogram): + def _generate_axes_(self): + return NamedAxesTuple(self._axis(i) for i in range(self.ndim)) + + def __init__(self, *args, **kwargs): + super(CustomHist, self).__init__(*args, **kwargs) + valid_names = [ax.name for ax in self.axes if ax.name] + if len(valid_names) != len(set(valid_names)): + msg = "{} instance cannot contain axes with duplicated names".format( + self.__class__.__name__ + ) + raise KeyError(msg) + + +def test_hist_creation(): + hist_1 = CustomHist(Regular(10, 0, 1, name="a"), Regular(20, 0, 4, name="b")) + assert hist_1.axes[0].name == "a" + assert hist_1.axes[1].name == "b" + + hist_2 = CustomHist(Regular(10, 0, 1, name=""), Regular(20, 0, 4, name="")) + assert hist_2.axes[0].name == "" + assert hist_2.axes[1].name == "" + + with pytest.raises(KeyError): + CustomHist(Regular(10, 0, 1, name="a"), Regular(20, 0, 4, name="a")) + + +def test_hist_index(): + hist_1 = CustomHist(Regular(10, 0, 1, name="a"), Regular(20, 0, 4, name="b")) + assert hist_1.axes[0].name == "a" + assert hist_1.axes[1].name == "b" + + +def test_hist_convert(): + hist_1 = CustomHist(Regular(10, 0, 1, name="a"), Regular(20, 0, 4, name="b")) + hist_bh = bh.Histogram(hist_1) + + assert type(hist_bh.axes[0]) == bh.axis.Regular + assert hist_bh.axes[0].name == "a" + assert hist_bh.axes[1].name == "b" + + hist_2 = CustomHist(hist_bh) + + assert type(hist_2.axes[0]) == Regular + assert hist_2.axes[0].name == "a" + assert hist_2.axes[1].name == "b" + + # Just verify no-op status + hist_3 = CustomHist(hist_1) + + assert type(hist_3.axes[0]) == Regular + assert hist_3.axes[0].name == "a" + assert hist_3.axes[1].name == "b" + + +def test_access(): + hist = CustomHist(Regular(10, 0, 1, name="a"), Regular(20, 0, 4, name="b")) + + assert hist.axes["a"] == hist.axes[0] + assert hist.axes["b"] == hist.axes[1] + + from_bh = bh.Histogram(bh.axis.Regular(10, 0, 1), bh.axis.Regular(20, 0, 4)) + from_bh.axes.name = "a", "b" + hist_conv = CustomHist(from_bh) + + assert hist_conv.axes["a"] == hist_conv.axes[0] + assert hist_conv.axes["b"] == hist_conv.axes[1]