procodile 0.0.7__tar.gz → 0.0.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: procodile
3
- Version: 0.0.7
3
+ Version: 0.0.9
4
4
  Summary: A light-weight processor development framework
5
5
  Project-URL: Documentation, https://eo-tools.github.io/eozilla
6
6
  Project-URL: Repository, https://github.com/eo-tools/eozilla
@@ -4,22 +4,37 @@
4
4
 
5
5
  from importlib.metadata import version
6
6
 
7
-
8
7
  __version__ = version("procodile")
9
8
 
10
9
  from gavicore.util.request import ExecutionRequest
10
+
11
+ from .artifacts import ArtifactRef, ArtifactStore, ExecutionContext
11
12
  from .job import Job, JobCancelledException, JobContext
12
- from .process import Process
13
+ from .process import Process, additional_parameters
13
14
  from .registry import ProcessRegistry
15
+ from .workflow import (
16
+ FromMain,
17
+ FromStep,
18
+ Workflow,
19
+ WorkflowStepRegistry,
20
+ )
14
21
 
15
22
  """Processes development API."""
16
23
 
17
24
  __all__ = [
25
+ "additional_parameters",
26
+ "ArtifactRef",
27
+ "ArtifactStore",
18
28
  "__version__",
19
29
  "ExecutionRequest",
30
+ "ExecutionContext",
31
+ "FromStep",
32
+ "FromMain",
20
33
  "Job",
21
34
  "JobContext",
22
35
  "JobCancelledException",
23
- "ProcessRegistry",
24
36
  "Process",
37
+ "ProcessRegistry",
38
+ "WorkflowStepRegistry",
39
+ "Workflow",
25
40
  ]
