164 lines
6.1 KiB
Python
164 lines
6.1 KiB
Python
"""
|
|
@package pmsco.config
|
|
infrastructure for configurable objects
|
|
|
|
@author Matthias Muntwiler
|
|
|
|
@copyright (c) 2021-23 by Paul Scherrer Institut @n
|
|
Licensed under the Apache License, Version 2.0 (the "License"); @n
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
"""
|
|
|
|
import collections.abc
|
|
import inspect
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from string import Template
|
|
from typing import Any, Callable, Dict, Generator, Iterable, List, Mapping, Optional, Sequence, Set, Tuple, Union
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PathLike = Union[str, os.PathLike]
|
|
DataDict = Mapping[str, Union[str, int, float, Iterable, Mapping]]
|
|
|
|
|
|
def resolve_path(path: PathLike, dirs: Mapping[str, Any]):
|
|
"""
|
|
Resolve a file path by replacing placeholders.
|
|
|
|
Placeholders are enclosed in curly braces.
|
|
Values for all possible placeholders are provided in a dictionary.
|
|
|
|
@param path: str, Path or other path-like.
|
|
Example: '${work}/test/testfile.dat'.
|
|
@param dirs: Dictionary mapping placeholders to project paths.
|
|
The paths can be str, Path or other path-like
|
|
Example: {'work': '/home/user/work'}
|
|
@return: pathlib.Path object
|
|
"""
|
|
|
|
return Path(*(Template(p).substitute(dirs) for p in Path(path).parts))
|
|
|
|
|
|
class ConfigurableObject(object):
|
|
"""
|
|
Parent class for objects that can be configured from a runfile
|
|
|
|
The runfile is a JSON file that contains object data in a nested dictionary structure.
|
|
|
|
In the dictionary structure the keys are property or attribute names of the object to be initialized.
|
|
Keys starting with a non-alphabetic character (except for some special keys like __class__) are ignored.
|
|
These can be used as comments, or they protect private attributes.
|
|
|
|
The values can be numeric values, strings, lists or dictionaries.
|
|
|
|
Simple values are simply assigned using setattr.
|
|
This may call a property setter if defined.
|
|
|
|
Lists are iterated. Each item is appended to the attribute.
|
|
The attribute must implement an append method in this case.
|
|
|
|
If an item is a dictionary and contains the special key '__class__',
|
|
an object of that class is instantiated and recursively initialized with the dictionary elements.
|
|
This requires that the class can be found in the module scope passed to the parser methods,
|
|
and that the class inherits from this class.
|
|
|
|
Cases that can't be covered easily using this mechanism
|
|
should be implemented in a property setter.
|
|
Value-checking should also be done in a property setter (or the append method in sequence-like objects).
|
|
|
|
Attributes
|
|
----------
|
|
|
|
project_symbols: Dictionary of symbols that should be used to resolve class and function names.
|
|
This is usually the globals() dictionary of the project module.
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.project_symbols: Optional[Mapping[str, Any]] = None
|
|
|
|
def set_properties(self, symbols: Optional[Mapping[str, Any]],
|
|
data_dict: DataDict,
|
|
project: 'ConfigurableObject') -> None:
|
|
|
|
"""
|
|
Set properties from dictionary.
|
|
|
|
@param symbols: Dictionary of symbols that should be used to resolve class names.
|
|
This is usually the globals() dictionary of the project module.
|
|
Classes are resolved using the eval function.
|
|
@param data_dict: Dictionary of properties to set.
|
|
See the class description for details.
|
|
@param project: Reference to the project object.
|
|
@return: None
|
|
"""
|
|
|
|
self.project_symbols = symbols
|
|
for key in data_dict:
|
|
if key[0].isalpha():
|
|
self.set_property(symbols, key, data_dict[key], project)
|
|
|
|
def set_property(self, symbols: Optional[Mapping[str, Any]],
|
|
key: str,
|
|
value: DataDict,
|
|
project: 'ConfigurableObject') -> None:
|
|
|
|
"""
|
|
Set one property.
|
|
|
|
@param symbols: Dictionary of symbols that should be used to resolve class names.
|
|
This is usually the globals() dictionary of the project module.
|
|
Classes are resolved using the eval function.
|
|
@param key: Attribute name to set.
|
|
@param value: New value of the attribute.
|
|
@param project: Reference to the project object.
|
|
@return: None
|
|
"""
|
|
|
|
obj = self.parse_object(symbols, value, project)
|
|
if hasattr(self, key):
|
|
if obj is not None:
|
|
if isinstance(obj, collections.abc.MutableSequence):
|
|
attr = getattr(self, key)
|
|
for item in obj:
|
|
attr.append(item)
|
|
elif isinstance(obj, collections.abc.Mapping):
|
|
d = getattr(self, key)
|
|
if d is not None and isinstance(d, collections.abc.MutableMapping):
|
|
d.update(obj)
|
|
else:
|
|
setattr(self, key, obj)
|
|
else:
|
|
setattr(self, key, obj)
|
|
else:
|
|
setattr(self, key, obj)
|
|
else:
|
|
logger.warning(f"class {self.__class__.__name__} does not have attribute {key}.")
|
|
|
|
def parse_object(self, symbols: Optional[Mapping[str, Any]],
|
|
value: DataDict,
|
|
project: 'ConfigurableObject') -> object:
|
|
|
|
if isinstance(value, collections.abc.MutableMapping) and "__class__" in value:
|
|
cn = value["__class__"]
|
|
try:
|
|
c = eval(cn, symbols)
|
|
except (AttributeError, KeyError, NameError, ValueError):
|
|
logger.critical(f"can't resolve class name {cn}")
|
|
raise
|
|
s = inspect.signature(c)
|
|
if 'project' in s.parameters:
|
|
o = c(project=project)
|
|
else:
|
|
o = c()
|
|
o.set_properties(symbols, value, project)
|
|
elif isinstance(value, collections.abc.MutableSequence):
|
|
o = [self.parse_object(symbols, i, project) for i in value]
|
|
else:
|
|
o = value
|
|
return o
|