ocelescope-module-plugin 0.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ocelescope_module_plugin/__init__.py +0 -0
- ocelescope_module_plugin/archive.py +99 -0
- ocelescope_module_plugin/base_plugin.py +105 -0
- ocelescope_module_plugin/config.py +14 -0
- ocelescope_module_plugin/dependencies.py +27 -0
- ocelescope_module_plugin/dynamic_import.py +56 -0
- ocelescope_module_plugin/hashing.py +41 -0
- ocelescope_module_plugin/models/__init__.py +0 -0
- ocelescope_module_plugin/models/plugin.py +97 -0
- ocelescope_module_plugin/models/plugin_result.py +47 -0
- ocelescope_module_plugin/module.py +57 -0
- ocelescope_module_plugin/ocel_visualization.py +68 -0
- ocelescope_module_plugin/registry.py +273 -0
- ocelescope_module_plugin/results.py +21 -0
- ocelescope_module_plugin/routers.py +258 -0
- ocelescope_module_plugin/tasks.py +188 -0
- ocelescope_module_plugin-0.12.0.dist-info/METADATA +23 -0
- ocelescope_module_plugin-0.12.0.dist-info/RECORD +20 -0
- ocelescope_module_plugin-0.12.0.dist-info/WHEEL +4 -0
- ocelescope_module_plugin-0.12.0.dist-info/entry_points.txt +3 -0
|
File without changes
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import tempfile
|
|
3
|
+
import traceback
|
|
4
|
+
import zipfile
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from ocelescope_backend.app.internal.config import config as backend_config
|
|
9
|
+
from ocelescope_backend.app.internal.util.archive import extract_archive, is_ignored
|
|
10
|
+
from ocelescope_backend.app.sse_manager import (
|
|
11
|
+
ErrorNotification,
|
|
12
|
+
InvalidationRequest,
|
|
13
|
+
SSEMessage,
|
|
14
|
+
SystemNotification,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from ocelescope_module_plugin.config import config
|
|
18
|
+
from ocelescope_module_plugin.registry import PluginRegistry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_plugin_archive(archive: zipfile.ZipFile) -> bool:
|
|
22
|
+
return any(
|
|
23
|
+
len(path.parts) == 2 and path.name == "__init__.py" and not is_ignored(path)
|
|
24
|
+
for path in map(PurePosixPath, archive.namelist())
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def import_plugin_archive(
|
|
29
|
+
registry: PluginRegistry, archive: zipfile.ZipFile, file_name: str
|
|
30
|
+
) -> list[SSEMessage]:
|
|
31
|
+
if not config.PLUGIN_DIR:
|
|
32
|
+
raise RuntimeError("Plugin uploads are disabled on this deployment")
|
|
33
|
+
|
|
34
|
+
added_plugin_ids = []
|
|
35
|
+
|
|
36
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
37
|
+
extract_archive(
|
|
38
|
+
archive,
|
|
39
|
+
Path(temp_dir),
|
|
40
|
+
max_uncompressed_bytes=backend_config.ARCHIVE_MAX_UNCOMPRESSED_BYTES,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
for plugin_candidate in Path(temp_dir).iterdir():
|
|
44
|
+
if (
|
|
45
|
+
plugin_candidate.is_dir()
|
|
46
|
+
and (plugin_candidate / "__init__.py").exists()
|
|
47
|
+
):
|
|
48
|
+
plugin_id = f"plugin_{uuid4()!s}"
|
|
49
|
+
shutil.move(plugin_candidate, config.PLUGIN_DIR / plugin_id)
|
|
50
|
+
added_plugin_ids.append(plugin_id)
|
|
51
|
+
|
|
52
|
+
results: list[SSEMessage] = []
|
|
53
|
+
loaded_plugin_ids: list[str] = []
|
|
54
|
+
for plugin_id in added_plugin_ids:
|
|
55
|
+
try:
|
|
56
|
+
loaded_plugin_ids.extend(
|
|
57
|
+
registry.load_from_dir(
|
|
58
|
+
config.PLUGIN_DIR, [plugin_id], ignore_errors=False
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
results.append(
|
|
63
|
+
ErrorNotification(
|
|
64
|
+
type="error",
|
|
65
|
+
title=f"Error while uploading plugin {file_name}",
|
|
66
|
+
message=str(exc),
|
|
67
|
+
trace=traceback.format_exc(),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
loaded_plugins = [
|
|
72
|
+
plugin.label
|
|
73
|
+
for id in loaded_plugin_ids
|
|
74
|
+
if (plugin := registry.get_plugin(id)) is not None
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
if len(loaded_plugins) == 0:
|
|
78
|
+
if not results:
|
|
79
|
+
results.append(
|
|
80
|
+
SystemNotification(
|
|
81
|
+
type="notification",
|
|
82
|
+
title="Uploaded zip didn't contain any plugins",
|
|
83
|
+
notification_type="error",
|
|
84
|
+
message=f"The uploaded file {file_name} did not contain any valid plugins",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return results
|
|
88
|
+
|
|
89
|
+
results.extend(
|
|
90
|
+
[
|
|
91
|
+
SystemNotification(
|
|
92
|
+
title="Plugin successfully uploaded",
|
|
93
|
+
message=f"Successfully uploaded {' '.join(loaded_plugins)}",
|
|
94
|
+
notification_type="info",
|
|
95
|
+
),
|
|
96
|
+
InvalidationRequest(routes=["plugins", "tasks", "discoveryMethods"]),
|
|
97
|
+
]
|
|
98
|
+
)
|
|
99
|
+
return results
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from ocelescope.discovery import inductive_miner, ocdfg_miner
|
|
4
|
+
|
|
5
|
+
from ocelescope import (
|
|
6
|
+
OCEL,
|
|
7
|
+
OCEL_FIELD,
|
|
8
|
+
SLIDER_FIELD,
|
|
9
|
+
DirectlyFollowsGraph,
|
|
10
|
+
OCELAnnotation,
|
|
11
|
+
PetriNet,
|
|
12
|
+
Plugin,
|
|
13
|
+
PluginInput,
|
|
14
|
+
ResourceAnnotation,
|
|
15
|
+
plugin_method,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
Paths = Annotated[
|
|
19
|
+
float,
|
|
20
|
+
SLIDER_FIELD(
|
|
21
|
+
min=0.01,
|
|
22
|
+
max=1,
|
|
23
|
+
step=0.01,
|
|
24
|
+
default=0.5,
|
|
25
|
+
title="Paths",
|
|
26
|
+
description=(
|
|
27
|
+
"How many of the less frequent paths to include. "
|
|
28
|
+
"Lower values give a simpler model."
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
]
|
|
32
|
+
ObjectTypes = Annotated[
|
|
33
|
+
list[str],
|
|
34
|
+
OCEL_FIELD(
|
|
35
|
+
field_type="object_type",
|
|
36
|
+
ocel_id="ocel",
|
|
37
|
+
theme="r4pm",
|
|
38
|
+
title="Included object types",
|
|
39
|
+
description="Only these object types are used for discovery.",
|
|
40
|
+
default_frequency=0.5,
|
|
41
|
+
),
|
|
42
|
+
]
|
|
43
|
+
Activities = Annotated[
|
|
44
|
+
list[str],
|
|
45
|
+
OCEL_FIELD(
|
|
46
|
+
field_type="event_type",
|
|
47
|
+
ocel_id="ocel",
|
|
48
|
+
theme="r4pm",
|
|
49
|
+
title="Included activities",
|
|
50
|
+
description="Only events of these activities are used for discovery.",
|
|
51
|
+
default_frequency=0.5,
|
|
52
|
+
),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DiscoveryPetriInput(PluginInput):
|
|
57
|
+
paths: Paths
|
|
58
|
+
object_types: ObjectTypes
|
|
59
|
+
activities: Activities
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DiscoveryDFGInput(PluginInput):
|
|
63
|
+
paths: Paths
|
|
64
|
+
activities: Activities
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BasePlugin(Plugin):
|
|
68
|
+
label = "Base Discovery"
|
|
69
|
+
description = "Discover object-centric process models from an event log."
|
|
70
|
+
version = "1.0.0"
|
|
71
|
+
|
|
72
|
+
@plugin_method(
|
|
73
|
+
label="Discover Directly-Follows Graph",
|
|
74
|
+
description="Discover an object-centric directly-follows graph.",
|
|
75
|
+
)
|
|
76
|
+
def discover_ocdfg(
|
|
77
|
+
self,
|
|
78
|
+
ocel: Annotated[OCEL, OCELAnnotation(label="Event log")],
|
|
79
|
+
input: DiscoveryDFGInput,
|
|
80
|
+
) -> Annotated[
|
|
81
|
+
DirectlyFollowsGraph, ResourceAnnotation(label="Directly-Follows Graph")
|
|
82
|
+
]:
|
|
83
|
+
ocdfg = ocdfg_miner(
|
|
84
|
+
ocel,
|
|
85
|
+
frequency_threshold=input.paths,
|
|
86
|
+
included_activities=input.activities,
|
|
87
|
+
)
|
|
88
|
+
return ocdfg
|
|
89
|
+
|
|
90
|
+
@plugin_method(
|
|
91
|
+
label="Discover Petri Net",
|
|
92
|
+
description="Discover an object-centric Petri net using the inductive miner.",
|
|
93
|
+
)
|
|
94
|
+
def discover_ocpn(
|
|
95
|
+
self,
|
|
96
|
+
ocel: Annotated[OCEL, OCELAnnotation(label="Event log")],
|
|
97
|
+
input: DiscoveryPetriInput,
|
|
98
|
+
) -> Annotated[PetriNet, ResourceAnnotation(label="Petri Net")]:
|
|
99
|
+
return inductive_miner(
|
|
100
|
+
ocel,
|
|
101
|
+
# pm4py's noise threshold counts what to drop, the slider what to keep.
|
|
102
|
+
noise_threshold=1 - input.paths,
|
|
103
|
+
included_activities=input.activities,
|
|
104
|
+
included_object_types=input.object_types,
|
|
105
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pydantic import DirectoryPath, Field
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PluginModuleConfig(BaseSettings):
|
|
6
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
7
|
+
|
|
8
|
+
PLUGIN_DIR: DirectoryPath | None = Field(
|
|
9
|
+
default=None,
|
|
10
|
+
description="Path to the directory, where plugins are stored",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
config = PluginModuleConfig()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, Request
|
|
4
|
+
from ocelescope_backend.app.dependencies import ApiSession
|
|
5
|
+
from ocelescope_backend.app.internal.exceptions import NotFound
|
|
6
|
+
|
|
7
|
+
from ocelescope_module_plugin.registry import PluginRegistry
|
|
8
|
+
from ocelescope_module_plugin.tasks import PluginTask
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_plugin_registry(request: Request) -> PluginRegistry:
|
|
12
|
+
return request.app.state.plugin_registry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ApiPluginRegistry = Annotated[PluginRegistry, Depends(get_plugin_registry)]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_plugin_task(session: ApiSession, task_id: str) -> PluginTask:
|
|
19
|
+
plugin_task = session.get_task(task_id)
|
|
20
|
+
|
|
21
|
+
if plugin_task is None or not isinstance(plugin_task, PluginTask):
|
|
22
|
+
raise NotFound("Task could not be found")
|
|
23
|
+
|
|
24
|
+
return plugin_task
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ApiPluginTask = Annotated[PluginTask, Depends(get_plugin_task)]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import importlib.util
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from packaging import tags
|
|
10
|
+
from packaging.utils import parse_wheel_filename
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# TODO: Use packaging to make this more normed
|
|
14
|
+
def import_wheel_dynamically(wheel_path: Path):
|
|
15
|
+
"""
|
|
16
|
+
Dynamically loads a wheel (.whl) file if not already installed or imported.
|
|
17
|
+
Returns the loaded package module.
|
|
18
|
+
"""
|
|
19
|
+
with zipfile.ZipFile(wheel_path, "r") as whl:
|
|
20
|
+
names = whl.namelist()
|
|
21
|
+
pkg_names = [n.split("/")[0] for n in names if n.endswith("__init__.py")]
|
|
22
|
+
if not pkg_names:
|
|
23
|
+
raise ImportError("No package found in wheel")
|
|
24
|
+
package_name = pkg_names[0]
|
|
25
|
+
|
|
26
|
+
if package_name in sys.modules:
|
|
27
|
+
return sys.modules[package_name]
|
|
28
|
+
|
|
29
|
+
if importlib.util.find_spec(package_name) is not None:
|
|
30
|
+
return importlib.import_module(package_name)
|
|
31
|
+
|
|
32
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="dynwhl_"))
|
|
33
|
+
with zipfile.ZipFile(wheel_path, "r") as whl:
|
|
34
|
+
whl.extractall(temp_dir)
|
|
35
|
+
|
|
36
|
+
pkg_root = None
|
|
37
|
+
for root, _, files in os.walk(temp_dir):
|
|
38
|
+
if "__init__.py" in files:
|
|
39
|
+
pkg_root = Path(root)
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
if not pkg_root:
|
|
43
|
+
raise ImportError("No package with __init__.py found in wheel")
|
|
44
|
+
|
|
45
|
+
sys.path.insert(0, str(pkg_root.parent))
|
|
46
|
+
|
|
47
|
+
pkg = importlib.import_module(package_name)
|
|
48
|
+
return pkg
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_wheel_compatible(wheel_name: str):
|
|
52
|
+
_, _, _, wheel_tags = parse_wheel_filename(wheel_name)
|
|
53
|
+
|
|
54
|
+
supported_tag = set(tags.sys_tags())
|
|
55
|
+
|
|
56
|
+
return bool(supported_tag & wheel_tags)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import hashlib
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import orjson
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize(obj: Any) -> Any:
|
|
10
|
+
"""Recursively convert objects into JSON-serializable, deterministic forms."""
|
|
11
|
+
if isinstance(obj, BaseModel):
|
|
12
|
+
return normalize(obj.model_dump(serialize_as_any=True))
|
|
13
|
+
|
|
14
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
15
|
+
return normalize(dataclasses.asdict(obj))
|
|
16
|
+
|
|
17
|
+
if isinstance(obj, dict):
|
|
18
|
+
return {str(k): normalize(v) for k, v in sorted(obj.items())}
|
|
19
|
+
|
|
20
|
+
if isinstance(obj, (list, tuple)):
|
|
21
|
+
return [normalize(v) for v in obj]
|
|
22
|
+
|
|
23
|
+
if isinstance(obj, set):
|
|
24
|
+
return sorted(normalize(v) for v in obj)
|
|
25
|
+
|
|
26
|
+
return obj
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def to_canonical_bytes(obj: Any) -> bytes:
|
|
30
|
+
"""Serialize normalized object to canonical JSON bytes."""
|
|
31
|
+
normalized = normalize(obj)
|
|
32
|
+
return orjson.dumps(normalized, option=orjson.OPT_SORT_KEYS)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_tuple_hash(*args: Any) -> str:
|
|
36
|
+
"""Generate a deterministic SHA-256 hash for arbitrary arguments."""
|
|
37
|
+
h = hashlib.sha256()
|
|
38
|
+
for arg in args:
|
|
39
|
+
h.update(to_canonical_bytes(arg))
|
|
40
|
+
h.update(b"\x00")
|
|
41
|
+
return h.hexdigest()
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from typing import Annotated, Any, Literal, Self
|
|
2
|
+
|
|
3
|
+
from ocelescope.plugin.decorators import PluginIO
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from ocelescope import OCEL, Plugin, PluginMethod
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PluginIOApiBase(BaseModel):
|
|
10
|
+
name: str
|
|
11
|
+
label: str
|
|
12
|
+
description: str | None
|
|
13
|
+
is_optional: bool
|
|
14
|
+
is_list: bool
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_plugin_io(cls, io: PluginIO) -> Self:
|
|
18
|
+
|
|
19
|
+
return cls(
|
|
20
|
+
name=io.name,
|
|
21
|
+
label=io.label,
|
|
22
|
+
description=io.description,
|
|
23
|
+
is_optional=io.is_optional,
|
|
24
|
+
is_list=io.is_list,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OCELIOApi(PluginIOApiBase):
|
|
29
|
+
type: Literal["ocel"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ResourceIOApi(PluginIOApiBase):
|
|
33
|
+
type: Literal["resource"]
|
|
34
|
+
schema_id: str
|
|
35
|
+
resource_label: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
PluginIOApi = Annotated[OCELIOApi | ResourceIOApi, Field(discriminator="type")]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _plugin_io_to_api(io: PluginIO):
|
|
42
|
+
base = PluginIOApiBase.from_plugin_io(io)
|
|
43
|
+
return (
|
|
44
|
+
OCELIOApi(type="ocel", **base.model_dump())
|
|
45
|
+
if issubclass(io.type, OCEL)
|
|
46
|
+
else ResourceIOApi(
|
|
47
|
+
type="resource",
|
|
48
|
+
schema_id=io.type.get_schema_hash(),
|
|
49
|
+
resource_label=io.type.get_label(),
|
|
50
|
+
**base.model_dump(),
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MethodApi(BaseModel):
|
|
56
|
+
name: str
|
|
57
|
+
label: str
|
|
58
|
+
description: str | None = None
|
|
59
|
+
inputs: list[PluginIOApi]
|
|
60
|
+
outputs: list[PluginIOApi]
|
|
61
|
+
configuration_schema: dict[str, Any] | None = None
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_method_meta(cls, method: PluginMethod) -> Self:
|
|
65
|
+
return cls(
|
|
66
|
+
name=method.name,
|
|
67
|
+
label=method.label,
|
|
68
|
+
description=method.description,
|
|
69
|
+
inputs=[_plugin_io_to_api(io) for io in method.inputs],
|
|
70
|
+
outputs=[_plugin_io_to_api(io) for io in method.outputs],
|
|
71
|
+
configuration_schema=method.configuration_input.model_json_schema()
|
|
72
|
+
if method.configuration_input
|
|
73
|
+
else None,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PluginApi(BaseModel):
|
|
78
|
+
id: str
|
|
79
|
+
name: str
|
|
80
|
+
version: str
|
|
81
|
+
label: str
|
|
82
|
+
description: str | None = None
|
|
83
|
+
methods: list[MethodApi]
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_plugin(cls, id: str, plugin: Plugin) -> Self:
|
|
87
|
+
return cls(
|
|
88
|
+
id=id,
|
|
89
|
+
name=plugin.get_name(),
|
|
90
|
+
version=plugin.version,
|
|
91
|
+
label=plugin.label,
|
|
92
|
+
description=plugin.description,
|
|
93
|
+
methods=[
|
|
94
|
+
MethodApi.from_method_meta(method)
|
|
95
|
+
for method in plugin.method_map().values()
|
|
96
|
+
],
|
|
97
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import Literal, Self
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ocelescope_module_plugin.tasks import PluginTask
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PluginOutput(BaseModel):
|
|
9
|
+
id: str
|
|
10
|
+
type: Literal["ocel", "resource"]
|
|
11
|
+
name: str
|
|
12
|
+
type_label: str
|
|
13
|
+
created_at: str
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_task(cls, task: PluginTask) -> list[Self]:
|
|
17
|
+
session = task.session
|
|
18
|
+
|
|
19
|
+
ocels = [
|
|
20
|
+
cls(
|
|
21
|
+
id=ocel.id,
|
|
22
|
+
type="ocel",
|
|
23
|
+
name=ocel.name,
|
|
24
|
+
type_label="OCEL",
|
|
25
|
+
created_at=ocel.created_at,
|
|
26
|
+
)
|
|
27
|
+
for ocel in session.list_ocels(source_id=task.id, include_hidden=True)
|
|
28
|
+
]
|
|
29
|
+
resources = [
|
|
30
|
+
cls(
|
|
31
|
+
id=resource.id,
|
|
32
|
+
type="resource",
|
|
33
|
+
name=resource.name,
|
|
34
|
+
type_label=resource.resource_type_label,
|
|
35
|
+
created_at=resource.created_at,
|
|
36
|
+
)
|
|
37
|
+
for resource in session.list_resources(
|
|
38
|
+
source_id=task.id, include_hidden=True
|
|
39
|
+
)
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
return sorted([*ocels, *resources], key=lambda output: output.created_at)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ResultSelection(BaseModel):
|
|
46
|
+
id: str
|
|
47
|
+
name: str | None
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import zipfile
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from ocelescope_backend.app.modules import Module, ModuleMeta
|
|
6
|
+
from ocelescope_backend.app.modules.context import ModuleContext
|
|
7
|
+
from ocelescope_backend.app.sse_manager import SSEMessage
|
|
8
|
+
from packaging.version import Version
|
|
9
|
+
|
|
10
|
+
from ocelescope_module_plugin.archive import import_plugin_archive, is_plugin_archive
|
|
11
|
+
from ocelescope_module_plugin.base_plugin import BasePlugin
|
|
12
|
+
from ocelescope_module_plugin.config import config
|
|
13
|
+
from ocelescope_module_plugin.registry import PluginRegistry
|
|
14
|
+
from ocelescope_module_plugin.routers import router
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ocelescope_backend.app.internal.session import Session
|
|
18
|
+
from ocelescope_backend.app.internal.tasks.system_tasks import ImportMetadata
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PluginModule(Module):
|
|
22
|
+
meta = ModuleMeta(key="plugin", version=Version("1.0"))
|
|
23
|
+
|
|
24
|
+
def __init__(self, context: ModuleContext) -> None:
|
|
25
|
+
super().__init__(context)
|
|
26
|
+
self.plugins = PluginRegistry(self.registry)
|
|
27
|
+
self.plugins.add_plugin(PluginRegistry.BASE_PLUGIN_ID, BasePlugin)
|
|
28
|
+
|
|
29
|
+
if config.PLUGIN_DIR:
|
|
30
|
+
self.plugins.load_from_dir(
|
|
31
|
+
config.PLUGIN_DIR,
|
|
32
|
+
[path.name for path in config.PLUGIN_DIR.iterdir() if path.is_dir()],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def create_app(self) -> FastAPI:
|
|
36
|
+
app = FastAPI(
|
|
37
|
+
title="Plugin",
|
|
38
|
+
version=str(self.meta.version),
|
|
39
|
+
docs_url=None,
|
|
40
|
+
redoc_url=None,
|
|
41
|
+
)
|
|
42
|
+
app.state.plugin_registry = self.plugins
|
|
43
|
+
|
|
44
|
+
app.include_router(router)
|
|
45
|
+
|
|
46
|
+
return app
|
|
47
|
+
|
|
48
|
+
def import_archive(
|
|
49
|
+
self,
|
|
50
|
+
session: "Session",
|
|
51
|
+
archive: zipfile.ZipFile,
|
|
52
|
+
metadata: "ImportMetadata",
|
|
53
|
+
) -> list[SSEMessage] | None:
|
|
54
|
+
if not is_plugin_archive(archive):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
return import_plugin_archive(self.plugins, archive, metadata["fileName"])
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Plotly summary of an OCEL, mirroring the bar lists the frontend renders."""
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import plotly.graph_objects as go
|
|
5
|
+
from plotly.subplots import make_subplots
|
|
6
|
+
|
|
7
|
+
from ocelescope import OCEL, Plotly
|
|
8
|
+
|
|
9
|
+
DEFAULT_TOP_N = 8
|
|
10
|
+
|
|
11
|
+
_ACTIVITY_COLOR = "#228be6"
|
|
12
|
+
_OBJECT_COLOR = "#12b886"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _bars(counts: pd.Series, color: str) -> go.Bar:
|
|
16
|
+
"""Horizontal bars, most frequent on top."""
|
|
17
|
+
ordered = counts.iloc[::-1]
|
|
18
|
+
values = [int(value) for value in ordered]
|
|
19
|
+
|
|
20
|
+
return go.Bar(
|
|
21
|
+
x=values,
|
|
22
|
+
y=[str(label) for label in ordered.index],
|
|
23
|
+
orientation="h",
|
|
24
|
+
marker_color=color,
|
|
25
|
+
text=[f"{value:,}" for value in values],
|
|
26
|
+
textposition="outside",
|
|
27
|
+
cliponaxis=False,
|
|
28
|
+
hovertemplate="%{y}: %{x:,}<extra></extra>",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _title(label: str, counts: pd.Series, top_n: int) -> str:
|
|
33
|
+
total = len(counts)
|
|
34
|
+
return (
|
|
35
|
+
f"{label} (top {top_n} of {total:,})"
|
|
36
|
+
if total > top_n
|
|
37
|
+
else f"{label} ({total:,})"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def visualize_ocel(ocel: OCEL, top_n: int = DEFAULT_TOP_N) -> Plotly:
|
|
42
|
+
"""Chart the activity and object type frequencies of an OCEL."""
|
|
43
|
+
activity_counts = ocel.events.activity_counts
|
|
44
|
+
object_counts = ocel.objects.counts
|
|
45
|
+
|
|
46
|
+
figure = make_subplots(
|
|
47
|
+
rows=2,
|
|
48
|
+
cols=1,
|
|
49
|
+
subplot_titles=(
|
|
50
|
+
_title("Activity", activity_counts, top_n),
|
|
51
|
+
_title("Object type", object_counts, top_n),
|
|
52
|
+
),
|
|
53
|
+
vertical_spacing=0.15,
|
|
54
|
+
)
|
|
55
|
+
figure.add_trace(_bars(activity_counts.head(top_n), _ACTIVITY_COLOR), row=1, col=1)
|
|
56
|
+
figure.add_trace(_bars(object_counts.head(top_n), _OBJECT_COLOR), row=2, col=1)
|
|
57
|
+
|
|
58
|
+
figure.update_xaxes(visible=False)
|
|
59
|
+
figure.update_yaxes(automargin=True, ticksuffix=" ")
|
|
60
|
+
figure.update_layout(
|
|
61
|
+
showlegend=False,
|
|
62
|
+
bargap=0.35,
|
|
63
|
+
margin={"l": 8, "r": 32, "t": 32, "b": 8},
|
|
64
|
+
paper_bgcolor="rgba(0,0,0,0)",
|
|
65
|
+
plot_bgcolor="rgba(0,0,0,0)",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return Plotly(figure)
|