Skip to content

Commit 2ceedbb

Browse files
committed
Merge branch 'main' into fix/importTasksWithPasswords-CMEM-5932
2 parents 7664cf8 + 12152eb commit 2ceedbb

26 files changed

+643
-207
lines changed

.copier-answers.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Changes here will be overwritten by Copier
2-
_commit: v7.0.0
2+
_commit: v7.1.0
33
_src_path: gh:eccenca/cmem-plugin-template
44
author_mail: [email protected]
55
author_name: eccenca GmbH

.gitlab-ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
default:
3-
image: docker-registry.eccenca.com/eccenca-python:v3.11.4
3+
image: docker-registry.eccenca.com/eccenca-python:v3.11.9-2
44
# all jobs can be interrupted in case a new commit is pushed
55
interruptible: true
66
before_script:

.idea/cmem-plugin-base.iml

+1-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CHANGELOG.md

+21-2
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,30 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- Custom actions for Workflow plugins (CMEM-5576)
12+
- Added explicit_schema parameter to FlexibleOutputSchema (CMEM-6444)
13+
914
### Fixed
1015

11-
- Check if passwords can be decrypted, i.e., if the key is valid (CMEM-5932).
16+
- Check if passwords can be decrypted, i.e., if the key is valid (CMEM-5932)
17+
18+
## [4.9.0] 2025-02-20
19+
20+
### Added
21+
22+
- TypedEntitySchema - A custom entity schema that holds entities of a specific type (e.g. files)
23+
- FileEntitySchema - Entity schema that holds a collection of files
24+
- testing module with different context classes for tests:
25+
- TestUserContext
26+
- TestPluginContext
27+
- TestTaskContext
28+
- TestExecutionContext
29+
- TestSystemContext
30+
1231

13-
## [4.8.0] 2024-09-12
32+
## [4.8.0] 2024-09-12 - shipped with DI v24.3.0
1433

1534
### Added
1635

Taskfile.yaml

+1-3
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,7 @@ tasks:
157157
<<: *preparation
158158
cmds:
159159
# ignore 51358 safety - dev dependency only
160-
# ignore 74735 jinja2 - dev dependency only
161-
# ignore 75180 pip - dev dependency only
162-
- poetry run safety check -i 51358 -i 74735 -i 75180
160+
- poetry run safety check -i 51358
163161

164162
check:ruff:
165163
desc: Complain about everything else

cmem_plugin_base/dataintegration/description.py

+93-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from pkgutil import get_data
1010
from typing import Any, ClassVar
1111

