ocelescope-module-plugin 0.12.0__tar.gz

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.
Files changed (20) hide show
  1. ocelescope_module_plugin-0.12.0/PKG-INFO +23 -0
  2. ocelescope_module_plugin-0.12.0/README.md +7 -0
  3. ocelescope_module_plugin-0.12.0/pyproject.toml +33 -0
  4. ocelescope_module_plugin-0.12.0/pyproject.toml.orig +32 -0
  5. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/__init__.py +0 -0
  6. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/archive.py +99 -0
  7. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/base_plugin.py +105 -0
  8. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/config.py +14 -0
  9. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/dependencies.py +27 -0
  10. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/dynamic_import.py +56 -0
  11. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/hashing.py +41 -0
  12. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/models/__init__.py +0 -0
  13. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/models/plugin.py +97 -0
  14. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/models/plugin_result.py +47 -0
  15. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/module.py +57 -0
  16. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/ocel_visualization.py +68 -0
  17. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/registry.py +273 -0
  18. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/results.py +21 -0
  19. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/routers.py +258 -0
  20. ocelescope_module_plugin-0.12.0/src/ocelescope_module_plugin/tasks.py +188 -0
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.3
2
+ Name: ocelescope-module-plugin
3
+ Version: 0.12.0
4
+ Summary: Ocelescope backend module for uploading and running plugins
5
+ Requires-Dist: fastapi>=0.136.0
6
+ Requires-Dist: ocelescope[plugin]>=0.12.0,<0.13.0
7
+ Requires-Dist: ocelescope-backend>=0.12.0,<0.13.0
8
+ Requires-Dist: orjson>=3.11.3
9
+ Requires-Dist: packaging>=24.0
10
+ Requires-Dist: pandas>=2.3.1
11
+ Requires-Dist: plotly>=6.3.1
12
+ Requires-Dist: pydantic>=2.11.7
13
+ Requires-Dist: pydantic-settings>=2.2.1,<3
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+
17
+ # ocelescope-module-plugin
18
+
19
+ Ocelescope backend module for uploading, listing, running and deleting plugins,
20
+ including the built-in discovery plugin. Plugin zips dropped on the generic
21
+ session upload are claimed by this module through `Module.import_archive`.
22
+
23
+ Plugins are stored in `PLUGIN_DIR`. Mounted at `/modules/plugin/v1`.
@@ -0,0 +1,7 @@
1
+ # ocelescope-module-plugin
2
+
3
+ Ocelescope backend module for uploading, listing, running and deleting plugins,
4
+ including the built-in discovery plugin. Plugin zips dropped on the generic
5
+ session upload are claimed by this module through `Module.import_archive`.
6
+
7
+ Plugins are stored in `PLUGIN_DIR`. Mounted at `/modules/plugin/v1`.
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "ocelescope-module-plugin"
3
+ version = "0.12.0"
4
+ description = "Ocelescope backend module for uploading and running plugins"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "fastapi>=0.136.0",
9
+ "ocelescope[plugin]>=0.12.0,<0.13.0",
10
+ "ocelescope-backend>=0.12.0,<0.13.0",
11
+ "orjson>=3.11.3",
12
+ "packaging>=24.0",
13
+ "pandas>=2.3.1",
14
+ "plotly>=6.3.1",
15
+ "pydantic>=2.11.7",
16
+ "pydantic-settings>=2.2.1,<3",
17
+ ]
18
+
19
+ [project.entry-points."ocelescope_backend.modules"]
20
+ pluginV1 = "ocelescope_module_plugin.module:PluginModule"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.13.0,<0.14.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [tool.uv]
27
+ package = true
28
+
29
+ [tool.uv.sources.ocelescope]
30
+ workspace = true
31
+
32
+ [tool.uv.sources.ocelescope-backend]
33
+ workspace = true
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "ocelescope-module-plugin"
3
+ version = "0.12.0"
4
+ description = "Ocelescope backend module for uploading and running plugins"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "fastapi>=0.136.0",
9
+ "ocelescope[plugin]>=0.12.0,<0.13.0",
10
+ "ocelescope-backend>=0.12.0,<0.13.0",
11
+ "orjson>=3.11.3",
12
+ "packaging>=24.0",
13
+ "pandas>=2.3.1",
14
+ "plotly>=6.3.1",
15
+ "pydantic>=2.11.7",
16
+ "pydantic-settings>=2.2.1,<3",
17
+ ]
18
+
19
+ [project.entry-points."ocelescope_backend.modules"]
20
+ pluginV1 = "ocelescope_module_plugin.module:PluginModule"
21
+
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.13.0,<0.14.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [tool.uv]
28
+ package = true
29
+
30
+ [tool.uv.sources]
31
+ ocelescope = { workspace = true }
32
+ ocelescope-backend = { workspace = true }
@@ -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()
@@ -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"])