@@ -0,0 +1,192 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+ from typing import Any, Mapping, TypeAlias
4
+
5
+ NormalizedOutputs: TypeAlias = dict[str, Any]
6
+ """
7
+ Normalized mapping of output names to resolved values produced by a step
8
+ execution in a workflow.
9
+ """
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ArtifactRef:
14
+ """
15
+ Lightweight reference to an externally stored artifact.
16
+
17
+ An ``ArtifactRef`` is returned when a value is materialized by an
18
+ ArtifactStore and can later be resolved back into the original object.
19
+ """
20
+
21
+ path: str
22
+ loader: str
23
+
24
+
25
+ class ArtifactStore(ABC):
26
+ """
27
+ Abstract base class for persisting and loading large objects
28
+ (e.g. xarray datasets, large arrays, model artifacts).
29
+
30
+ Concrete implementations define how objects are stored
31
+ (filesystem, object store, etc.).
32
+ """
33
+
34
+ @abstractmethod
35
+ def is_artifact_big(self, obj: Any) -> bool:
36
+ """
37
+ Determine whether an object should be treated as a 'big object'
38
+ and stored externally instead of being passed as is.
39
+
40
+ Args:
41
+ obj: Object to inspect.
42
+
43
+ Returns:
44
+ True if the object is considered large and should be stored
45
+ via this store, False otherwise.
46
+ """
47
+ raise NotImplementedError # pragma: no cover
48
+
49
+ @abstractmethod
50
+ def save_artifact(self, obj: Any) -> ArtifactRef:
51
+ """
52
+ Persist a large object and return a reference to it.
53
+
54
+ Args:
55
+ obj: The object to be stored.
56
+
57
+ Returns:
58
+ ArtifactRef: A reference that can later be used to load the object.
59
+
60
+ Raises:
61
+ TypeError: If the object type is not supported by the implementation.
62
+ """
63
+ raise NotImplementedError # pragma: no cover
64
+
65
+ @abstractmethod
66
+ def load_artifact(self, ref: ArtifactRef) -> Any:
67
+ """
68
+ Load a previously stored object using an ArtifactRef.
69
+
70
+ Args:
71
+ ref: Reference returned by `save`.
72
+
73
+ Returns:
74
+ The reconstructed object.
75
+
76
+ Raises:
77
+ ValueError: If the loader specified in the reference is unknown.
78
+ """
79
+ raise NotImplementedError # pragma: no cover
80
+
81
+
82
+ class NullArtifactStore(ArtifactStore):
83
+ """
84
+ No-op artifact store.
85
+
86
+ This store never treats objects as 'big' and does not
87
+ support saving or loading artifacts.
88
+ """
89
+
90
+ def is_artifact_big(self, obj: Any) -> bool:
91
+ """
92
+ No object is considered big in the null store.
93
+ """
94
+ return False
95
+
96
+ def save_artifact(self, obj: Any):
97
+ """
98
+ Saving artifacts is not supported in the null store.
99
+ """
100
+ raise RuntimeError("NullArtifactStore does not support saving artifacts")
101
+
102
+ def load_artifact(self, ref):
103
+ """
104
+ Loading artifacts is not supported in the null store.
105
+ """
106
+ raise RuntimeError("NullArtifactStore does not support loading artifacts")
107
+
108
+
109
+ class ExecutionContext:
110
+ """
111
+ Holds execution-time state for a workflow run.
112
+
113
+ The execution context stores resolved outputs from the main step and
114
+ individual steps, manages artifact materialization, and normalizes outputs.
115
+ """
116
+
117
+ def __init__(self, store: ArtifactStore) -> None:
118
+ self.store = store
119
+ self.main: dict[str, Any] = {}
120
+ self.steps: dict[str, dict[str, Any]] = {}
121
+
122
+ def resolve(self, value: Any) -> Any:
123
+ """Recursively resolve the inputs."""
124
+ if isinstance(value, ArtifactRef):
125
+ return self.store.load_artifact(value)
126
+
127
+ if isinstance(value, dict):
128
+ return {k: self.resolve(v) for k, v in value.items()}
129
+
130
+ if isinstance(value, tuple):
131
+ return tuple(self.resolve(v) for v in value)
132
+
133
+ if isinstance(value, list):
134
+ return [self.resolve(v) for v in value]
135
+
136
+ return value
137
+
138
+ def normalize_outputs(
139
+ self, result: Any, output_spec: Mapping[str, Any] | None, store: ArtifactStore
140
+ ) -> NormalizedOutputs:
141
+ """
142
+ Normalize function outputs into a dict[str, Any].
143
+
144
+ Rules:
145
+ - If output_spec is None → {"return_value": result}
146
+ - Tuple → positional mapping
147
+ - Dict → key mapping
148
+ - Single value → single output
149
+ """
150
+ if output_spec is None:
151
+ return {"return_value": self.materialize_artifact(result, store)}
152
+
153
+ output_keys = list(output_spec.keys())
154
+
155
+ if len(output_keys) == 1:
156
+ return {output_keys[0]: self.materialize_artifact(result, store)}
157
+
158
+ if isinstance(result, dict):
159
+ missing = set(output_keys) - result.keys()
160
+ if missing:
161
+ raise ValueError(f"Missing outputs in return dict: {missing}")
162
+ return {k: self.materialize_artifact(result[k], store) for k in output_keys}
163
+
164
+ if isinstance(result, tuple):
165
+ if len(result) != len(output_keys):
166
+ raise ValueError("Tuple output length does not match declared outputs")
167
+ return {
168
+ k: self.materialize_artifact(v, store)
169
+ for k, v in zip(output_keys, result, strict=True)
170
+ }
171
+
172
+ raise TypeError(
173
+ f"Invalid return type for declared outputs. result: {result}, output_spec: {output_spec}",
174
+ )
175
+
176
+ def materialize_artifact(self, value: Any, store: ArtifactStore) -> Any:
177
+ """
178
+ Recursively materialize big objects.
179
+ """
180
+ if store.is_artifact_big(value):
181
+ return store.save_artifact(value)
182
+
183
+ if isinstance(value, dict):
184
+ return {k: self.materialize_artifact(v, store) for k, v in value.items()}
185
+
186
+ if isinstance(value, tuple):
187
+ return tuple(self.materialize_artifact(v, store) for v in value)
188
+
189
+ if isinstance(value, list):
190
+ return [self.materialize_artifact(v, store) for v in value]
191
+
192
+ return value
@@ -5,6 +5,6 @@
5
5
  # No exports here. To avoid unnecessary imports, CLI apps should pick
