"""Classes supporting the definition and use of custom operations in SimPhoNy.
This file contains an `Operations` abstract class that wrapper or package
developers can use to implement specific functionality for certain ontology
classes (e.g. download and upload commands for files, multiplying EMMO
vectors, ...).
Instances of the `OperationsNamespace` class are accessed as the `operations`
property of ontology individuals. The `OperationsNamespace` instances let the
user access the operations defined for each ontology individual. Each
individual has an associated instance of the subclass of `Operations` that the
wrapper or package developer has defined.
"""
from __future__ import annotations
import os
import pkgutil
import sys
from abc import ABC, abstractmethod
from collections.abc import Mapping
from functools import wraps
from importlib import util
from pathlib import Path
from types import ModuleType
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Generator,
Iterable,
Iterator,
List,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
)
from rdflib import URIRef
if sys.version_info < (3, 8):
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points
if TYPE_CHECKING:
from simphony_osp.ontology import OntologyIndividual
__all__ = [
"Operations",
"OperationsNamespace",
"find_operations",
]
_catalog: Dict[URIRef, Dict[str, Tuple[Type, Callable]]] = dict()
"""Holds the operations associated with each ontology class."""
_initialized: List[bool] = [False]
"""True when the installed operations have already been loaded."""
def catalog(func):
"""Initialize the catalog lazily.
This decorator is meant to decorate functions that write to or access the
catalog, so that the installed operations can be loaded lazily on the
first access/write. This is useful to prevent headaches with import cycles.
"""
@wraps(func)
def wrapper(*args, **kwargs):
if _initialized[0] is False:
_initialized[0] = True
_load_operations()
return func(*args, **kwargs)
return wrapper
def _load_operations():
"""Finds the installed operations and registers them in the catalog."""
# Retrieve operations from package entry points.
package_entry_points = entry_points()
if sys.version_info >= (3, 10):
operations = package_entry_points.select(
group="simphony_osp.ontology.operations"
)
else:
operations = package_entry_points.get(
"simphony_osp.ontology.operations", tuple()
)
del package_entry_points
operations = {
entry_point.name: entry_point.load() for entry_point in operations
}
for name, operations in operations.items():
register(operations, operations.iri)
del operations
# Retrieve operations from the operation folder in the user's home
# directory.
path = (
os.environ.get("SIMPHONY_OPERATIONS_DIR")
or Path.home() / ".simphony-osp" / "operations"
)
operations = find_operations_in_operations_folder(path)
for operations in operations:
register(operations, operations.iri)
del operations
@catalog
def get(
item: Union[str, URIRef], default: Optional[Any] = None
) -> Union[Dict[str, Tuple[Type, Callable]], Any]:
"""Get the methods registered for the given identifier.
Args:
item: Identifier to get the methods for.
default: Default value to return when the identifier is not registered.
Raises:
KeyError: Identifier not registered and no default provided.
"""
item = URIRef(item)
return _catalog.get(item, default)
@catalog
def register(
class_: Type[Operations], identifier: Union[str, Iterable[str]]
) -> None:
"""Register an `Operations` class in the catalog.
Args:
class_: The `Operations` class to register.
identifier: The identifier (or identifiers) that will be
registered as associated with the given `Operations` class.
Raises:
RuntimeError: Tried to register two methods with the same name for the
same identifier.
"""
identifiers = (
(URIRef(identifier),) if isinstance(identifier, str) else identifier
)
methods = class_.__simphony_operations__()
for identifier in identifiers:
catalog_entry = _catalog.get(identifier, dict())
# Raise exception if two methods with the same name are registered
conflicts = set(catalog_entry) & set(methods)
if conflicts:
raise RuntimeError(
f"Methods {','.join(conflicts)} defined twice for class "
f"{identifier}."
)
# Put the operations on the catalog
catalog_entry.update(
{name: (class_, method) for name, method in methods.items()}
)
_catalog[identifier] = catalog_entry
class Operations(ABC):
"""Define operations for an ontology class."""
@property
@abstractmethod
def iri(self) -> Union[str, Iterable[str]]:
"""IRI of the ontology class for which operations should be registered.
It is also possible to define several IRIs at once (by returning an
iterable).
"""
pass
[docs] def __init__(self, individual: OntologyIndividual):
"""Initialization of your instance of the operations.
It is recommended to save the individual that is received as an
argument to an instance attribute, as the operations to be executed are
supposed to be related to it.
"""
self._individual = individual
@classmethod
def __simphony_operations__(cls) -> Dict[str, Union[Callable, property]]:
"""Magic method that returns the operations defined on this class."""
dir_operations = dir(Operations)
methods = {
name: getattr(cls, name)
for name in dir(cls)
if not (name.startswith("_") or name in dir_operations)
}
return methods
class OperationsNamespace(Mapping):
"""Access the operations associated to an ontology individual.
Instances of the `OperationsNamespace` class are accessed as the
`operations` property of ontology individuals. The `OperationsNamespace`
instances let the user access the operations defined for each ontology
individual. Each individual has an associated instance of the subclass of
`Operations` that the wrapper or package developer has defined.
"""
_individual: OntologyIndividual
_instances: Dict[Type, Operations]
def __init__(self, individual: OntologyIndividual):
"""Initialize the `OperationsNamespace`."""
self._instances = dict()
self._individual = individual
def __getattr__(self, item: str) -> Any:
"""Get an operation by name using dot notation."""
try:
result = self[item]
except KeyError as e:
raise AttributeError(str(e)) from e
return result
def __setattr__(self, item: str, value: Any) -> None:
"""Set the value of operation's property."""
if item.startswith("_"):
super().__setattr__(item, value)
return
try:
self[item] = value
except KeyError as e:
raise AttributeError(str(e)) from e
def __getitem__(self, key: str) -> Union[Callable, Any]:
"""Get an operation by name using brackets."""
method, instance = self._method_and_instance(key)
if isinstance(method, property):
result = getattr(instance, key)
else:
@wraps(method)
def function(*args, **kwargs):
return method(instance, *args, **kwargs)
result = function
return result
def __setitem__(self, key, value) -> None:
"""Set an operation's property using brackets."""
method, instance = self._method_and_instance(key)
if isinstance(method, property):
setattr(instance, key, value)
else:
raise AttributeError(f"operation '{key}' is not writable")
def __len__(self) -> int:
"""Number of operations available for the individual."""
return sum(1 for _ in self)
def __iter__(self) -> Iterator[str]:
"""Iterate over the names of the available operations."""
yield from {name for name, class_, method in self._all_methods()}
def _method_and_instance(self, key: str) -> Tuple[Callable, Operations]:
"""Returns the method and operation instance for a given name.
Searches the catalog for methods with names that match the given key
and returns both the method and the instance of the `Operations`
class to which such method belongs.
"""
results = {
(class_, method)
for name, class_, method in self._all_methods()
if name == key
}
if len(results) > 1:
raise RuntimeError(
f"More than one operation available under the name {key} for "
f"individual {self._individual} of classes "
f"{','.join(str(x) for x in self._individual.classes)} "
f"available ."
)
elif len(results) == 0:
raise KeyError(
f"No operation with name {key} available for "
f"{self._individual} of classes "
f"{','.join(str(x) for x in self._individual.classes)}."
)
class_, method = results.pop()
if class_ not in self._instances:
self._instances[class_] = class_(individual=self._individual)
instance = self._instances[class_]
return method, instance
def _all_methods(self) -> Set[Tuple[str, Type, Callable]]:
"""Get a set will all the available operations for the individual."""
classes = (
class_.identifier for class_ in self._individual.superclasses
)
results = {
(name, class_, method)
for identifier in classes
for name, (class_, method) in get(identifier, dict()).items()
}
return results
OPERATIONS = TypeVar("OPERATIONS", bound=Operations)
def find_operations_in_package(
path: Union[str, Path]
) -> Generator[Type[OPERATIONS]]:
"""Find operations on a Python package.
Given the path of a Python package (a folder containing an `__init__.py`
file), this function finds all the operations defined in the package
and yields them back.
Args:
path: location of the Python package to be scanned for operation
definitions.
Yields:
Operation definitions, that is, subclasses of the `Operations` class.
"""
package_paths = [path]
def load_submodules_recursively(paths: List[str]) -> Iterator[ModuleType]:
"""Load Python packages and all of their submodules.
Given the paths of Python packages, this function loads the package as
well as all the submodules recursively.
Args:
paths: Paths of the Python packages to load.
Yields:
The loaded Python modules.
"""
pathlib_paths = [Path(x) for x in paths]
names = {
module_info.name
for module_info in pkgutil.iter_modules(
str(x.parent.absolute()) for x in pathlib_paths
)
if module_info.ispkg
and module_info.name in {x.name for x in pathlib_paths}
}
filter_walk = (
(loader, module_name, is_pkg)
for loader, module_name, is_pkg in pkgutil.walk_packages(
str(x.parent.absolute()) for x in pathlib_paths
)
if any(module_name.startswith(name) for name in names)
)
for loader, module_name, is_pkg in filter_walk:
if module_name not in sys.modules:
spec = loader.find_spec(module_name)
module = util.module_from_spec(spec)
spec.loader.exec_module(module)
sys.modules[module_name] = module
yield module
else:
yield sys.modules[module_name]
modules = load_submodules_recursively(package_paths)
modules = {module.__name__ for module in modules}
classes = (
x for x in Operations.__subclasses__() if x.__module__ in modules
)
return classes
def find_operations_in_operations_folder(
path: Union[str, Path]
) -> Set[Type[OPERATIONS]]:
"""Find operation definitions in a folder.
Given a folder path, this function scans the folder for operation
definitions and returns them.
Args:
path: The folder path to be scanned.
Returns:
A set of operation definitions, that is, subclasses of the
`Operations` class.
"""
package_paths = [path]
prefix = "simphony_osp.ontology.operations.installed."
for loader, module_name, is_pkg in pkgutil.walk_packages(
package_paths, prefix
):
if module_name not in sys.modules:
spec = loader.find_spec(module_name)
module = util.module_from_spec(spec)
spec.loader.exec_module(module)
sys.modules[module_name] = module
return {
operation
for operation in Operations.__subclasses__()
if operation.__module__.startswith(prefix)
}
def find_operations(
packages: Optional[Union[List[str], str]] = None,
) -> Set[str]:
"""Generates the entry point definitions for operations.
Scans one or several packages and generates sets of strings that can be
used in `setup.py` to register SimPhoNy ontology operations.
This method is meant to ease the work that operation developers have to
do in their `setup.py` files.
Args:
packages: name(s) of the package(s) to scan. When left empty,
all packages on the working directory are scanned.
Returns:
Set of strings that can be used with the
"simphony_osp.ontology.operations" entry point.
Example:
{"File = simphony_osp.ontology.operations.file:File"}
"""
if isinstance(packages, str):
packages = [packages]
path = Path(os.getcwd()).absolute()
packages = packages or []
paths = [path / package for package in packages] or [
path / module_info.name
for module_info in pkgutil.iter_modules([str(path)])
if module_info.ispkg
]
operations = {
op for path in paths for op in find_operations_in_package(path)
}
operations = {
f"{op.__name__} = {op.__module__}:{op.__name__}" for op in operations
}
return operations