diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c30135528..8645fed05 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,12 +9,17 @@ Pressing forward to 1.0. #### User changes -* You can now set all complex storages, either on a Histogram or a View with an (N+1)D array [#475][] +* You can now set all complex storages, either on a Histogram or a View with an + (N+1)D array [#475][] +* Axes are now normal `__dict__` classes, you can manipulate the `__dict__` as + normal. Axes construction now lets you either use the old metadata shortcut + or the `__dict__` inline. [#477][] #### Bug fixes * Fixed issue if final bin of Variable histogram was infinite by updating to Boost 1.75 [#470][] * NumPy arrays can be used for weights in `bh.numpy` [#472][] +* Vectorization for WeightedMean accumulators was broken [#475][] #### Developer changes @@ -24,6 +29,7 @@ Pressing forward to 1.0. [#470]: https://github.com/scikit-hep/boost-histogram/pull/470 [#472]: https://github.com/scikit-hep/boost-histogram/pull/472 [#475]: https://github.com/scikit-hep/boost-histogram/pull/475 +[#477]: https://github.com/scikit-hep/boost-histogram/pull/477 ## Version 0.11 diff --git a/src/boost_histogram/_internal/axestuple.py b/src/boost_histogram/_internal/axestuple.py index 2c703d89c..79b16e354 100644 --- a/src/boost_histogram/_internal/axestuple.py +++ b/src/boost_histogram/_internal/axestuple.py @@ -94,7 +94,7 @@ def __getitem__(self, 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) + return self.__class__(getattr(s, attr) for s in self) def __setattr__(self, attr, values): return self.__class__(s.__setattr__(attr, v) for s, v in zip(self, values)) diff --git a/src/boost_histogram/_internal/axis.py b/src/boost_histogram/_internal/axis.py index e6e1ec9dd..07cd2153a 100644 --- a/src/boost_histogram/_internal/axis.py +++ b/src/boost_histogram/_internal/axis.py @@ -30,35 +30,52 @@ def _isstr(value): # Contains common methods and properties to all axes @set_module("boost_histogram.axis") class Axis(object): - __slots__ = ("_ax",) + __slots__ = ("_ax", "__dict__") - def __copy__(self): - other = self.__class__.__new__(self.__class__) - other._ax = copy.copy(self._ax) - return other + def __setattr__(self, attr, value): + if attr == "__dict__": + self._ax.metadata = value + object.__setattr__(self, attr, value) - def __getattr__(self, item): - if item == "_ax": - return Axis.__dict__[item].__get__(self) - elif item in self._ax.metadata: - return self._ax.metadata[item] - elif item == "metadata": + def __getattr__(self, attr): + if attr == "metadata": return None - else: - msg = "'{}' object has no attribute '{}' in {}".format( - type(self).__name__, item, set(self._ax.metadata) + raise AttributeError( + "object {0} has not attribute {1}".format(self.__class__.__name__, attr) + ) + + def __init__(self, ax, metadata, __dict__): + """ + ax: the C++ object + metadata: the metadata keyword contents + __dict__: the __dict__ keyword contents + """ + + self._ax = ax + + if __dict__ is not None and metadata is not None: + raise KeyError( + "Cannot provide metadata by keyword and __dict__, use __dict__ only" ) - raise AttributeError(msg) + elif __dict__ is not None: + self._ax.metadata = __dict__ + elif metadata is not None: + self._ax.metadata["metadata"] = metadata - def __setattr__(self, item, value): - if item == "_ax": - Axis.__dict__[item].__set__(self, value) - else: - self._ax.metadata[item] = value + self.__dict__ = self._ax.metadata + + def __setstate__(self, state): + self._ax = state["_ax"] + self.__dict__ = self._ax.metadata + + def __getstate__(self): + return {"_ax": self._ax} - def __dir__(self): - metadata = list(self._ax.metadata) - return sorted(dir(type(self)) + metadata) + def __copy__(self): + other = self.__class__.__new__(self.__class__) + other._ax = copy.copy(self._ax) + other.__dict__ = other._ax.metadata + return other def index(self, value): """ @@ -100,6 +117,7 @@ def __ne__(self, other): def _convert_cpp(cls, cpp_object): nice_ax = cls.__new__(cls) nice_ax._ax = cpp_object + nice_ax.__dict__ = cpp_object.metadata return nice_ax def __len__(self): @@ -230,7 +248,7 @@ class Regular(Axis): __slots__ = () @inject_signature( - "self, bins, start, stop, *, metadata=None, underflow=True, overflow=True, growth=False, circular=False, transform=None" + "self, bins, start, stop, *, metadata=None, underflow=True, overflow=True, growth=False, circular=False, transform=None, __dict__=None" ) def __init__(self, bins, start, stop, **kwargs): """ @@ -258,11 +276,14 @@ def __init__(self, bins, start, stop, **kwargs): Filling wraps around. transform : Optional[AxisTransform] = None Transform the regular bins (Log, Sqrt, and Pow(v)) + __dict__: Optional[Dict[str, Any]] = None + The full metadata dictionary """ with KWArgs(kwargs) as k: metadata = k.optional("metadata") transform = k.optional("transform") + __dict__ = k.optional("__dict__") options = k.options( underflow=True, overflow=True, growth=False, circular=False ) @@ -277,29 +298,29 @@ def __init__(self, bins, start, stop, **kwargs): ): raise TypeError("You must pass an instance, use {}()".format(transform)) - self._ax = transform._produce(bins, start, stop) + ax = transform._produce(bins, start, stop) elif options == {"growth", "underflow", "overflow"}: - self._ax = ca.regular_uoflow_growth(bins, start, stop) + ax = ca.regular_uoflow_growth(bins, start, stop) elif options == {"underflow", "overflow"}: - self._ax = ca.regular_uoflow(bins, start, stop) + ax = ca.regular_uoflow(bins, start, stop) elif options == {"underflow"}: - self._ax = ca.regular_uflow(bins, start, stop) + ax = ca.regular_uflow(bins, start, stop) elif options == {"overflow"}: - self._ax = ca.regular_oflow(bins, start, stop) + ax = ca.regular_oflow(bins, start, stop) elif options == {"circular", "underflow", "overflow"} or options == { "circular", "overflow", }: # growth=True, underflow=False is also correct - self._ax = ca.regular_circular(bins, start, stop) + ax = ca.regular_circular(bins, start, stop) elif options == set(): - self._ax = ca.regular_none(bins, start, stop) + ax = ca.regular_none(bins, start, stop) else: raise KeyError("Unsupported collection of options") - self.metadata = metadata + super(Regular, self).__init__(ax, metadata, __dict__) def _repr_args(self): "Return inner part of signature for use in repr" @@ -339,7 +360,7 @@ class Variable(Axis): __slots__ = () @inject_signature( - "self, edges, *, metadata=None, underflow=True, overflow=True, growth=False" + "self, edges, *, metadata=None, underflow=True, overflow=True, growth=False, __dict__=None" ) def __init__(self, edges, **kwargs): """ @@ -361,33 +382,37 @@ def __init__(self, edges, **kwargs): growth : bool = False Allow the axis to grow if a value is encountered out of range. Be careful, the axis will grow as large as needed. + __dict__: Optional[Dict[str, Any]] = None + The full metadata dictionary """ + with KWArgs(kwargs) as k: metadata = k.optional("metadata") + __dict__ = k.optional("__dict__") options = k.options( underflow=True, overflow=True, circular=False, growth=False ) if options == {"growth", "underflow", "overflow"}: - self._ax = ca.variable_uoflow_growth(edges) + ax = ca.variable_uoflow_growth(edges) elif options == {"underflow", "overflow"}: - self._ax = ca.variable_uoflow(edges) + ax = ca.variable_uoflow(edges) elif options == {"underflow"}: - self._ax = ca.variable_uflow(edges) + ax = ca.variable_uflow(edges) elif options == {"overflow"}: - self._ax = ca.variable_oflow(edges) + ax = ca.variable_oflow(edges) elif options == {"circular", "underflow", "overflow",} or options == { "circular", "overflow", }: # growth=True, underflow=False is also correct - self._ax = ca.variable_circular(edges) + ax = ca.variable_circular(edges) elif options == set(): - self._ax = ca.variable_none(edges) + ax = ca.variable_none(edges) else: raise KeyError("Unsupported collection of options") - self.metadata = metadata + super(Variable, self).__init__(ax, metadata, __dict__) def _repr_args(self): "Return inner part of signature for use in repr" @@ -414,7 +439,7 @@ class Integer(Axis): __slots__ = () @inject_signature( - "self, start, stop, *, metadata=None, underflow=True, overflow=True, growth=False" + "self, start, stop, *, metadata=None, underflow=True, overflow=True, growth=False, __dict__=None" ) def __init__(self, start, stop, **kwargs): """ @@ -437,9 +462,13 @@ def __init__(self, start, stop, **kwargs): growth : bool = False Allow the axis to grow if a value is encountered out of range. Be careful, the axis will grow as large as needed. + __dict__: Optional[Dict[str, Any]] = None + The full metadata dictionary """ + with KWArgs(kwargs) as k: metadata = k.optional("metadata") + __dict__ = k.optional("__dict__") options = k.options( underflow=True, overflow=True, circular=False, growth=False ) @@ -447,21 +476,21 @@ def __init__(self, start, stop, **kwargs): # underflow and overflow settings are ignored, integers are always # finite and thus cannot end up in a flow bin when growth is on if "growth" in options and "circular" not in options: - self._ax = ca.integer_growth(start, stop) + ax = ca.integer_growth(start, stop) elif options == {"underflow", "overflow"}: - self._ax = ca.integer_uoflow(start, stop) + ax = ca.integer_uoflow(start, stop) elif options == {"underflow"}: - self._ax = ca.integer_uflow(start, stop) + ax = ca.integer_uflow(start, stop) elif options == {"overflow"}: - self._ax = ca.integer_oflow(start, stop) + ax = ca.integer_oflow(start, stop) elif "circular" in options and "growth" not in options: - self._ax = ca.integer_circular(start, stop) + ax = ca.integer_circular(start, stop) elif options == set(): - self._ax = ca.integer_none(start, stop) + ax = ca.integer_none(start, stop) else: raise KeyError("Unsupported collection of options") - self.metadata = metadata + super(Integer, self).__init__(ax, metadata, __dict__) def _repr_args(self): "Return inner part of signature for use in repr" @@ -495,7 +524,9 @@ def _repr_kwargs(self): @set_module("boost_histogram.axis") @register({ca.category_str_growth, ca.category_str}) class StrCategory(BaseCategory): - @inject_signature("self, categories, *, metadata=None, growth=False") + __slots__ = () + + @inject_signature("self, categories, *, metadata=None, growth=False, __dict__=None") def __init__(self, categories, **kwargs): """ Make a category axis with strings; items will @@ -512,22 +543,26 @@ def __init__(self, categories, **kwargs): growth : bool = False Allow the axis to grow if a value is encountered out of range. Be careful, the axis will grow as large as needed. + __dict__: Optional[Dict[str, Any]] = None + The full metadata dictionary """ + with KWArgs(kwargs) as k: metadata = k.optional("metadata") + __dict__ = k.optional("__dict__") options = k.options(growth=False) # henryiii: We currently expand "abc" to "a", "b", "c" - some # Python interfaces protect against that if options == {"growth"}: - self._ax = ca.category_str_growth(tuple(categories)) + ax = ca.category_str_growth(tuple(categories)) elif options == set(): - self._ax = ca.category_str(tuple(categories)) + ax = ca.category_str(tuple(categories)) else: raise KeyError("Unsupported collection of options") - self.metadata = metadata + super(StrCategory, self).__init__(ax, metadata, __dict__) def index(self, value): """ @@ -553,7 +588,9 @@ def _repr_args(self): @set_module("boost_histogram.axis") @register({ca.category_int, ca.category_int_growth}) class IntCategory(BaseCategory): - @inject_signature("self, categories, *, metadata=None, growth=False") + __slots__ = () + + @inject_signature("self, categories, *, metadata=None, growth=False, __dict__=None") def __init__(self, categories, **kwargs): """ Make a category axis with ints; items will @@ -570,19 +607,23 @@ def __init__(self, categories, **kwargs): growth : bool = False Allow the axis to grow if a value is encountered out of range. Be careful, the axis will grow as large as needed. + __dict__: Optional[Dict[str, Any]] = None + The full metadata dictionary """ + with KWArgs(kwargs) as k: metadata = k.optional("metadata") + __dict__ = k.optional("__dict__") options = k.options(growth=False) if options == {"growth"}: - self._ax = ca.category_int_growth(tuple(categories)) + ax = ca.category_int_growth(tuple(categories)) elif options == set(): - self._ax = ca.category_int(tuple(categories)) + ax = ca.category_int(tuple(categories)) else: raise KeyError("Unsupported collection of options") - self.metadata = metadata + super(IntCategory, self).__init__(ax, metadata, __dict__) def _repr_args(self): "Return inner part of signature for use in repr" @@ -597,7 +638,7 @@ def _repr_args(self): class Boolean(Axis): __slots__ = () - @inject_signature("self, *, metadata=None") + @inject_signature("self, *, metadata=None, __dict__=None") def __init__(self, **kwargs): """ Make an axis for boolean values. @@ -606,12 +647,17 @@ def __init__(self, **kwargs): ---------- metadata : object Any Python object to attach to the axis, like a label. + __dict__: Optional[Dict[str, Any]] = None + The full metadata dictionary """ + with KWArgs(kwargs) as k: metadata = k.optional("metadata") + __dict__ = k.optional("__dict__") + + ax = ca.boolean() - self._ax = ca.boolean() - self.metadata = metadata + super(Boolean, self).__init__(ax, metadata, __dict__) def _repr_args(self): "Return inner part of signature for use in repr" diff --git a/src/boost_histogram/_internal/hist.py b/src/boost_histogram/_internal/hist.py index 91bb04db5..8a1d93d8a 100644 --- a/src/boost_histogram/_internal/hist.py +++ b/src/boost_histogram/_internal/hist.py @@ -154,7 +154,7 @@ 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) + ax.__dict__ = copy.copy(ax._ax.metadata) # Allow custom behavior on either "from" or "to" h._export_bh_(self) @@ -198,9 +198,9 @@ def _new_hist(self, _hist, memo=NOTHING): other.axes = other._generate_axes_() for ax in other.axes: if memo is NOTHING: - ax._ax.metadata = copy.copy(ax._ax.metadata) + ax.__dict__ = copy.copy(ax._ax.metadata) else: - ax._ax.metadata = copy.deepcopy(ax._ax.metadata, memo) + ax.__dict__ = copy.deepcopy(ax._ax.metadata, memo) return other @property @@ -440,9 +440,9 @@ def __setstate__(self, state): else: # Classic (0.10 and before) state self._hist = state["_hist"] self.metadata = state.get("metadata", None) + for i in range(self._hist.rank()): + self._hist.axis(i).metadata = {"metadata": self._hist.axis(i).metadata} self.axes = self._generate_axes_() - for ax in self.axes: - ax._ax.metadata = {"metadata": ax._ax.metadata} def __repr__(self): newline = "\n " diff --git a/tests/test_axis.py b/tests/test_axis.py index 982620e5a..e657697b7 100644 --- a/tests/test_axis.py +++ b/tests/test_axis.py @@ -3,6 +3,7 @@ from pytest import approx import boost_histogram as bh +import boost_histogram.utils import numpy as np from numpy.testing import assert_array_equal, assert_allclose @@ -62,7 +63,36 @@ def test_metadata(axis, args, opt, kwargs): assert axis(*args, **kwargs) == axis(*args, **kwargs) assert axis(*args, **kwargs) != axis(*args, metadata="bar") + del kwargs["metadata"] + ax = axis(*args, __dict__={"metadata": 3, "other": 2}) + assert ax.metadata == 3 + assert ax.other == 2 + + del ax.__dict__ + assert ax.__dict__ == {} + assert ax.metadata is None + + ax.__dict__ = {"metadata": 5} + assert ax.__dict__ == {"metadata": 5} + assert ax.metadata == 5 + + # Python 2 does not allow mixing ** and kw + new_kwargs = copy.copy(kwargs) + new_kwargs["__dict__"] = {"something": 2} + new_kwargs["metadata"] = 3 + with pytest.raises(KeyError): + axis(*args, **new_kwargs) + + new_kwargs = copy.copy(kwargs) + new_kwargs["__dict__"] = {"metadata": 2} + new_kwargs["metadata"] = 3 + with pytest.raises(KeyError): + axis(*args, **new_kwargs) + + +# The point of this ABC is to force all the tests listed here to be +# implemented for each axis type. class Axis(ABC): @abc.abstractmethod def test_init(self): diff --git a/tests/test_subclassing.py b/tests/test_subclassing.py index 63316149c..00d110f1d 100644 --- a/tests/test_subclassing.py +++ b/tests/test_subclassing.py @@ -11,7 +11,7 @@ class MyHist(bh.Histogram): @bh.utils.set_family(NEW_FAMILY) class MyRegular(bh.axis.Regular): - pass + __slots__ = () @bh.utils.set_family(NEW_FAMILY) class MyIntStorage(bh.storage.Int64):