nf-meta 0.2.3__tar.gz → 0.3.0.dev0__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.
- {nf_meta-0.2.3/src/nf_meta.egg-info → nf_meta-0.3.0.dev0}/PKG-INFO +2 -2
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/README.md +1 -1
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/pyproject.toml +18 -1
- nf_meta-0.3.0.dev0/src/nf_meta/__main__.py +135 -0
- nf_meta-0.3.0.dev0/src/nf_meta/core/cache.py +39 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/core/errors.py +24 -11
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/core/events.py +4 -4
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/core/graph.py +88 -41
- nf_meta-0.3.0.dev0/src/nf_meta/core/models.py +742 -0
- nf_meta-0.3.0.dev0/src/nf_meta/core/nf_core_utils.py +521 -0
- nf_meta-0.3.0.dev0/src/nf_meta/core/nf_param_validation.py +124 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/core/session.py +18 -9
- nf_meta-0.3.0.dev0/src/nf_meta/editor/__init__.py +41 -0
- nf_meta-0.3.0.dev0/src/nf_meta/editor/backend/__init__.py +1 -0
- nf_meta-0.3.0.dev0/src/nf_meta/editor/backend/api.py +232 -0
- nf_meta-0.3.0.dev0/src/nf_meta/editor/backend/serializers.py +40 -0
- nf_meta-0.3.0.dev0/src/nf_meta/editor/base_editor.py +46 -0
- nf_meta-0.3.0.dev0/src/nf_meta/editor/browser_editor.py +99 -0
- nf_meta-0.3.0.dev0/src/nf_meta/editor/editor.py +20 -0
- nf_meta-0.3.0.dev0/src/nf_meta/editor/frontend_dist/assets/index-BeQMBiE2.css +1 -0
- nf_meta-0.3.0.dev0/src/nf_meta/editor/frontend_dist/assets/index-C23FgNmk.js +202 -0
- nf_meta-0.3.0.dev0/src/nf_meta/editor/frontend_dist/assets/nfcore-BTUAiOeh.svg +76 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/editor/frontend_dist/index.html +2 -2
- nf_meta-0.3.0.dev0/src/nf_meta/editor/utils.py +20 -0
- nf_meta-0.3.0.dev0/src/nf_meta/runner/__init__.py +41 -0
- nf_meta-0.3.0.dev0/src/nf_meta/runner/base_runner.py +54 -0
- nf_meta-0.3.0.dev0/src/nf_meta/runner/python_runner.py +308 -0
- nf_meta-0.3.0.dev0/src/nf_meta/runner/runner.py +50 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/runner/utils.py +59 -4
- nf_meta-0.3.0.dev0/src/nf_meta/runner/workflow_run.py +165 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0/src/nf_meta.egg-info}/PKG-INFO +2 -2
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta.egg-info/SOURCES.txt +12 -3
- nf_meta-0.2.3/src/nf_meta/__main__.py +0 -85
- nf_meta-0.2.3/src/nf_meta/core/models.py +0 -391
- nf_meta-0.2.3/src/nf_meta/core/nf_core_utils.py +0 -70
- nf_meta-0.2.3/src/nf_meta/editor/__init__.py +0 -66
- nf_meta-0.2.3/src/nf_meta/editor/backend/__init__.py +0 -1
- nf_meta-0.2.3/src/nf_meta/editor/backend/api.py +0 -141
- nf_meta-0.2.3/src/nf_meta/editor/backend/serializers.py +0 -25
- nf_meta-0.2.3/src/nf_meta/editor/frontend_dist/assets/index-CUFGOfKn.js +0 -202
- nf_meta-0.2.3/src/nf_meta/editor/frontend_dist/assets/index-ESZ-ptZu.css +0 -1
- nf_meta-0.2.3/src/nf_meta/runner/__init__.py +0 -4
- nf_meta-0.2.3/src/nf_meta/runner/python_runner.py +0 -256
- nf_meta-0.2.3/src/nf_meta/runner/runner.py +0 -65
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/LICENSE +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/MANIFEST.in +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/setup.cfg +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/__init__.py +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/core/__init__.py +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/core/history.py +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/editor/frontend_dist/assets/materialdesignicons-webfont-B7mPwVP_.ttf +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/editor/frontend_dist/assets/materialdesignicons-webfont-CSr8KVlo.eot +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/editor/frontend_dist/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/editor/frontend_dist/assets/materialdesignicons-webfont-PXm3-2wK.woff +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/editor/frontend_dist/vite.svg +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/runner/cascade_runner.py +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta/runner/errors.py +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta.egg-info/dependency_links.txt +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta.egg-info/entry_points.txt +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta.egg-info/requires.txt +0 -0
- {nf_meta-0.2.3 → nf_meta-0.3.0.dev0}/src/nf_meta.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nf-meta
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0.dev0
|
|
4
4
|
Summary: nf-meta is a cli tool for building, validating and running meta-pipelines based on the Nextflow workflow language
|
|
5
5
|
Author-email: Julian Flesch <julian.flesch@qbic.uni-tuebingen.de>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -85,7 +85,7 @@ This project features a small editor which intends to ease the creation and upda
|
|
|
85
85
|
of these config files, by visualizing the config as a graph and
|
|
86
86
|
offering user-firendly form for entering and validating values.
|
|
87
87
|
|
|
88
|
-

