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.
@@ -0,0 +1,11 @@
1
+ .DS_Store
2
+ .claude/worktrees/
3
+ bif_data/
4
+ data/
5
+ .pytest_cache/
6
+ .claude/
7
+ .venv/
8
+ __pycache__/
9
+ docs/build/
10
+ wetlands/
11
+ packages/bioimageflow-common-tools/bioimageflow_common_tools/.vscode/
@@ -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