-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathoperations.py
More file actions
465 lines (375 loc) · 14.5 KB
/
Copy pathoperations.py
File metadata and controls
465 lines (375 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
"""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) or sys.version_info < (3, 8):
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
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