"""Implements ASRResult object and related quantities.
The most important class in this module is
:py:class:`asr.core.results.ASRResult`, which is used to wrap results
generated with ASR.
:py:class:`asr.core.results.ASRResult` has a bunch of associated
encoders that implements different ways of representing results, and
potentially also implements ways to decode results. These encoders are:
- :py:class:`asr.core.results.DictEncoder`
- :py:class:`asr.core.results.JSONEncoder`
- :py:class:`asr.core.results.HTMLEncoder`
- :py:class:`asr.core.results.WebPanelEncoder`
A dictionary representation of a result-object can be converted to a
result object through :py:func:`asr.core.results.dct_to_result`.
"""
from ase.io import jsonio
import copy
import typing
from abc import ABC, abstractmethod
from . import get_recipe_from_name
import importlib
import inspect
import warnings
[docs]
def read_hacked_data(dct) -> 'ObjectDescription':
"""Fix hacked results files to contain necessary metadata."""
data = {}
metadata = {}
for key, value in dct.items():
if key.startswith('__') and key.endswith('__'):
key_name = key[2:-2]
if key_name in MetaData.accepted_keys:
metadata[key_name] = value
else:
data[key] = value
obj_desc = ObjectDescription(
object_id='asr.core.results::HackedASRResult',
args=(),
kwargs={'data': data,
'metadata': metadata,
'strict': False},
)
return obj_desc
[docs]
def read_old_data(dct) -> 'ObjectDescription':
"""Parse an old style result dictionary."""
metadata = {}
data = {}
for key, value in dct.items():
if key.startswith('__') and key.endswith('__'):
key_name = key[2:-2]
if key_name in MetaData.accepted_keys:
metadata[key_name] = value
else:
data[key] = value
asr_name = metadata['asr_name']
recipe = get_recipe_from_name(asr_name)
object_description = ObjectDescription(
object_id=obj_to_id(recipe.returns),
args=(),
kwargs=dict(
data=data,
metadata=metadata,
strict=False
),
)
return object_description
[docs]
def read_new_data(dct) -> 'ObjectDescription':
"""Parse a new style result dictionary."""
object_description = ObjectDescription.fromdict(dct)
return object_description
known_object_types = {'Result'}
[docs]
def get_reader_function(dct):
"""Determine dataformat of dct and return approriate reader."""
if 'object_id' in dct:
# Then this is a new-style data-format
reader_function = read_new_data
elif '__asr_name__' in dct:
# Then this is a old-style data-format
reader_function = read_old_data
elif '__asr_hacked__' in dct:
reader_function = read_hacked_data
else:
raise UnknownDataFormat(f'Bad data={dct}')
return reader_function
[docs]
def find_class_matching_version(returns, version):
"""Find result class that matches version.
Walks through the class hierarchy defined by returns.prev_version and
searches for class fulfilling returns.version == version.
Raises :py:exc:`UnknownASRResultFormat` if no matching result is
found.
"""
# Walk through all previous implementations.
while version != returns.version and returns.prev_version is not None:
returns = returns.prev_version
if not version == returns.version:
raise UnknownASRResultFormat(
'Unknown version number: version={version}')
return returns
[docs]
def get_object_matching_obj_id(asr_obj_id):
assert asr_obj_id.startswith('asr.'), f'Invalid object id {asr_obj_id}'
module, name = asr_obj_id.split('::')
mod = importlib.import_module(module)
cls = getattr(mod, name)
return cls
[docs]
def object_description_to_object(object_description: 'ObjectDescription'):
"""Instantiate object description."""
return object_description.instantiate()
[docs]
def dct_to_result(dct: dict) -> object:
"""Convert dict representing an ASR result to corresponding result object."""
for key, value in dct.items():
if not isinstance(value, dict):
continue
try:
dct[key] = dct_to_result(value)
except UnknownDataFormat:
pass
reader_function = get_reader_function(dct)
object_description = reader_function(dct)
obj = object_description_to_object(object_description)
return obj
[docs]
class ResultEncoder(ABC):
"""Abstract encoder base class.
Encodes a results object as a specific format. Optionally
provides functionality for decoding.
"""
def __call__(self, result, *args, **kwargs):
"""Encode result."""
return self.encode(result, *args, **kwargs)
[docs]
@abstractmethod
def encode(self, result, *args, **kwargs):
"""Encode result."""
raise NotImplementedError
[docs]
def decode(self, formatted_results):
"""Decode result."""
return NotImplemented
[docs]
class JSONEncoder(ResultEncoder):
"""JSON ASRResult encoder."""
[docs]
def encode(self, result: 'ASRResult', indent=1):
"""Encode a ASRResult object as json."""
from ase.io.jsonio import MyEncoder
data = result.format_as('dict')
return MyEncoder(indent=indent).encode(data)
[docs]
def decode(self, cls, json_string: str):
"""Decode json string."""
dct = jsonio.decode(json_string)
return cls.fromdict(dct)
[docs]
class HTMLEncoder(ResultEncoder):
"""HTML ASRResult encoder."""
[docs]
def encode(self, result: 'ASRResult'):
"""Encode a ASRResult object as html."""
return str(result)
[docs]
class WebPanelEncoder(ResultEncoder):
"""Encoder for ASE compatible webpanels."""
[docs]
def encode(self, result, row, key_descriptions):
"""Make basic webpanel.
Simply prints all attributes.
"""
rows = []
for key, value in result.items():
rows.append([key, value])
table = {'type': 'table',
'header': ['key', 'value'],
'rows': rows}
columns = [[table]]
panel = {'title': 'Results',
'columns': columns,
'sort': 1}
return [panel]
[docs]
class DictEncoder(ResultEncoder):
"""Dict ASRResult encoder."""
[docs]
def encode(self, result: 'ASRResult'):
"""Encode ASRResult object as dict."""
return result.todict()
[docs]
def decode(self, cls, dct: dict):
"""Decode dict."""
return cls.fromdict(dct)
[docs]
def get_key_descriptions(obj):
"""Get key descriptions of object."""
if hasattr(obj, 'key_descriptions'):
return obj.key_descriptions
return {}
[docs]
def get_object_types(obj):
"""Get type hints of object."""
return typing.get_type_hints(obj)
[docs]
def make_property(key, doc, return_type):
def getter(self) -> return_type:
return self.data[key]
getter.__annotations__ = {'return': return_type}
def setter(self, value) -> None:
if self.data:
raise AttributeError(
f'Data was already set. You cannot overwrite/set data.'
)
self.data[key] = value
return property(fget=getter, fset=setter, doc=doc)
[docs]
def prepare_result(cls: object) -> str:
"""Prepare result class.
This function read key descriptions and types defined in a Result class and
assigns properties to all keys. It also sets strict=True used by the
result object to ensure all data is present. It also changes the signature
of the class to something more helpful than args, kwargs.
"""
descriptions = get_key_descriptions(cls)
types = get_object_types(cls)
type_keys = set(types)
description_keys = set(descriptions)
missing_types = description_keys - type_keys
assert not missing_types, f'{cls.get_obj_id()}: Missing types for={missing_types}.'
data_keys = description_keys
for key in descriptions:
description = descriptions[key]
attr_type = types[key]
setattr(cls, key, make_property(key, description, return_type=attr_type))
sig = inspect.signature(cls.__init__)
parameters = [list(sig.parameters.values())[0]] + [
inspect.Parameter(key, inspect.Parameter.POSITIONAL_OR_KEYWORD)
for key in data_keys
]
sig = sig.replace(parameters=parameters)
def __init__(self, *args, **kwargs):
return super(type(self), self).__init__(*args, **kwargs)
cls.__init__ = __init__
cls.__init__.__signature__ = sig
cls.strict = True
cls._known_data_keys = data_keys
return cls
[docs]
def obj_to_id(cls):
"""Get a string representation of path to object.
Ie. if obj is the ASRResult class living in the module, asr.core.results,
the correspinding string would be 'asr.core.results::ASRResult'.
"""
return f'{cls.__module__}::{cls.__name__}'
[docs]
class ObjectDescription:
"""Result object descriptor."""
[docs]
def __init__(self, object_id: str, args: tuple, kwargs: dict,
constructor: typing.Optional[str] = None):
"""Initialize instance.
Parameters
----------
object_id: str
ID of object, eg. 'asr.core.results::ASRResult' as
produced by :py:func:`obj_to_id`.
args
Arguments for object construction.
kwargs
Keyword arguments for object construction.
constructor: str or None
ID of constructor object, ie. callable that can be used to
instantiate object. If unset use constructor=object_id.
"""
self._data = {
'object_id': copy.copy(object_id),
'constructor': (copy.copy(constructor) if constructor
else copy.copy(object_id)),
'args': copy.deepcopy(args),
'kwargs': copy.deepcopy(kwargs),
}
@property
def object_id(self):
"""Get object id."""
return self._data['object_id']
@property
def constructor(self):
"""Get object id."""
return self._data['constructor']
@property
def args(self):
"""Get extra arguments supplied to constructor."""
return self._data['args']
@property
def kwargs(self):
"""Get extra arguments supplied to constructor."""
return self._data['kwargs']
[docs]
def todict(self):
"""Convert object description to dictionary."""
return copy.deepcopy(self._data)
[docs]
@classmethod
def fromdict(cls, dct):
"""Instantiate ObjectDescription from dict."""
return cls(**dct)
[docs]
def instantiate(self):
"""Instantiate object."""
cls = get_object_matching_obj_id(self.constructor)
return cls(*self.args, **self.kwargs)
[docs]
def data_to_dict(dct):
"""Recursively .todict all result instances."""
for key, value in dct.items():
if isinstance(value, ASRResult):
dct[key] = value.todict()
data_to_dict(dct[key])
return dct
[docs]
class ASRResult(object):
"""Base class for describing results generated with recipes.
A results object is a container for results generated with ASR. It
contains data and metadata describing results and the
circumstances under which the result were generated,
respectively. The metadata has to be set manually through the
``metadata`` property. The wrapped data can be accessed through
the ``data`` property or directly as an attribute on the object.
The result object provides the means of presenting the wrapped
data in different formats as obtained from the ``get_formats``
method. To implement a new webpanel, inherit from this class and
overwrite the ``formats`` dictionary appropriately.
This object implements dict/namespace like default behaviour and
contained data can be check with ``in`` (see "Examples" below).
Examples
--------
>>> @prepare_result
... class Result(ASRResult):
... a: int
... key_descriptions = {'a': 'Some key description.'}
>>> result = Result.fromdata(a=1)
>>> result.metadata = {'resources': {'time': 'a good time.'}}
>>> result.a
1
>>> result['a']
1
>>> result.metadata
resources={'time': 'a good time.'}
>>> str(result)
'a=1'
>>> 'a' in result
True
>>> other_result = Result.fromdata(a=1)
>>> result == other_result
True
>>> print(format(result, 'json'))
{
"object_id": "asr.core.results::Result",
"constructor": "asr.core.results::Result",
"args": [],
"kwargs": {
"data": {
"a": 1
},
"metadata": {
"resources": {
"time": "a good time."
}
},
"strict": true
}
}
""" # noqa
version: int = 0
prev_version: typing.Any = None
key_descriptions: typing.Dict[str, str]
formats = {'json': JSONEncoder(),
'html': HTMLEncoder(),
'dict': DictEncoder(),
'str': str}
strict = False
[docs]
def __init__(self,
data: typing.Dict[str, typing.Any] = {},
metadata: typing.Dict[str, typing.Any] = {},
strict: typing.Optional[bool] = None):
"""Instantiate result.
Parameters
----------
data: Dict[str, Any]
Input data to be wrapped.
metadata: dict
Dictionary containing metadata.
strict: bool or None
Strictly enforce data entries in data.
"""
strict = ((strict is None and self.strict) or strict)
if (hasattr(self, '_known_data_keys')):
data_keys = set(data)
unknown_keys = data_keys - self._known_data_keys
msg_ukwn = f'{self.get_obj_id()}: Trying to set unknown keys={unknown_keys}'
missing_keys = self._known_data_keys - data_keys
msg_miss = f'{self.get_obj_id()}: Missing data keys={missing_keys}'
if strict:
assert not missing_keys, msg_miss
assert not unknown_keys, msg_ukwn
else:
if unknown_keys:
warnings.warn(msg_ukwn)
if missing_keys:
warnings.warn(msg_miss)
self.strict = strict
self._data = data
self._metadata = MetaData()
self.metadata.set(**metadata)
[docs]
@classmethod
def fromdata(cls, **data):
return cls(data=data)
[docs]
@classmethod
def get_obj_id(cls) -> str:
return obj_to_id(cls)
@property
def data(self) -> dict:
"""Get result data."""
return self._data
@property
def metadata(self) -> MetaData:
"""Get result metadata."""
return self._metadata
@metadata.setter
def metadata(self, metadata) -> None:
"""Set result metadata."""
# The following line is for copying the metadata into place.
metadata = copy.deepcopy(metadata)
self._metadata.set(**metadata)
[docs]
def get_object_desc(self) -> ObjectDescription:
"""Make ObjectDescription of this instance."""
return ObjectDescription(
object_id=obj_to_id(type(self)),
# constructor='asr.core::result_factory',
args=(),
kwargs={
'data': data_to_dict(copy.deepcopy(self.data)),
'metadata': self.metadata.todict(),
'strict': self.strict,
# 'version': self.version,
},
)
# To and from dict
[docs]
def todict(self):
object_description = self.get_object_desc()
return object_description.todict()
[docs]
@classmethod
def fromdict(cls, dct: dict):
obj_desc = ObjectDescription.fromdict(dct)
return obj_desc.instantiate()
# ---- Magic methods ----
def __getitem__(self, item):
"""Get item from self.data."""
return self.data[item]
def __contains__(self, item):
"""Determine if item in self.data."""
return item in self.data
def __iter__(self):
"""Iterate over keys."""
return self.data.__iter__()
[docs]
def get(self, key, *args):
"""Wrap self.data.get."""
return self.data.get(key, *args)
[docs]
def values(self):
"""Wrap self.data.values."""
return self.data.values()
[docs]
def items(self):
"""Wrap self.data.items."""
return self.data.items()
[docs]
def keys(self):
"""Wrap self.data.keys."""
return self.data.keys()
def __format__(self, fmt: str) -> str:
"""Encode result as string."""
formats = self.get_formats()
return formats[fmt](self)
def __str__(self):
"""Convert data to string."""
string_parts = []
for key, value in self.items():
string_parts.append(f'{key}=' + str(value))
return "\n".join(string_parts)
def __eq__(self, other):
"""Compare two result objects."""
if not isinstance(other, type(self)):
return False
return self.data == other.data
[docs]
class HackedASRResult(ASRResult):
pass