|
|
88
|
+

|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
## Metapipeline Runners
|
|
@@ -61,7 +61,7 @@ This project features a small editor which intends to ease the creation and upda
|
|
|
61
61
|
of these config files, by visualizing the config as a graph and
|
|
62
62
|
offering user-firendly form for entering and validating values.
|
|
63
63
|
|
|
64
|
-

|
|
64
|
+

|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
## Metapipeline Runners
|
|
@@ -13,7 +13,7 @@ namespaces = false
|
|
|
13
13
|
|
|
14
14
|
[project]
|
|
15
15
|
name = "nf-meta"
|
|
16
|
-
version = "0.
|
|
16
|
+
version = "0.3.0-dev"
|
|
17
17
|
description = "nf-meta is a cli tool for building, validating and running meta-pipelines based on the Nextflow workflow language"
|
|
18
18
|
readme = "README.md"
|
|
19
19
|
authors = [
|
|
@@ -45,4 +45,21 @@ nf-meta = "nf_meta.__main__:cli"
|
|
|
45
45
|
[dependency-groups]
|
|
46
46
|
dev = [
|
|
47
47
|
"twine>=6.2.0",
|
|
48
|
+
"pytest>=9.0.3",
|
|
49
|
+
"pytest-mock>=3.15.1",
|
|
50
|
+
"mypy>=2.1.0",
|
|
51
|
+
"types-networkx>=3.6.1.20260408",
|
|
52
|
+
"types-pyyaml>=6.0.12.20260408",
|
|
53
|
+
"types-requests>=2.33.0.20260503",
|
|
48
54
|
]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
testpaths = ["tests"]
|
|
58
|
+
|
|
59
|
+
[tool.mypy]
|
|
60
|
+
python_version = "3.12"
|
|
61
|
+
mypy_path = "src"
|
|
62
|
+
|
|
63
|
+
[[tool.mypy.overrides]]
|
|
64
|
+
module = ["uvicorn", "uvicorn.*"]
|
|
65
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from nf_meta.core.errors import (
|
|
4
|
+
GraphValidationError,
|
|
5
|
+
ValidationError,
|
|
6
|
+
format_errors_for_cli,
|
|
7
|
+
)
|
|
8
|
+
from nf_meta.runner import (
|
|
9
|
+
run_metapipeline,
|
|
10
|
+
get_registered_runners,
|
|
11
|
+
RunOptions,
|
|
12
|
+
NfMetaRunnerError,
|
|
13
|
+
)
|
|
14
|
+
from nf_meta.core.graph import MetaworkflowGraph
|
|
15
|
+
from nf_meta.core.session import start_session
|
|
16
|
+
from nf_meta.editor import get_registered_editors, EditorOptions, run_editor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
@click.version_option()
|
|
21
|
+
def cli() -> None:
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.command("editor")
|
|
26
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enables verbose mode")
|
|
27
|
+
@click.option(
|
|
28
|
+
"--editor",
|
|
29
|
+
"-e",
|
|
30
|
+
type=click.Choice(get_registered_editors()),
|
|
31
|
+
default="browser",
|
|
32
|
+
help="Editor backend to use",
|
|
33
|
+
)
|
|
34
|
+
@click.option("--host", help="Host to bind the editor server to")
|
|
35
|
+
@click.option(
|
|
36
|
+
"--port",
|
|
37
|
+
type=int,
|
|
38
|
+
help="Port for the editor server (auto-assigned if omitted)",
|
|
39
|
+
)
|
|
40
|
+
@click.argument("config", required=False, type=click.Path())
|
|
41
|
+
def edit_browser(config, verbose, editor, host, port):
|
|
42
|
+
try:
|
|
43
|
+
start_session(config)
|
|
44
|
+
opts = EditorOptions(editor_name=editor)
|
|
45
|
+
if host is not None:
|
|
46
|
+
opts.host = host
|
|
47
|
+
if port is not None:
|
|
48
|
+
opts.port = port
|
|
49
|
+
run_editor(opts)
|
|
50
|
+
except (GraphValidationError, ValidationError) as e:
|
|
51
|
+
click.echo(format_errors_for_cli(e))
|
|
52
|
+
raise SystemExit(1)
|
|
53
|
+
except FileNotFoundError as e:
|
|
54
|
+
click.echo(click.style(e, fg="red"))
|
|
55
|
+
raise SystemExit(1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@click.command("validate")
|
|
59
|
+
@click.argument("config", type=click.Path())
|
|
60
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enables verbose mode")
|
|
61
|
+
def validate_config(config, verbose):
|
|
62
|
+
try:
|
|
63
|
+
g = MetaworkflowGraph.from_file(config)
|
|
64
|
+
click.echo(click.style("✓ Config is valid", fg="green"))
|
|
65
|
+
except (GraphValidationError, ValidationError) as e:
|
|
66
|
+
click.echo(format_errors_for_cli(e))
|
|
67
|
+
raise SystemExit(1)
|
|
68
|
+
except FileNotFoundError as e:
|
|
69
|
+
click.echo(click.style(e, fg="red"))
|
|
70
|
+
raise SystemExit(1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@click.command("run")
|
|
74
|
+
@click.argument("config", type=click.Path())
|
|
75
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enables verbose mode")
|
|
76
|
+
@click.option(
|
|
77
|
+
"--runner",
|
|
78
|
+
"-r",
|
|
79
|
+
prompt=True,
|
|
80
|
+
type=click.Choice(get_registered_runners()),
|
|
81
|
+
default="python",
|
|
82
|
+
)
|
|
83
|
+
@click.option("--resume", is_flag=True, help="Resume a previous run")
|
|
84
|
+
@click.option(
|
|
85
|
+
"--output-lines",
|
|
86
|
+
"-l",
|
|
87
|
+
default="25",
|
|
88
|
+
type=int,
|
|
89
|
+
help="Number of lines of workflow output to stream to output window (Only for Python Runner!)",
|
|
90
|
+
)
|
|
91
|
+
@click.option("--start", "-s", type=str, help="ID of workflow to start from")
|
|
92
|
+
@click.option("--target", "-t", type=str, help="ID of workflow to run until")
|
|
93
|
+
@click.option(
|
|
94
|
+
"--profile",
|
|
95
|
+
"-p",
|
|
96
|
+
type=str,
|
|
97
|
+
help="Nextflow profile to apply globally, taking precedent over config values.",
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--stub",
|
|
101
|
+
is_flag=True,
|
|
102
|
+
help="Run all workflows as stub runs (passes -stub to Nextflow)",
|
|
103
|
+
)
|
|
104
|
+
def run(config, verbose, runner, resume, output_lines, start, target, profile, stub):
|
|
105
|
+
try:
|
|
106
|
+
run_options = RunOptions(
|
|
107
|
+
runner_name=runner,
|
|
108
|
+
verbose=verbose,
|
|
109
|
+
output_lines=output_lines,
|
|
110
|
+
nf_profile=profile,
|
|
111
|
+
stub=stub,
|
|
112
|
+
resume=resume,
|
|
113
|
+
start=start,
|
|
114
|
+
target=target,
|
|
115
|
+
)
|
|
116
|
+
g = MetaworkflowGraph.from_file(config)
|
|
117
|
+
run_metapipeline(g, run_options)
|
|
118
|
+
except (GraphValidationError, ValidationError) as e:
|
|
119
|
+
click.echo(format_errors_for_cli(e))
|
|
120
|
+
raise SystemExit(1)
|
|
121
|
+
except NfMetaRunnerError as e:
|
|
122
|
+
click.echo(click.style(e.message, fg="red"))
|
|
123
|
+
raise SystemExit(1)
|
|
124
|
+
except FileNotFoundError as e:
|
|
125
|
+
click.echo(click.style(e, fg="red"))
|
|
126
|
+
raise SystemExit(1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
cli.add_command(edit_browser)
|
|
130
|
+
cli.add_command(validate_config)
|
|
131
|
+
cli.add_command(run)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
cli()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
_ENV_VAR = "NF_META_CACHE_DIR"
|
|
10
|
+
GLOBAL_CACHE_DIR: Path = Path(
|
|
11
|
+
os.environ.get(_ENV_VAR) or Path.home() / ".cache" / "nf-meta"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def schema_cache_path(url: str, version: str) -> Path:
|
|
16
|
+
key = hashlib.sha256(f"{url}@{version}".encode()).hexdigest()[:16]
|
|
17
|
+
return GLOBAL_CACHE_DIR / "schemas" / f"{key}.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_schema_cache(url: str, version: str) -> dict | None:
|
|
21
|
+
path = schema_cache_path(url, version)
|
|
22
|
+
if not path.exists():
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
with open(path) as f:
|
|
26
|
+
return json.load(f)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
logger.warning("Failed to read schema cache at %s: %s", path, e)
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_schema_cache(url: str, version: str, schema: dict) -> None:
|
|
33
|
+
path = schema_cache_path(url, version)
|
|
34
|
+
try:
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
with open(path, "w") as f:
|
|
37
|
+
json.dump(schema, f)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
logger.warning("Failed to write schema cache to %s: %s", path, e)
|
|
@@ -25,7 +25,7 @@ class WorkflowReferenceErrors(GraphValidationError):
|
|
|
25
25
|
class SessionCommandError(Exception):
|
|
26
26
|
@dataclass
|
|
27
27
|
class FieldError:
|
|
28
|
-
workflow_id: str
|
|
28
|
+
workflow_id: Optional[str]
|
|
29
29
|
field: str
|
|
30
30
|
message: str
|
|
31
31
|
|
|
@@ -33,7 +33,13 @@ class SessionCommandError(Exception):
|
|
|
33
33
|
graph_errors: list[str]
|
|
34
34
|
|
|
35
35
|
@classmethod
|
|
36
|
-
def from_exception(
|
|
36
|
+
def from_exception(
|
|
37
|
+
cls,
|
|
38
|
+
e: WorkflowReferenceError
|
|
39
|
+
| WorkflowReferenceErrors
|
|
40
|
+
| GraphValidationError
|
|
41
|
+
| ValueError,
|
|
42
|
+
):
|
|
37
43
|
if isinstance(e, WorkflowReferenceError):
|
|
38
44
|
e = WorkflowReferenceErrors([e])
|
|
39
45
|
if isinstance(e, WorkflowReferenceErrors):
|
|
@@ -42,23 +48,30 @@ class SessionCommandError(Exception):
|
|
|
42
48
|
cls.FieldError(
|
|
43
49
|
workflow_id=ref_error.reference.source_wf_id,
|
|
44
50
|
field="params", # TODO: For now ok. Think about more finegrained errors for Workflow output/input references
|
|
45
|
-
message=ref_error.message
|
|
51
|
+
message=ref_error.message,
|
|
46
52
|
)
|
|
47
53
|
for ref_error in e.errors
|
|
48
54
|
],
|
|
49
|
-
graph_errors=[]
|
|
55
|
+
graph_errors=[],
|
|
50
56
|
)
|
|
51
57
|
return cls(field_errors=[], graph_errors=[str(e)])
|
|
52
|
-
|
|
58
|
+
|
|
53
59
|
def to_dict(self):
|
|
54
60
|
return {
|
|
55
|
-
"field_errors": [
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
"field_errors": [
|
|
62
|
+
{"workflow_id": e.workflow_id, "field": e.field, "message": e.message}
|
|
63
|
+
for e in self.field_errors
|
|
64
|
+
],
|
|
65
|
+
"graph_errors": self.graph_errors,
|
|
58
66
|
}
|
|
59
|
-
|
|
60
67
|
|
|
61
|
-
|
|
68
|
+
|
|
69
|
+
def format_errors_for_cli(
|
|
70
|
+
e: WorkflowReferenceError
|
|
71
|
+
| WorkflowReferenceErrors
|
|
72
|
+
| GraphValidationError
|
|
73
|
+
| ValidationError,
|
|
74
|
+
) -> str:
|
|
62
75
|
lines = []
|
|
63
76
|
if isinstance(e, WorkflowReferenceError):
|
|
64
77
|
e = WorkflowReferenceErrors([e])
|
|
@@ -74,5 +87,5 @@ def format_errors_for_cli(e: WorkflowReferenceError | WorkflowReferenceErrors |
|
|
|
74
87
|
lines.append(click.style("Validation failed:", fg="red", bold=True))
|
|
75
88
|
for err in e.errors():
|
|
76
89
|
field = ".".join(str(l) for l in err["loc"])
|
|
77
|
-
lines.append(f" {click.style(field, fg=
|
|
90
|
+
lines.append(f" {click.style(field, fg='yellow')}: {err['msg']}")
|
|
78
91
|
return "\n".join(lines)
|
|
@@ -7,7 +7,6 @@ from .models import Workflow, Transition, GlobalOptions
|
|
|
7
7
|
# Event Handler
|
|
8
8
|
# TODO: Test that Metaworkflow implements GraphEventHandler: isinstance(graph, GraphEventHandler)
|
|
9
9
|
class GraphEventHandler(Protocol):
|
|
10
|
-
|
|
11
10
|
def pop_events(self) -> tuple["Event"]: ...
|
|
12
11
|
|
|
13
12
|
def add_workflow(self, w: Workflow) -> None: ...
|
|
@@ -22,13 +21,14 @@ class GraphEventHandler(Protocol):
|
|
|
22
21
|
|
|
23
22
|
def update_global_options(self, g: GlobalOptions) -> None: ...
|
|
24
23
|
|
|
25
|
-
def deferred_validation(self)
|
|
24
|
+
def deferred_validation(self): ...
|
|
26
25
|
|
|
27
26
|
|
|
28
27
|
# ------------------------------------------
|
|
29
28
|
# ----- Events: State Changes --------------
|
|
30
29
|
# ------------------------------------------
|
|
31
30
|
|
|
31
|
+
|
|
32
32
|
class Event(Protocol):
|
|
33
33
|
def get_undo_cmd(self) -> "Command": ...
|
|
34
34
|
|
|
@@ -87,14 +87,14 @@ class GlobalOptionsUpdated:
|
|
|
87
87
|
# ----- COMMANDS: State Change Intents -----
|
|
88
88
|
# ------------------------------------------
|
|
89
89
|
|
|
90
|
-
class Command(Protocol):
|
|
91
90
|
|
|
91
|
+
class Command(Protocol):
|
|
92
92
|
def apply(self, graph: GraphEventHandler) -> None: ...
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
@dataclass(frozen=True)
|
|
96
96
|
class Transaction:
|
|
97
|
-
commands: tuple[Command]
|
|
97
|
+
commands: tuple[Command, ...]
|
|
98
98
|
|
|
99
99
|
def apply(self, graph: GraphEventHandler):
|
|
100
100
|
# TODO: Handle errors?
|
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
from contextlib import contextmanager
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Optional
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
import logging
|
|
5
5
|
|
|
6
6
|
import networkx as nx
|
|
7
7
|
|
|
8
|
-
from .models import
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
from .models import (
|
|
9
|
+
MetaworkflowConfig,
|
|
10
|
+
GlobalOptions,
|
|
11
|
+
Transition,
|
|
12
|
+
Workflow,
|
|
13
|
+
load_config,
|
|
14
|
+
dump_config,
|
|
15
|
+
CONFIG_VERSION_MAX,
|
|
16
|
+
)
|
|
17
|
+
from .events import (
|
|
18
|
+
Event,
|
|
19
|
+
WorkflowAdded,
|
|
20
|
+
WorkflowRemoved,
|
|
21
|
+
WorkflowUpdated,
|
|
22
|
+
TransitionAdded,
|
|
23
|
+
TransitionRemoved,
|
|
24
|
+
GlobalOptionsUpdated,
|
|
25
|
+
)
|
|
26
|
+
from .errors import (
|
|
27
|
+
GraphValidationError,
|
|
28
|
+
WorkflowReferenceError,
|
|
29
|
+
WorkflowReferenceErrors,
|
|
30
|
+
)
|
|
11
31
|
|
|
12
32
|
logger = logging.getLogger()
|
|
13
33
|
|
|
@@ -22,7 +42,7 @@ class MetaworkflowGraph:
|
|
|
22
42
|
|
|
23
43
|
def __init__(self):
|
|
24
44
|
self.G: nx.DiGraph = nx.DiGraph()
|
|
25
|
-
self.global_options
|
|
45
|
+
self.global_options = GlobalOptions()
|
|
26
46
|
self.config_version: str = CONFIG_VERSION_MAX
|
|
27
47
|
self._events: list[Event] = []
|
|
28
48
|
self._validation_suspended = False
|
|
@@ -41,7 +61,7 @@ class MetaworkflowGraph:
|
|
|
41
61
|
|
|
42
62
|
if not cfg:
|
|
43
63
|
raise ValueError(f"No config data loaded from {cfg_file}")
|
|
44
|
-
|
|
64
|
+
|
|
45
65
|
return cls.from_config(cfg)
|
|
46
66
|
|
|
47
67
|
@classmethod
|
|
@@ -82,17 +102,17 @@ class MetaworkflowGraph:
|
|
|
82
102
|
if wf.params and not self._validation_suspended:
|
|
83
103
|
self.validate_param_references(wf)
|
|
84
104
|
|
|
85
|
-
if
|
|
105
|
+
if wf.id not in self.G.nodes:
|
|
86
106
|
raise ValueError("Workflow has invalid id. Update unsuccessful!")
|
|
87
107
|
|
|
88
108
|
old_wf = self.get_workflow_by_id(wf.id)
|
|
89
109
|
self.G.nodes[wf.id]["workflow"] = wf.model_copy()
|
|
90
|
-
self._emit(WorkflowUpdated(old_workflow=old_wf, new_workflow=wf))
|
|
110
|
+
self._emit(WorkflowUpdated(old_workflow=old_wf, new_workflow=wf)) # type: ignore
|
|
91
111
|
|
|
92
112
|
def remove_workflow(self, wf_id: str, recursive=False):
|
|
93
113
|
if wf_id not in self.G.nodes:
|
|
94
114
|
raise ValueError("Invalid wf_id")
|
|
95
|
-
|
|
115
|
+
|
|
96
116
|
for edge in list(self.G.in_edges(wf_id)):
|
|
97
117
|
self.remove_transition(*edge)
|
|
98
118
|
|
|
@@ -101,14 +121,18 @@ class MetaworkflowGraph:
|
|
|
101
121
|
|
|
102
122
|
removed_wf = self.get_workflow_by_id(wf_id)
|
|
103
123
|
self.G.remove_node(wf_id)
|
|
104
|
-
self._emit(WorkflowRemoved(removed_wf))
|
|
124
|
+
self._emit(WorkflowRemoved(removed_wf)) # type: ignore
|
|
105
125
|
|
|
106
126
|
def add_transition(self, tr: Transition):
|
|
107
127
|
if tr.source not in self.G.nodes:
|
|
108
|
-
raise ValueError(
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Unknown node {tr.source} found in transition {tr.source}->{tr.target}"
|
|
130
|
+
)
|
|
109
131
|
|
|
110
132
|
if tr.target not in self.G.nodes:
|
|
111
|
-
raise ValueError(
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"Unknown node {tr.source} found in transition {tr.source}->{tr.target}"
|
|
135
|
+
)
|
|
112
136
|
|
|
113
137
|
if self.G.has_edge(tr.source, tr.target):
|
|
114
138
|
print(f"[warning] Edge already exists: {tr.source}->{tr.target}")
|
|
@@ -130,7 +154,7 @@ class MetaworkflowGraph:
|
|
|
130
154
|
|
|
131
155
|
if not self._validation_suspended:
|
|
132
156
|
source_wf = self.get_workflow_by_id(source)
|
|
133
|
-
self.validate_param_references(source_wf)
|
|
157
|
+
self.validate_param_references(source_wf) # type: ignore
|
|
134
158
|
|
|
135
159
|
def update_global_options(self, glob: GlobalOptions):
|
|
136
160
|
old_globals = self.global_options
|
|
@@ -143,22 +167,41 @@ class MetaworkflowGraph:
|
|
|
143
167
|
def validate_param_references(self, wf: Workflow):
|
|
144
168
|
errors = []
|
|
145
169
|
for ref in wf.field_refs:
|
|
146
|
-
if ref.
|
|
147
|
-
|
|
170
|
+
if ref.namespace != "params":
|
|
171
|
+
continue # output references not yet validated
|
|
172
|
+
referenced_wf = self.get_workflow_by_id(ref.target_wf_id)
|
|
173
|
+
if not referenced_wf:
|
|
174
|
+
errors.append(
|
|
175
|
+
WorkflowReferenceError(
|
|
176
|
+
ref, f"Reference to unknown workflow: {ref.target_wf_id}"
|
|
177
|
+
)
|
|
178
|
+
)
|
|
148
179
|
continue
|
|
149
|
-
|
|
180
|
+
|
|
150
181
|
if ref.target_wf_id not in list(self.G.predecessors(wf.id)):
|
|
151
|
-
errors.append(
|
|
182
|
+
errors.append(
|
|
183
|
+
WorkflowReferenceError(
|
|
184
|
+
ref,
|
|
185
|
+
f"Reference to workflow {ref.target_wf_id} that is not a predecessor of {wf.id}",
|
|
186
|
+
)
|
|
187
|
+
)
|
|
152
188
|
continue
|
|
153
189
|
|
|
154
|
-
referenced_wf = self.get_workflow_by_id(ref.target_wf_id)
|
|
155
190
|
if not referenced_wf.params or not ref.target_key:
|
|
156
|
-
errors.append(
|
|
191
|
+
errors.append(
|
|
192
|
+
WorkflowReferenceError(
|
|
193
|
+
ref, f"No referencable params in workflow {referenced_wf.id}"
|
|
194
|
+
)
|
|
195
|
+
)
|
|
157
196
|
continue
|
|
158
|
-
|
|
197
|
+
|
|
159
198
|
if not referenced_wf.params.get(ref.target_key):
|
|
160
|
-
errors.append(
|
|
161
|
-
|
|
199
|
+
errors.append(
|
|
200
|
+
WorkflowReferenceError(
|
|
201
|
+
ref, f"Reference to unresolvable param: {ref.target_key}"
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
162
205
|
if errors:
|
|
163
206
|
raise WorkflowReferenceErrors(errors)
|
|
164
207
|
|
|
@@ -191,51 +234,55 @@ class MetaworkflowGraph:
|
|
|
191
234
|
# ===========================
|
|
192
235
|
# EXPORT BACK TO CONFIG
|
|
193
236
|
# ===========================
|
|
194
|
-
def to_config(self) ->
|
|
237
|
+
def to_config(self) -> MetaworkflowConfig:
|
|
195
238
|
workflows = self.get_workflows()
|
|
196
239
|
transitions = self.get_transitions()
|
|
197
240
|
|
|
198
|
-
return MetaworkflowConfig.model_validate(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
241
|
+
return MetaworkflowConfig.model_validate(
|
|
242
|
+
{
|
|
243
|
+
"config_version": self.config_version,
|
|
244
|
+
"globals": self.global_options,
|
|
245
|
+
"workflows": workflows,
|
|
246
|
+
"transitions": transitions,
|
|
247
|
+
}
|
|
248
|
+
)
|
|
204
249
|
|
|
205
|
-
def to_file(self, file: Path|str) -> None:
|
|
250
|
+
def to_file(self, file: Path | str) -> None:
|
|
206
251
|
dump_config(self.to_config(), Path(file))
|
|
207
252
|
|
|
208
253
|
# ===========================
|
|
209
254
|
# UTILITIES
|
|
210
255
|
# ===========================
|
|
211
|
-
def get_workflow_by_id(self, id: str) -> Workflow:
|
|
256
|
+
def get_workflow_by_id(self, id: str) -> Optional[Workflow]:
|
|
212
257
|
try:
|
|
213
258
|
return self.G.nodes[id].get("workflow")
|
|
214
259
|
except KeyError:
|
|
215
260
|
return None
|
|
216
261
|
|
|
217
|
-
def get_transition(self, source: str, target: str) -> Transition:
|
|
262
|
+
def get_transition(self, source: str, target: str) -> Optional[Transition]:
|
|
218
263
|
try:
|
|
219
264
|
return self.G.edges[(source, target)].get("transition")
|
|
220
265
|
except KeyError:
|
|
221
266
|
return None
|
|
222
267
|
|
|
223
268
|
def get_transitions(self) -> list[Transition]:
|
|
224
|
-
return [self.get_transition(*e) for e in self.G.edges]
|
|
269
|
+
return [self.get_transition(*e) for e in self.G.edges] # type: ignore
|
|
225
270
|
|
|
226
271
|
def get_workflows(self) -> list[Workflow]:
|
|
227
|
-
return [self.get_workflow_by_id(n) for n in self.G.nodes]
|
|
272
|
+
return [self.get_workflow_by_id(n) for n in self.G.nodes] # type: ignore
|
|
228
273
|
|
|
229
274
|
def get_workflows_sorted(self) -> list[Workflow]:
|
|
230
275
|
"""Returns workflows in valid execution order."""
|
|
231
276
|
nodes_sorted = list(nx.topological_sort(self.G))
|
|
232
277
|
workflows = [self.get_workflow_by_id(n) for n in nodes_sorted]
|
|
233
|
-
return workflows
|
|
278
|
+
return workflows # type: ignore
|
|
234
279
|
|
|
235
|
-
def subset_workflows(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
280
|
+
def subset_workflows(
|
|
281
|
+
self,
|
|
282
|
+
start: Optional[str] = None,
|
|
283
|
+
target: Optional[str] = None,
|
|
284
|
+
workflows: Optional[list[Workflow]] = None,
|
|
285
|
+
):
|
|
239
286
|
|
|
240
287
|
if workflows is None:
|
|
241
288
|
workflows = self.get_workflows_sorted()
|
|
@@ -264,10 +311,10 @@ class MetaworkflowGraph:
|
|
|
264
311
|
return workflows_sorted[0]
|
|
265
312
|
|
|
266
313
|
def successors(self, wf: Workflow) -> list[Workflow]:
|
|
267
|
-
return [self.get_workflow_by_id(n) for n in self.G.successors(wf.id)]
|
|
314
|
+
return [self.get_workflow_by_id(n) for n in self.G.successors(wf.id)] # type: ignore
|
|
268
315
|
|
|
269
316
|
def predecessors(self, wf: Workflow) -> list[Workflow]:
|
|
270
|
-
return [self.get_workflow_by_id(n) for n in self.G.predecessors(wf.id)]
|
|
317
|
+
return [self.get_workflow_by_id(n) for n in self.G.predecessors(wf.id)] # type: ignore
|
|
271
318
|
|
|
272
319
|
@contextmanager
|
|
273
320
|
def deferred_validation(self):
|