12-
from cmem_plugin_base.dataintegration.plugins import TransformPlugin, WorkflowPlugin
12+
from cmem_plugin_base.dataintegration.context import PluginContext
13+
from cmem_plugin_base.dataintegration.plugins import PluginBase, TransformPlugin, WorkflowPlugin
1314
from cmem_plugin_base.dataintegration.types import (
1415
ParameterType,
1516
ParameterTypes,
@@ -96,6 +97,84 @@ def __init__( # noqa: PLR0913
9697
self.visible = visible
9798

9899

100+
class PluginAction:
101+
"""Custom plugin action.
102+
103+
Plugin actions provide additional functionality besides the default execution.
104+
They can be triggered from the plugin UI.
105+
Each action is based on a method on the plugin class. Besides the self parameter,
106+
the method can have one additional parameter of type PluginContext.
107+
The return value of the method will be converted to a string and displayed in the UI.
108+
The string may use Markdown formatting.
109+
The method may return None, in which case no output will be displayed.
110+
It may raise an exception to signal an error to the user.
111+
112+
:param name: The name of the method.
113+
:param label: A human-readable label of the action
114+
:param description: A human-readable description of the action
115+
:param icon: An optional custom icon.
116+
"""
117+
118+
def __init__(self, name: str, label: str, description: str, icon: Icon | None = None):
119+
self.name = name
120+
self.label = label
121+
self.description = description
122+
self.icon = icon
123+
self.validated = False
124+
self.provide_plugin_context = False # Will be set by validate()
125+
126+
def validate(self, plugin_class: type) -> None:
127+
"""Validate the action and set the `provide_plugin_context` boolean.
128+
129+
:param plugin_class: The plugin class
130+
"""
131+
# Get the method from the class.
132+
try:
133+
method = getattr(plugin_class, self.name)
134+
except AttributeError:
135+
raise TypeError(
136+
f"Plugin class '{plugin_class.__name__}' does not have a method named '{self.name}'"
137+
) from None
138+
if not callable(method):
139+
raise TypeError(f"'{self.name}' in class '{plugin_class.__name__}' is not a function.")
140+
141+
# Check parameters
142+
parameters = list(inspect.signature(method).parameters.values())
143+
if len(parameters) == 1:
144+
self.provide_plugin_context = False
145+
elif len(parameters) - 1 == 1:
146+
if parameters[1].annotation is PluginContext:
147+
self.provide_plugin_context = True
148+
else:
149+
raise TypeError(
150+
f"Argument of method '{self.name}' in {plugin_class.__name__} must "
151+
f"be typed PluginContext (it's {parameters[1].annotation})."
152+
)
153+
else:
154+
raise TypeError(
155+
f"Method '{self.name}' in {plugin_class.__name__} has more than one"
156+
f" argument (besides 'self')."
157+
)
158+
self.validated = True
159+
160+
def execute(self, plugin: PluginBase, context: PluginContext) -> str | None:
161+
"""Call the action.
162+
163+
:param plugin: The plugin instance on which the action is called.
164+
:param context: The plugin context
165+
:return: The result of the action as string
166+
"""
167+
if not self.validated:
168+
raise ValueError("Action must be validated before it can be executed.")
169+
if self.provide_plugin_context:
170+
result = getattr(plugin, self.name)(context)
171+
else:
172+
result = getattr(plugin, self.name)()
173+
if result is None:
174+
return None
175+
return str(result)
176+
177+
99178
class PluginDescription:
100179
"""A plugin description.
101180
@@ -106,6 +185,7 @@ class PluginDescription:
106185
:param categories: The categories to which this plugin belongs to.
107186
:param parameters: Available plugin parameters
108187
:param icon: An optional custom plugin icon.
188+
:param actions: Custom plugin actions.
109189
"""
110190

111191
def __init__( # noqa: PLR0913
@@ -118,6 +198,7 @@ def __init__( # noqa: PLR0913
118198
categories: list[str] | None = None,
119199
parameters: list[PluginParameter] | None = None,
120200
icon: Icon | None = None,
201+
actions: list[PluginAction] | None = None,
121202
) -> None:
122203
# Set the type of the plugin. Same as the class name of the plugin
123204
# base class, e.g., 'WorkflowPlugin'.
@@ -153,6 +234,12 @@ def __init__( # noqa: PLR0913
153234
else:
154235
self.parameters = parameters
155236
self.icon = icon
237+
if actions is None:
238+
self.actions = []
239+
else:
240+
self.actions = actions
241+
for action in self.actions:
242+
action.validate(plugin_class)
156243

157244

158245
@dataclass
@@ -233,6 +320,7 @@ class Plugin:
233320
:param categories: The categories to which this plugin belongs to.
234321
:param parameters: Available plugin parameters.
235322
:param icon: Optional custom plugin icon.
323+
:param actions: Custom plugin actions
236324
"""
237325

238326
plugins: ClassVar[list[PluginDescription]] = []
@@ -246,12 +334,14 @@ def __init__( # noqa: PLR0913
246334
categories: list[str] | None = None,
247335
parameters: list[PluginParameter] | None = None,
248336
icon: Icon | None = None,
337+
actions: list[PluginAction] | None = None,
249338
):
250339
self.label = label
251340
self.description = description
252341
self.documentation = documentation
253342
self.plugin_id = plugin_id
254343
self.icon = icon
344+
self.actions = actions
255345
if categories is None:
256346
self.categories = []
257347
else:
@@ -272,6 +362,7 @@ def __call__(self, func: type):
272362
categories=self.categories,
273363
parameters=self.retrieve_parameters(func),
274364
icon=self.icon,
365+
actions=self.actions,
275366
)
276367
Plugin.plugins.append(plugin_desc)
277368
return func
@@ -304,7 +395,7 @@ def retrieve_parameters(self, plugin_class: type) -> list[PluginParameter]:
304395
# Special handling of PluginContext parameter
305396
if isinstance(param.param_type, PluginContextParameterType):
306397
param.visible = False # Should never be visible in the UI
307-
param.default_value = "" # dummy value
398+
param.default_value = "" # default value
308399

309400
if param.default_value is None and sig_param.default != _empty:
310401
param.default_value = sig_param.default

cmem_plugin_base/dataintegration/parameter/resource.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""DI Resource Parameter Type."""
2+
# ruff: noqa: A005
23

34
from typing import Any
45

cmem_plugin_base/dataintegration/ports.py

+7
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,15 @@ class FlexibleSchemaPort(Port):
2222
Flexible input ports will adapt the schema to the connected output.
2323
Flexible output ports will adapt the schema to the connected input.
2424
It is not allowed to connect two flexible ports.
25+
26+
:param explicit_schema: Indicates whether an output port has an explicitly defined and quickly
27+
retrievable schema (like CSV). This allows for connecting it directly to
28+
flexible input ports in which case the explict schema will be used.
2529
"""
2630

31+
def __init__(self, explicit_schema: bool = False):
32+
self.explicit_schema = explicit_schema
33+
2734

2835
class UnknownSchemaPort(Port):
2936
"""Port for which the schema is not known in advance.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Custom entity schema that holds entities of a specific type (e.g. files)"""
2+
3+
4+
def type_uri(suffix: str) -> str:
5+
"""Create a new entity schema type URI."""
6+
return "https://vocab.eccenca.com/di/entity/" + suffix
7+
8+
9+
def path_uri(suffix: str) -> str:
10+
"""Create a new entity schema path."""
11+
return "https://vocab.eccenca.com/di/entity/" + suffix
12+
13+
14+
def instance_uri(suffix: str) -> str:
15+
"""Create a new typed entity instance URI"""
16+
return "https://eccenca.com/di/entity/" + suffix
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""File entities"""
2+
3+
from cmem_plugin_base.dataintegration.entity import Entity, EntityPath
4+
from cmem_plugin_base.dataintegration.typed_entities import instance_uri, path_uri, type_uri
5+
from cmem_plugin_base.dataintegration.typed_entities.typed_entities import (
6+
TypedEntitySchema,
7+
)
8+
9+
10+
class File:
11+
"""A file entity that can be held in a FileEntitySchema."""
12+
13+
def __init__(self, path: str, file_type: str, mime: str | None) -> None:
14+
self.path = path
15+
self.file_type = file_type
16+
self.mime = mime
17+
18+
19+
class LocalFile(File):
20+
"""A file that's located on the local file system."""
21+
22+
def __init__(self, path: str, mime: str | None = None) -> None:
23+
super().__init__(path, "Local", mime)
24+
25+
26+
class ProjectFile(File):
27+
"""A project file"""
28+
29+
def __init__(self, path: str, mime: str | None = None) -> None:
30+
super().__init__(path, "Project", mime)
31+
32+
33+
class FileEntitySchema(TypedEntitySchema[File]):
34+
"""Entity schema that holds a collection of files."""
35+
36+
def __init__(self):
37+
super().__init__(
38+
type_uri=type_uri("File"),
39+
paths=[
40+
EntityPath(path_uri("filePath")),
41+
EntityPath(path_uri("fileType")),
42+
EntityPath(path_uri("mimeType")),
43+
],
44+
)
45+
46+
def to_entity(self, value: File) -> Entity:
47+
"""Create a generic entity from a file"""
48+
return Entity(
49+
uri=instance_uri(value.path),
50+
values=[[value.path], [value.file_type], [value.mime or ""]],
51+
)
52+
53+
def from_entity(self, entity: Entity) -> File:
54+
"""Create a file entity from a generic entity."""
55+
path = entity.values[0][0]
56+
file_type = entity.values[1][0]
57+
mime = entity.values[2][0] if entity.values[2][0] else None
58+
match file_type:
59+
case "Local":
60+
return LocalFile(path, mime)
61+
case "Project":
62+
return ProjectFile(path, mime)
63+
case _:
64+
raise ValueError(f"File '{path}' has unexpected type '{file_type}'.")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Custom entity schema that holds entities of a specific type (e.g. files)"""
2+
3+
from abc import abstractmethod
4+
from collections.abc import Iterator, Sequence
5+
from typing import Generic, TypeVar
6+
7+
from cmem_plugin_base.dataintegration.entity import Entities, Entity, EntityPath, EntitySchema
8+
9+
T = TypeVar("T")
10+
11+
12+
class TypedEntitySchema(EntitySchema, Generic[T]):
13+
"""A custom entity schema that holds entities of a specific type (e.g. files)."""
14+
15+
def __init__(self, type_uri: str, paths: Sequence[EntityPath]):
16+
super().__init__(type_uri, paths)
17+
18+
@abstractmethod
19+
def to_entity(self, value: T) -> Entity:
20+
"""Create a generic entity from a typed entity."""
21+
22+
@abstractmethod
23+
def from_entity(self, entity: Entity) -> T:
24+
"""Create a typed entity from a generic entity.
25+
26+
Implementations may assume that the incoming schema matches the schema expected by
27+
this typed schema, i.e., schema validation is not required.
28+
"""
29+
30+
def to_entities(self, values: Iterator[T]) -> "TypedEntities[T]":
31+
"""Given a collection of values, create a new typed entities instance."""
32+
return TypedEntities(values, self)
33+
34+
def from_entities(self, entities: Entities) -> "TypedEntities[T]":
35+
"""Create typed entities from generic entities.
36+
37+
Returns None if the entities do not match the target type.
38+
"""
39+
# TODO(robert): add validation
40+
# CMEM-6095
41+
if entities.schema.type_uri == self.type_uri:
42+
if isinstance(entities, TypedEntities):
43+
return entities
44+
return TypedEntities(map(self.from_entity, entities.entities), self)
45+
raise ValueError(
46+
f"Expected entities of type '{self.type_uri}' but got '{entities.schema.type_uri}'."
47+
)
48+
49+
50+
class TypedEntities(Entities, Generic[T]):
51+
"""Collection of entities of a particular type."""
52+
53+
def __init__(self, values: Iterator[T], schema: TypedEntitySchema[T]):
54+
super().__init__(map(schema.to_entity, values), schema)
55+
self.values = values
56+
self.schema = schema

cmem_plugin_base/dataintegration/types.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Parameter types."""
2+
# ruff: noqa: A005
23

34
from collections.abc import Iterable
45
from dataclasses import dataclass

0 commit comments

Comments
 (0)