bioimageflow 0.1.1__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.
- bioimageflow-0.1.1/.gitignore +11 -0
- bioimageflow-0.1.1/PKG-INFO +10 -0
- bioimageflow-0.1.1/bioimageflow/__init__.py +16 -0
- bioimageflow-0.1.1/bioimageflow/cache.py +178 -0
- bioimageflow-0.1.1/bioimageflow/dataframe_tool.py +42 -0
- bioimageflow-0.1.1/bioimageflow/engine.py +1676 -0
- bioimageflow-0.1.1/bioimageflow/env_manager.py +249 -0
- bioimageflow-0.1.1/bioimageflow/node.py +238 -0
- bioimageflow-0.1.1/bioimageflow/paths.py +34 -0
- bioimageflow-0.1.1/bioimageflow/storage.py +59 -0
- bioimageflow-0.1.1/bioimageflow/sub_workflow.py +499 -0
- bioimageflow-0.1.1/bioimageflow/template.py +134 -0
- bioimageflow-0.1.1/bioimageflow/tool_loader.py +454 -0
- bioimageflow-0.1.1/bioimageflow/validation.py +135 -0
- bioimageflow-0.1.1/bioimageflow/workflow.py +546 -0
- bioimageflow-0.1.1/pyproject.toml +19 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bioimageflow
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python library for orchestrating bioimage analysis workflows
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: bioimageflow-core
|
|
7
|
+
Requires-Dist: pandas
|
|
8
|
+
Requires-Dist: pyarrow
|
|
9
|
+
Requires-Dist: pydantic
|
|
10
|
+
Requires-Dist: wetlands>=1.0.1
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""BioImageFlow orchestrator — main process only."""
|
|
2
|
+
|
|
3
|
+
from bioimageflow.dataframe_tool import DataFrameTool as DataFrameTool, Passthrough as Passthrough
|
|
4
|
+
from bioimageflow.workflow import Workflow as Workflow, ProgressEvent as ProgressEvent
|
|
5
|
+
from bioimageflow.engine import NodeStep as NodeStep, DisabledNodeError as DisabledNodeError
|
|
6
|
+
from bioimageflow.node import ColumnRef as ColumnRef, ColumnNotFoundError as ColumnNotFoundError, BindingError as BindingError, IndexAlignmentError as IndexAlignmentError
|
|
7
|
+
from bioimageflow.validation import get_inputs_schema as get_inputs_schema
|
|
8
|
+
from bioimageflow.sub_workflow import SubWorkflow as SubWorkflow, SubWorkflowNode as SubWorkflowNode
|
|
9
|
+
from bioimageflow.tool_loader import (
|
|
10
|
+
load_versioned_package as load_versioned_package,
|
|
11
|
+
unload_versioned_package as unload_versioned_package,
|
|
12
|
+
get_tool_package_info as get_tool_package_info,
|
|
13
|
+
require_tool_packages as require_tool_packages,
|
|
14
|
+
)
|
|
15
|
+
from bioimageflow.env_manager import configure_wetlands as configure_wetlands
|
|
16
|
+
from bioimageflow.paths import get_home as get_home, get_tool_store_path as get_tool_store_path, get_wetlands_path as get_wetlands_path
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Hashing, caching, and provenance."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from bioimageflow.storage import ensure_dirs, find_hash_dir, create_hash_dir
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize_dependencies(dependencies: dict[str, Any]) -> dict[str, Any]:
|
|
16
|
+
"""Normalize dependencies for consistent hashing."""
|
|
17
|
+
normalized: dict[str, Any] = {}
|
|
18
|
+
for key, value in sorted(dependencies.items()):
|
|
19
|
+
if isinstance(value, list):
|
|
20
|
+
normalized[key] = sorted(s.strip() for s in value)
|
|
21
|
+
elif isinstance(value, str):
|
|
22
|
+
normalized[key] = value.strip()
|
|
23
|
+
else:
|
|
24
|
+
normalized[key] = value
|
|
25
|
+
return normalized
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def compute_env_hash(dependencies: dict[str, Any]) -> str:
|
|
29
|
+
"""SHA256 of normalized dependencies."""
|
|
30
|
+
normalized = normalize_dependencies(dependencies)
|
|
31
|
+
data = json.dumps(normalized, sort_keys=True, separators=(",", ":"))
|
|
32
|
+
return hashlib.sha256(data.encode()).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def deterministic_serialize(obj: Any) -> str:
|
|
36
|
+
"""Serialize an object deterministically for hashing."""
|
|
37
|
+
def _default(o: Any) -> Any:
|
|
38
|
+
if isinstance(o, Path):
|
|
39
|
+
return o.as_posix()
|
|
40
|
+
if isinstance(o, (set, frozenset)):
|
|
41
|
+
return sorted(str(x) for x in o)
|
|
42
|
+
if isinstance(o, tuple):
|
|
43
|
+
return list(o)
|
|
44
|
+
if isinstance(o, Enum):
|
|
45
|
+
return o.value
|
|
46
|
+
if hasattr(o, '__dataclass_fields__'):
|
|
47
|
+
return {k: getattr(o, k) for k in o.__dataclass_fields__}
|
|
48
|
+
raise TypeError(
|
|
49
|
+
f"Cannot serialize {type(o).__name__} for hashing. "
|
|
50
|
+
f"Add explicit handling in deterministic_serialize()."
|
|
51
|
+
)
|
|
52
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=_default)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compute_signature_hash(
|
|
56
|
+
tool_name: str,
|
|
57
|
+
tool_version: str,
|
|
58
|
+
env_hash: str,
|
|
59
|
+
resolved_params: Any,
|
|
60
|
+
upstream_hashes: dict[str, str],
|
|
61
|
+
source_hash: str | None = None,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Compute the signature hash for a node."""
|
|
64
|
+
parts = [tool_name, str(tool_version), env_hash]
|
|
65
|
+
if source_hash is not None:
|
|
66
|
+
parts.append(source_hash)
|
|
67
|
+
parts.append(deterministic_serialize(resolved_params))
|
|
68
|
+
# Sort upstream hashes by node name for determinism
|
|
69
|
+
for name, h in sorted(upstream_hashes.items()):
|
|
70
|
+
parts.append(f"{name}:{h}")
|
|
71
|
+
combined = "|".join(parts)
|
|
72
|
+
return hashlib.sha256(combined.encode()).hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cache_lookup(node_dir: Path, sig_hash: str) -> Path | None:
|
|
76
|
+
"""Check if a cached result exists. Returns Path to the cache file or None.
|
|
77
|
+
|
|
78
|
+
Prefers Parquet; falls back to CSV for backwards compatibility.
|
|
79
|
+
"""
|
|
80
|
+
hash_dir = find_hash_dir(node_dir, sig_hash)
|
|
81
|
+
if hash_dir is not None:
|
|
82
|
+
parquet_path = hash_dir / "dataframe.parquet"
|
|
83
|
+
if parquet_path.exists():
|
|
84
|
+
return parquet_path
|
|
85
|
+
csv_path = hash_dir / "dataframe.csv"
|
|
86
|
+
if csv_path.exists():
|
|
87
|
+
return csv_path
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cache_save(
|
|
92
|
+
node_dir: Path,
|
|
93
|
+
sig_hash: str,
|
|
94
|
+
df: pd.DataFrame,
|
|
95
|
+
metadata: dict[str, Any] | None = None,
|
|
96
|
+
parameters: dict[str, Any] | None = None,
|
|
97
|
+
hash_dir: Path | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Save a DataFrame and metadata to the cache.
|
|
100
|
+
|
|
101
|
+
Writes Parquet (lossless) as the primary format and CSV as a
|
|
102
|
+
human-readable secondary output.
|
|
103
|
+
|
|
104
|
+
If *hash_dir* is provided (already created during execution), reuse it.
|
|
105
|
+
Otherwise create a new timestamped hash directory.
|
|
106
|
+
"""
|
|
107
|
+
if hash_dir is None:
|
|
108
|
+
hash_dir = create_hash_dir(node_dir, sig_hash)
|
|
109
|
+
else:
|
|
110
|
+
ensure_dirs(hash_dir)
|
|
111
|
+
# Parquet requires Arrow-serializable types — convert Path/SharedArray to str
|
|
112
|
+
df_save = df.copy()
|
|
113
|
+
for col in df_save.columns:
|
|
114
|
+
if df_save[col].dtype == object or pd.api.types.is_string_dtype(df_save[col]):
|
|
115
|
+
df_save[col] = df_save[col].apply(
|
|
116
|
+
lambda v: str(v) if not isinstance(v, (str, int, float, bool, type(None))) else v
|
|
117
|
+
)
|
|
118
|
+
df_save.to_parquet(hash_dir / "dataframe.parquet", index=True)
|
|
119
|
+
df_save.to_csv(hash_dir / "dataframe.csv", index=True)
|
|
120
|
+
if metadata:
|
|
121
|
+
(hash_dir / "metadata.json").write_text(
|
|
122
|
+
json.dumps(metadata, indent=2, default=str)
|
|
123
|
+
)
|
|
124
|
+
if parameters:
|
|
125
|
+
(hash_dir / "parameters.json").write_text(
|
|
126
|
+
json.dumps(parameters, indent=2, default=str)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cache_load(cache_path: Path) -> pd.DataFrame:
|
|
131
|
+
"""Load a DataFrame from cache.
|
|
132
|
+
|
|
133
|
+
Accepts either a ``.parquet`` or ``.csv`` path.
|
|
134
|
+
"""
|
|
135
|
+
if cache_path.suffix == ".parquet":
|
|
136
|
+
df = pd.read_parquet(cache_path)
|
|
137
|
+
else:
|
|
138
|
+
# Legacy CSV fallback
|
|
139
|
+
df = pd.read_csv(cache_path, index_col=0, keep_default_na=False)
|
|
140
|
+
# Restore numeric columns where possible
|
|
141
|
+
for col in df.columns:
|
|
142
|
+
if pd.api.types.is_string_dtype(df[col]):
|
|
143
|
+
try:
|
|
144
|
+
df[col] = pd.to_numeric(df[col])
|
|
145
|
+
except (ValueError, TypeError):
|
|
146
|
+
pass
|
|
147
|
+
df.index = df.index.astype(str)
|
|
148
|
+
return df
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cleanup_cache(node_dir: Path, max_executions: int = 0, max_age: str | None = None) -> None:
|
|
152
|
+
"""Remove old hash dirs based on retention policy."""
|
|
153
|
+
import shutil
|
|
154
|
+
|
|
155
|
+
node_dir = Path(node_dir)
|
|
156
|
+
if not node_dir.exists():
|
|
157
|
+
return
|
|
158
|
+
hash_dirs = sorted(
|
|
159
|
+
[d for d in node_dir.iterdir() if d.is_dir()],
|
|
160
|
+
key=lambda d: d.stat().st_mtime,
|
|
161
|
+
reverse=True,
|
|
162
|
+
)
|
|
163
|
+
# max_executions=0 means keep only the latest; N means keep N total
|
|
164
|
+
to_keep = max_executions if max_executions > 0 else 1
|
|
165
|
+
for d in hash_dirs[to_keep:]:
|
|
166
|
+
shutil.rmtree(d)
|
|
167
|
+
|
|
168
|
+
if max_age is not None:
|
|
169
|
+
now = time.time()
|
|
170
|
+
if max_age.endswith('d'):
|
|
171
|
+
max_seconds = int(max_age[:-1]) * 86400
|
|
172
|
+
elif max_age.endswith('h'):
|
|
173
|
+
max_seconds = int(max_age[:-1]) * 3600
|
|
174
|
+
else:
|
|
175
|
+
max_seconds = int(max_age)
|
|
176
|
+
for d in node_dir.iterdir():
|
|
177
|
+
if d.is_dir() and (now - d.stat().st_mtime) > max_seconds:
|
|
178
|
+
shutil.rmtree(d)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""DataFrameTool base class — main process only."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from bioimageflow_core.tool import BaseTool, IOModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Passthrough(IOModel):
|
|
9
|
+
"""Marker base class for DataFrameTool Outputs that preserve input columns."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DataFrameTool(BaseTool):
|
|
14
|
+
"""Tool that transforms DataFrames in the main process."""
|
|
15
|
+
|
|
16
|
+
def __call__(self, *upstream_nodes: Any, name: str | None = None, **kwargs: Any) -> Any:
|
|
17
|
+
"""Create a graph node. No computation occurs."""
|
|
18
|
+
try:
|
|
19
|
+
from bioimageflow.node import Node
|
|
20
|
+
except ImportError:
|
|
21
|
+
raise RuntimeError(
|
|
22
|
+
f"{type(self).__name__}.__call__() requires the bioimageflow "
|
|
23
|
+
f"orchestrator package."
|
|
24
|
+
)
|
|
25
|
+
return Node(tool=self, args=list(upstream_nodes), kwargs=kwargs, name=name)
|
|
26
|
+
|
|
27
|
+
def merge_dataframes(self, dfs: list[Any], arguments: Any) -> Any:
|
|
28
|
+
"""Default: inner join on index."""
|
|
29
|
+
if not dfs:
|
|
30
|
+
import pandas as pd
|
|
31
|
+
return pd.DataFrame()
|
|
32
|
+
if len(dfs) == 1:
|
|
33
|
+
return dfs[0].copy()
|
|
34
|
+
result = dfs[0]
|
|
35
|
+
for df in dfs[1:]:
|
|
36
|
+
result = result.join(df, how="inner", rsuffix="__bif_dup")
|
|
37
|
+
result = result[[c for c in result.columns if not c.endswith("__bif_dup")]]
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
def transform(self, df: Any, arguments: Any) -> Any:
|
|
41
|
+
"""Default: identity (passthrough)."""
|
|
42
|
+
return df
|