6
6
  # only what they need from dedicated submodules.
7
7
 
8
- from .cli import get_cli
8
+ from .cli import new_cli
9
9
 
10
- __all__ = ["get_cli"]
10
+ __all__ = ["new_cli"]
@@ -0,0 +1,259 @@
1
+ # Copyright (c) 2025 by the Eozilla team and contributors
2
+ # Permissions are hereby granted under the terms of the Apache 2.0 License:
3
+ # https://opensource.org/license/apache-2-0.
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Annotated, Any, Callable, Optional, Union
7
+
8
+ import click
9
+ import typer
10
+
11
+ from gavicore.util.cli.group import AliasedGroup
12
+ from gavicore.util.cli.parameters import (
13
+ DOT_PATH_OPTION,
14
+ PROCESS_ID_ARGUMENT,
15
+ REQUEST_FILE_OPTION,
16
+ REQUEST_INPUT_OPTION,
17
+ REQUEST_SUBSCRIBER_OPTION,
18
+ )
19
+
20
+ if TYPE_CHECKING: # pragma: no cover
21
+ from procodile import ProcessRegistry
22
+
23
+ PROCESS_REGISTRY_GETTER_KEY = "get_process_registry"
24
+
25
+ DEFAULT_NAME = "procodile"
26
+
27
+ DEFAULT_SUMMARY = """`{name}` is a command-line tool to describe and execute
28
+ one or more registered processes."""
29
+
30
+ DEFAULT_HELP = """{summary}
31
+
32
+ You can use shorter command name aliases, e.g., use command name `ep`
33
+ for `execute-process`, or `lp` for `list-processes`.
34
+ """
35
+
36
+
37
+ # noinspection PyShadowingBuiltins
38
+ def new_cli(
39
+ registry: Union[
40
+ str,
41
+ "ProcessRegistry",
42
+ Callable[[], "ProcessRegistry"],
43
+ ],
44
+ name: str,
45
+ version: str,
46
+ help: str | None = None,
47
+ summary: str | None = None,
48
+ context: dict[str, Any] | None = None,
49
+ ) -> typer.Typer:
50
+ """
51
+ Get the CLI instance configured to use the process registry
52
+ that is given either by
53
+
54
+ - a reference of the form "path.to.module:attribute",
55
+ - or process registry instance,
56
+ - or as a no-arg process registry getter function.
57
+
58
+ The process registry is usually a singleton in your application.
59
+
60
+ The context object `obj` of the returned CLI object
61
+ will be of type `dict` and will contain
62
+ a process registry getter function using the key
63
+ `get_process_registry`.
64
+
65
+ The function must be called before any CLI command or
66
+ callback has been invoked. Otherwise, the provided
67
+ `get_process_registry` getter will not be recognized and
68
+ all commands that require the process registry will
69
+ fail with an `AssertionError`.
70
+
71
+ Args:
72
+ name: The name of the CLI application.
73
+ registry: A registry reference string,
74
+ or a registry instance, or a no-arg
75
+ function that returns a registry instance.
76
+ help: Optional CLI application help text. If not provided, the default
77
+ `cuiman` help text will be used.
78
+ summary: A one-sentence human-readable description of the tool that
79
+ will be used by the default help text. Hence, used only,
80
+ if `help` is not provided. Should end with a dot '.'.
81
+ version: Optional version string. If not provided, the
82
+ `cuiman` version will be used.
83
+ context: Additional context values that will be registered with the CLI
84
+ and can be accessed by commands that you add to the
85
+ returned `typer.Typer` instance.
86
+
87
+ Return:
88
+ a `typer.Typer` instance
89
+ """
90
+ assert bool(registry), "registry argument must be provided"
91
+ assert bool(name), "name argument must be provided"
92
+ assert bool(version), "version argument must be provided"
93
+
94
+ t = typer.Typer(
95
+ cls=AliasedGroup,
96
+ name=name,
97
+ help=(
98
+ help
99
+ or DEFAULT_HELP.format(summary=summary or DEFAULT_SUMMARY.format(name=name))
100
+ ),
101
+ add_completion=False,
102
+ invoke_without_command=True,
103
+ context_settings={
104
+ "obj": {
105
+ PROCESS_REGISTRY_GETTER_KEY: _parse_process_registry_getter(registry),
106
+ **(context or {}),
107
+ },
108
+ },
109
+ )
110
+
111
+ @t.callback()
112
+ def main(
113
+ version_: Annotated[
114
+ bool, typer.Option("--version", help="Show version and exit.")
115
+ ] = False,
116
+ ):
117
+ if version_:
118
+ from procodile import __version__ as procodile_version
119
+
120
+ typer.echo(f"{version} (procodile {procodile_version})")
121
+ return
122
+
123
+ @t.command("execute-process")
124
+ def execute_process(
125
+ ctx: typer.Context,
126
+ process_id: Annotated[Optional[str], PROCESS_ID_ARGUMENT] = None,
127
+ dotpath: Annotated[bool, DOT_PATH_OPTION] = False,
128
+ request_inputs: Annotated[Optional[list[str]], REQUEST_INPUT_OPTION] = None,
129
+ request_subscribers: Annotated[
130
+ Optional[list[str]], REQUEST_SUBSCRIBER_OPTION
131
+ ] = None,
132
+ request_file: Annotated[Optional[str], REQUEST_FILE_OPTION] = None,
133
+ ):
134
+ """
135
+ Execute a process.
136
+
137
+ The process request to be submitted may be read from a file given
138
+ by `--request`, or from `stdin`, or from the `process_id` argument
139
+ with zero, one, or more `--input` (or `-i`) options.
140
+
141
+ The `process_id` argument and any given `--input` options will override
142
+ settings with the same name found in the given request file or `stdin`,
143
+ if any.
144
+ """
145
+ from procodile import ExecutionRequest, Job
146
+
147
+ registry = _get_process_registry(ctx)
148
+ execution_request = ExecutionRequest.create(
149
+ process_id=process_id,
150
+ dotpath=dotpath,
151
+ inputs=request_inputs,
152
+ subscribers=request_subscribers,
153
+ request_path=request_file,
154
+ )
155
+ process_id_ = execution_request.process_id
156
+ process = registry.get(process_id_)
157
+ if process is None:
158
+ raise click.ClickException(f"Process {process_id_!r} not found.")
159
+
160
+ job = Job.create(process, request=execution_request.to_process_request())
161
+ job_results = job.run()
162
+ if job_results is not None:
163
+ typer.echo(job_results.model_dump_json(indent=2))
164
+ else:
165
+ typer.echo(job.job_info.model_dump_json(indent=2))
166
+
167
+ @t.command("list-processes", help="List all processes.")
168
+ def list_processes(ctx: typer.Context):
169
+ registry = _get_process_registry(ctx)
170
+ typer.echo(
171
+ json.dumps(
172
+ {
173
+ k: v.description.model_dump(
174
+ mode="json",
175
+ by_alias=True,
176
+ exclude_none=True,
177
+ exclude_defaults=True,
178
+ exclude_unset=True,
179
+ exclude={"inputs", "outputs"},
180
+ )
181
+ for k, v in registry.items()
182
+ },
183
+ indent=2,
184
+ )
185
+ )
186
+
187
+ @t.command("get-process", help="Get details of a process.")
188
+ def get_process(
189
+ ctx: typer.Context,
190
+ process_id: Annotated[str, PROCESS_ID_ARGUMENT],
191
+ ):
192
+ import json
193
+
194
+ registry = _get_process_registry(ctx)
195
+ process = registry.get(process_id)
196
+ if process is None:
197
+ raise click.ClickException(f"Process {process_id!r} not found.")
198
+
199
+ typer.echo(
200
+ json.dumps(
201
+ process.description.model_dump(
202
+ mode="json",
203
+ by_alias=True,
204
+ exclude_defaults=True,
205
+ exclude_none=True,
206
+ exclude_unset=True,
207
+ ),
208
+ indent=2,
209
+ )
210
+ )
211
+
212
+ return t
213
+
214
+
215
+ __all__ = [
216
+ "new_cli",
217
+ ]
218
+
219
+
220
+ def _parse_process_registry_getter(
221
+ process_registry: Union[
222
+ str,
223
+ "ProcessRegistry",
224
+ Callable[[], "ProcessRegistry"],
225
+ ],
226
+ ) -> Callable[[], "ProcessRegistry"]:
227
+ process_registry_getter: Callable
228
+ if isinstance(process_registry, str):
229
+
230
+ def process_registry_getter():
231
+ from gavicore.util.dynimp import import_value
232
+ from procodile import ProcessRegistry
233
+
234
+ return import_value(
235
+ process_registry, name="process registry", type=ProcessRegistry
236
+ )
237
+
238
+ return process_registry_getter
239
+
240
+ elif callable(process_registry):
241
+ return process_registry
242
+ else:
243
+
244
+ def process_registry_getter():
245
+ return process_registry
246
+
247
+ return process_registry_getter
248
+
249
+
250
+ def _get_process_registry(
251
+ ctx: typer.Context,
252
+ ) -> "ProcessRegistry":
253
+ from procodile import ProcessRegistry
254
+
255
+ process_registry_getter = ctx.obj.get(PROCESS_REGISTRY_GETTER_KEY)
256
+ assert process_registry_getter is not None and callable(process_registry_getter)
257
+ process_registry = process_registry_getter()
258
+ assert isinstance(process_registry, ProcessRegistry)
259
+ return process_registry
@@ -71,7 +71,8 @@ class JobContext(ABC):
71
71
  del frame
