Source code for astropy.utils.metadata.core
# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""Classes for handling metadata."""
__all__ = ["MetaAttribute", "MetaData"]
import inspect
from collections import OrderedDict
from collections.abc import Mapping
from copy import deepcopy
from dataclasses import is_dataclass
[docs]
class MetaData:
"""
A descriptor for classes that have a ``meta`` property.
This can be set to any valid :class:`~collections.abc.Mapping`.
Parameters
----------
doc : `str`, optional
Documentation for the attribute of the class.
Default is ``""``.
.. versionadded:: 1.2
copy : `bool`, optional
If ``True`` the value is deepcopied before setting, otherwise it
is saved as reference.
Default is ``True``.
.. versionadded:: 1.2
default_factory : Callable[[], Mapping], optional keyword-only
The factory to use to create the default value of the ``meta``
attribute. This must be a callable that returns a `Mapping` object.
Default is `OrderedDict`, creating an empty `OrderedDict`.
.. versionadded:: 6.0
Examples
--------
``MetaData`` can be used as a descriptor to define a ``meta`` attribute`.
>>> class Foo:
... meta = MetaData()
... def __init__(self, meta=None):
... self.meta = meta
``Foo`` can be instantiated with a ``meta`` argument.
>>> foo = Foo(meta={'a': 1, 'b': 2})
>>> foo.meta
{'a': 1, 'b': 2}
The default value of ``meta`` is an empty :class:`~collections.OrderedDict`.
This can be set by passing ``None`` to the ``meta`` argument.
>>> foo = Foo()
>>> foo.meta
OrderedDict()
If an :class:`~collections.OrderedDict` is not a good default metadata type then
the ``default_factory`` keyword can be used to set the default to a different
`Mapping` type, when the class is defined.'
>>> class Bar:
... meta = MetaData(default_factory=dict)
... def __init__(self, meta=None):
... self.meta = meta
>>> Bar().meta
{}
When accessed from the class ``.meta`` returns `None` since metadata is
on the class' instances, not the class itself.
>>> print(Foo.meta)
None
"""
def __init__(self, doc="", copy=True, *, default_factory=OrderedDict):
self.__doc__ = doc
self.copy = copy
self._default_factory = default_factory
@property
def default_factory(self):
return self._default_factory
def __get__(self, instance, owner):
# class attribute access. Often, descriptors just return `self`, but if the
# owning class is a `dataclass`, the expectation is that the default is
# returned. In our case, this is None, triggering the creation of a dict-like in
# `__set__`.
if instance is None:
return None
# instance attribute access
if not hasattr(instance, "_meta"):
self.__set__(instance, None)
return instance._meta
def __set__(self, instance, value):
# The 'default' value is `None`, but we want to set it to an empty `Mapping`
# if it is `None` so that we can always assume it is a `Mapping` and not have
# to check for `None` everywhere.
if value is None:
value = self.default_factory()
# We don't want to allow setting the meta attribute to a non-dict-like object.
# NOTE: with mypyc compilation this can be removed.
elif not isinstance(value, Mapping):
raise TypeError("meta attribute must be dict-like")
# This is called when the dataclass is instantiated with a `meta` argument.
else:
value = deepcopy(value) if self.copy else value
if is_dataclass(instance) and instance.__dataclass_params__.frozen:
object.__setattr__(instance, "_meta", value)
else:
instance._meta = value
[docs]
class MetaAttribute:
"""
Descriptor to define custom attribute which gets stored in the object
``meta`` dict and can have a defined default.
This descriptor is intended to provide a convenient way to add attributes
to a subclass of a complex class such as ``Table`` or ``NDData``.
This requires that the object has an attribute ``meta`` which is a
dict-like object. The value of the MetaAttribute will be stored in a
new dict meta['__attributes__'] that is created when required.
Classes that define MetaAttributes are encouraged to support initializing
the attributes via the class ``__init__``. For example::
for attr in list(kwargs):
descr = getattr(self.__class__, attr, None)
if isinstance(descr, MetaAttribute):
setattr(self, attr, kwargs.pop(attr))
The name of a ``MetaAttribute`` cannot be the same as any of the following:
- Keyword argument in the owner class ``__init__``
- Method or attribute of the "parent class", where the parent class is
taken to be ``owner.__mro__[1]``.
Parameters
----------
default : Any, optional
Default value for the attribute, by default `None`.
"""
def __init__(self, default=None):
self.default = default
def __get__(self, instance, owner):
# When called without an instance, return self to allow access
# to descriptor attributes.
if instance is None:
return self
# If default is None and value has not been set already then return None
# without doing touching meta['__attributes__'] at all. This helps e.g.
# with the Table._hidden_columns attribute so it doesn't auto-create
# meta['__attributes__'] always.
if self.default is None and self.name not in instance.meta.get(
"__attributes__", {}
):
return None
# Get the __attributes__ dict and create if not there already.
attributes = instance.meta.setdefault("__attributes__", {})
try:
value = attributes[self.name]
except KeyError:
if self.default is not None:
attributes[self.name] = deepcopy(self.default)
# Return either specified default or None
value = attributes.get(self.name)
return value
def __set__(self, instance, value):
# Get the __attributes__ dict and create if not there already.
attributes = instance.meta.setdefault("__attributes__", {})
attributes[self.name] = value
def __delete__(self, instance):
# Remove this attribute from meta['__attributes__'] if it exists.
if "__attributes__" in instance.meta:
attrs = instance.meta["__attributes__"]
if self.name in attrs:
del attrs[self.name]
# If this was the last attribute then remove the meta key as well
if not attrs:
del instance.meta["__attributes__"]
def __set_name__(self, owner, name):
params = [
param.name
for param in inspect.signature(owner).parameters.values()
if param.kind
not in (inspect.Parameter.VAR_KEYWORD, inspect.Parameter.VAR_POSITIONAL)
]
# Reject names from existing params or best guess at parent class
if name in params or hasattr(owner.__mro__[1], name):
raise ValueError(f"{name} not allowed as {self.__class__.__name__}")
self.name = name
def __repr__(self):
return f"<{self.__class__.__name__} name={self.name} default={self.default}>"