72
72
  # noinspection PyUnreachableCode
73
73
  warnings.warn(
74
- "cannot determine current job context; using non-functional dummy"
74
+ "cannot determine current job context; using non-functional dummy",
75
+ stacklevel=2,
75
76
  )
76
77
  return NullJobContext()
77
78
 
@@ -141,6 +142,7 @@ class Job(JobContext):
141
142
  job_id: Optional job identifier.
142
143
  If omitted, a unique identifier will be generated (UUID4).
143
144
 
145
+
144
146
  Returns:
145
147
  A new job instance.
146
148
 
@@ -148,6 +150,7 @@ class Job(JobContext):
148
150
  pydantic.ValidationError: if an input value is not valid
149
151
  with respect to its process input description.
150
152
  """
153
+
151
154
  process_desc = process.description
152
155
  input_params = request.inputs or {}
153
156
  input_default_params = {
@@ -276,6 +279,12 @@ class Job(JobContext):
276
279
  def _get_job_results(self, function_result: Any) -> JobResults:
277
280
  assert self.job_info.status == JobStatus.successful
278
281
  assert self.job_info.processID is not None
282
+
283
+ # Outputs are already validated and normalized by
284
+ # `ExecutionContext.normalize_outputs()`.
285
+ if isinstance(function_result, dict):
286
+ return JobResults(**function_result)
287
+
279
288
  outputs = self.process.description.outputs or {}
280
289
  output_count = len(outputs)
281
290
  return JobResults(