bioimageflow-core 0.1.0__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_core-0.1.0/.gitignore +9 -0
- bioimageflow_core-0.1.0/PKG-INFO +5 -0
- bioimageflow_core-0.1.0/bioimageflow_core/__init__.py +14 -0
- bioimageflow_core-0.1.0/bioimageflow_core/arguments.py +36 -0
- bioimageflow_core-0.1.0/bioimageflow_core/environment.py +26 -0
- bioimageflow_core-0.1.0/bioimageflow_core/io.py +33 -0
- bioimageflow_core-0.1.0/bioimageflow_core/shm.py +48 -0
- bioimageflow_core-0.1.0/bioimageflow_core/tool.py +91 -0
- bioimageflow_core-0.1.0/bioimageflow_core/types.py +142 -0
- bioimageflow_core-0.1.0/bioimageflow_core/worker.py +90 -0
- bioimageflow_core-0.1.0/pyproject.toml +10 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from bioimageflow_core.types import (
|
|
2
|
+
GUIMeta,
|
|
3
|
+
ImagePath,
|
|
4
|
+
ImageShared,
|
|
5
|
+
ImageSpec,
|
|
6
|
+
Layout,
|
|
7
|
+
Semantic,
|
|
8
|
+
SharedArray,
|
|
9
|
+
check_compatibility,
|
|
10
|
+
extract_gui_meta,
|
|
11
|
+
)
|
|
12
|
+
from bioimageflow_core.environment import EnvironmentSpec, EnvironmentMismatchError, ResourceSpec
|
|
13
|
+
from bioimageflow_core.tool import BaseTool, IOModel, ProcessingTool
|
|
14
|
+
from bioimageflow_core.arguments import Arguments
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Arguments namespace and index lineage helpers."""
|
|
2
|
+
|
|
3
|
+
from difflib import get_close_matches as _get_close_matches
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Arguments:
|
|
8
|
+
"""
|
|
9
|
+
Lightweight namespace for passing resolved values to tool methods.
|
|
10
|
+
Supports attribute access with helpful error messages on typos.
|
|
11
|
+
"""
|
|
12
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
13
|
+
self.__dict__.update(kwargs)
|
|
14
|
+
|
|
15
|
+
def __getattr__(self, name: str) -> Any:
|
|
16
|
+
if name.startswith('_'):
|
|
17
|
+
raise AttributeError(name)
|
|
18
|
+
available = [k for k in self.__dict__ if not k.startswith('_')]
|
|
19
|
+
close = _get_close_matches(name, available, n=3, cutoff=0.6)
|
|
20
|
+
msg = f"Arguments has no field '{name}'."
|
|
21
|
+
if close:
|
|
22
|
+
msg += f" Did you mean: {', '.join(close)}?"
|
|
23
|
+
else:
|
|
24
|
+
msg += f" Available fields: {', '.join(sorted(available))}"
|
|
25
|
+
raise AttributeError(msg)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_index_lineage(index: str) -> list[str]:
|
|
29
|
+
"""Split an exploded index into its lineage components."""
|
|
30
|
+
return index.split("::")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parent_index(index: str) -> str:
|
|
34
|
+
"""Return the parent index (strip last explosion level)."""
|
|
35
|
+
parts = index.split("::")
|
|
36
|
+
return "::".join(parts[:-1]) if len(parts) > 1 else index
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Environment and resource specifications."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class EnvironmentSpec:
|
|
9
|
+
"""Defines a reusable Wetlands environment specification."""
|
|
10
|
+
name: str
|
|
11
|
+
dependencies: dict[str, str | list[str]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ResourceSpec:
|
|
16
|
+
"""Resource requirements for a processing tool."""
|
|
17
|
+
cpu: int = 1
|
|
18
|
+
gpu: int = 0
|
|
19
|
+
gpu_memory: str | None = None
|
|
20
|
+
max_concurrent: int = 0
|
|
21
|
+
memory: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EnvironmentMismatchError(Exception):
|
|
25
|
+
"""Raised when two EnvironmentSpecs share a name but differ in dependencies."""
|
|
26
|
+
pass
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""I/O dispatch — zero declared dependencies. Uses numpy at runtime."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Generator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from multiprocessing.shared_memory import SharedMemory
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from bioimageflow_core.types import SharedArray
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@contextmanager
|
|
13
|
+
def load_image(source: Any, *, file_reader: Callable[[Path], Any]) -> Generator[Any, None, None]:
|
|
14
|
+
"""
|
|
15
|
+
Dispatch between file and shared memory sources.
|
|
16
|
+
- SharedArray: attaches to shared memory, yields numpy view.
|
|
17
|
+
- Path or str: delegates to file_reader, yields result.
|
|
18
|
+
"""
|
|
19
|
+
if isinstance(source, SharedArray):
|
|
20
|
+
import numpy as np
|
|
21
|
+
shm = SharedMemory(name=source.name)
|
|
22
|
+
try:
|
|
23
|
+
arr = np.ndarray(source.shape, dtype=source.dtype, buffer=shm.buf)
|
|
24
|
+
yield arr
|
|
25
|
+
finally:
|
|
26
|
+
shm.close()
|
|
27
|
+
else:
|
|
28
|
+
yield file_reader(Path(source))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save_image(destination: str | Path, data: Any, *, file_writer: Callable[[Path, Any], None]) -> None:
|
|
32
|
+
"""Save image data to disk using the provided writer."""
|
|
33
|
+
file_writer(Path(destination), data)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Shared memory helpers — zero declared dependencies. Uses numpy at runtime."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from bioimageflow_core.types import SharedArray
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@contextmanager
|
|
12
|
+
def create_shared_output(data: Any, name: str | None = None) -> Generator[SharedArray, None, None]:
|
|
13
|
+
"""
|
|
14
|
+
Create a shared memory segment, copy data into it, and yield a SharedArray.
|
|
15
|
+
Closes the local handle on exit but does NOT unlink (data persists).
|
|
16
|
+
"""
|
|
17
|
+
import numpy as np
|
|
18
|
+
from multiprocessing.shared_memory import SharedMemory
|
|
19
|
+
|
|
20
|
+
arr = np.asarray(data)
|
|
21
|
+
if name is None:
|
|
22
|
+
name = f"bif_{uuid.uuid4().hex[:16]}"
|
|
23
|
+
|
|
24
|
+
shm = SharedMemory(name=name, create=True, size=arr.nbytes)
|
|
25
|
+
try:
|
|
26
|
+
shared_arr = np.ndarray(arr.shape, dtype=arr.dtype, buffer=shm.buf)
|
|
27
|
+
shared_arr[:] = arr[:]
|
|
28
|
+
ref = SharedArray(name=shm.name, shape=arr.shape, dtype=str(arr.dtype))
|
|
29
|
+
yield ref
|
|
30
|
+
finally:
|
|
31
|
+
shm.close()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextmanager
|
|
35
|
+
def open_shared_array(ref: SharedArray) -> Generator[Any, None, None]:
|
|
36
|
+
"""
|
|
37
|
+
Attach to an existing shared memory segment.
|
|
38
|
+
Yields a zero-copy numpy array. Closes handle on exit.
|
|
39
|
+
"""
|
|
40
|
+
import numpy as np
|
|
41
|
+
from multiprocessing.shared_memory import SharedMemory
|
|
42
|
+
|
|
43
|
+
shm = SharedMemory(name=ref.name)
|
|
44
|
+
try:
|
|
45
|
+
arr = np.ndarray(ref.shape, dtype=ref.dtype, buffer=shm.buf)
|
|
46
|
+
yield arr
|
|
47
|
+
finally:
|
|
48
|
+
shm.close()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Tool base classes — zero external dependencies."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IOModel:
|
|
7
|
+
"""Lightweight declarative base for tool Inputs/Outputs."""
|
|
8
|
+
|
|
9
|
+
@classmethod
|
|
10
|
+
def _get_all_annotations(cls) -> dict[str, Any]:
|
|
11
|
+
"""Walk the MRO to collect annotations from all ancestor classes."""
|
|
12
|
+
annotations: dict[str, Any] = {}
|
|
13
|
+
for klass in reversed(cls.__mro__):
|
|
14
|
+
annotations.update(getattr(klass, '__annotations__', {}))
|
|
15
|
+
return annotations
|
|
16
|
+
|
|
17
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
18
|
+
all_annotations = self._get_all_annotations()
|
|
19
|
+
unknown = set(kwargs) - set(all_annotations)
|
|
20
|
+
if unknown:
|
|
21
|
+
raise TypeError(f"Unknown fields: {unknown}")
|
|
22
|
+
for name in all_annotations:
|
|
23
|
+
if name in kwargs:
|
|
24
|
+
setattr(self, name, kwargs[name])
|
|
25
|
+
elif hasattr(self.__class__, name):
|
|
26
|
+
setattr(self, name, getattr(self.__class__, name))
|
|
27
|
+
else:
|
|
28
|
+
raise TypeError(f"Missing required field: '{name}'")
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
fields = {k: getattr(self, k) for k in self._get_all_annotations()}
|
|
32
|
+
return f"{self.__class__.__name__}({fields})"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BaseTool:
|
|
36
|
+
"""
|
|
37
|
+
Common base for all tools. Provides identity and Inputs.
|
|
38
|
+
__call__ is NOT defined here — each subclass defines its own.
|
|
39
|
+
"""
|
|
40
|
+
name: ClassVar[str]
|
|
41
|
+
documentation: ClassVar[str] = ""
|
|
42
|
+
tags: ClassVar[list[str]] = []
|
|
43
|
+
Inputs: ClassVar[type[IOModel]] = IOModel
|
|
44
|
+
Outputs: ClassVar[type[IOModel] | None] = None
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ProcessingTool(BaseTool):
|
|
51
|
+
"""Tool that processes data in an isolated Wetlands environment."""
|
|
52
|
+
environment: ClassVar[Any]
|
|
53
|
+
Outputs: ClassVar[type[IOModel] | None]
|
|
54
|
+
resources: ClassVar[Any] = None
|
|
55
|
+
|
|
56
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
57
|
+
super().__init_subclass__(**kwargs)
|
|
58
|
+
# Only validate leaf concrete classes that define BOTH name and Outputs on themselves
|
|
59
|
+
has_own_name = 'name' in cls.__dict__ and isinstance(cls.__dict__['name'], str)
|
|
60
|
+
has_own_outputs = 'Outputs' in cls.__dict__
|
|
61
|
+
if not has_own_name or not has_own_outputs:
|
|
62
|
+
return
|
|
63
|
+
# Check that at least one of process_row or process_batch is overridden
|
|
64
|
+
has_process_row = cls.process_row is not ProcessingTool.process_row
|
|
65
|
+
has_process_batch = cls.process_batch is not ProcessingTool.process_batch
|
|
66
|
+
if not has_process_row and not has_process_batch:
|
|
67
|
+
raise TypeError(
|
|
68
|
+
f"{cls.__name__} must implement process_row or process_batch"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def __call__(self, *, name: str | None = None, **kwargs: Any) -> Any:
|
|
72
|
+
"""Create a graph node. No computation occurs."""
|
|
73
|
+
try:
|
|
74
|
+
from bioimageflow.node import Node
|
|
75
|
+
except ImportError:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
f"{type(self).__name__}.__call__() requires the bioimageflow "
|
|
78
|
+
f"orchestrator package. This method is not available in worker "
|
|
79
|
+
f"environments — use process_row/process_batch instead."
|
|
80
|
+
)
|
|
81
|
+
return Node(tool=self, kwargs=kwargs, name=name)
|
|
82
|
+
|
|
83
|
+
def process_row(self, arguments: Any) -> Any:
|
|
84
|
+
"""Process a single row. Override in subclasses."""
|
|
85
|
+
raise NotImplementedError(
|
|
86
|
+
f"{type(self).__name__} must implement process_row or process_batch."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def process_batch(self, arguments_list: list[Any]) -> Any:
|
|
90
|
+
"""Process all rows at once. Override for batch processing."""
|
|
91
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""BioImageFlow type system — zero external dependencies."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Any, Set, Tuple, get_args, get_origin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Semantic(str, Enum):
|
|
11
|
+
"""What the pixel values represent."""
|
|
12
|
+
BINARY = "binary"
|
|
13
|
+
LABEL = "label"
|
|
14
|
+
INTENSITY = "intensity"
|
|
15
|
+
PROBABILITY = "probability"
|
|
16
|
+
DISPLACEMENT = "displacement"
|
|
17
|
+
FEATURE = "feature"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Layout(str, Enum):
|
|
21
|
+
"""Axis ordering of the image data."""
|
|
22
|
+
PLANAR = "YX"
|
|
23
|
+
PLANAR_CHANNEL = "CYX"
|
|
24
|
+
PLANAR_TIME = "TYX"
|
|
25
|
+
PLANAR_TIME_CHANNEL = "TCYX"
|
|
26
|
+
VOLUMETRIC = "ZYX"
|
|
27
|
+
VOLUMETRIC_CHANNEL = "CZYX"
|
|
28
|
+
VOLUMETRIC_TIME = "TZYX"
|
|
29
|
+
VOLUMETRIC_TIME_CHANNEL = "TCZYX"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def ndim(self) -> int:
|
|
33
|
+
return len(self.value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ImageSpec:
|
|
38
|
+
"""Defines type constraints. Empty sets mean 'any' (wildcard)."""
|
|
39
|
+
semantics: Set[Semantic] = field(default_factory=set)
|
|
40
|
+
layouts: Set[Layout] = field(default_factory=set)
|
|
41
|
+
dtypes: Set[str] = field(default_factory=set)
|
|
42
|
+
formats: Set[str] = field(default_factory=set)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class SharedArray:
|
|
47
|
+
"""A reference to data in shared memory. Picklable."""
|
|
48
|
+
name: str
|
|
49
|
+
shape: Tuple[int, ...]
|
|
50
|
+
dtype: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalize_param(value: Any) -> set[Any]:
|
|
54
|
+
"""Convert single value or None to a set."""
|
|
55
|
+
if value is None:
|
|
56
|
+
return set()
|
|
57
|
+
if isinstance(value, set):
|
|
58
|
+
return value
|
|
59
|
+
if isinstance(value, (list, tuple, frozenset)):
|
|
60
|
+
return set(value)
|
|
61
|
+
return {value}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ImagePath(
|
|
65
|
+
semantics: Any = None,
|
|
66
|
+
layouts: Any = None,
|
|
67
|
+
dtypes: Any = None,
|
|
68
|
+
formats: Any = None,
|
|
69
|
+
) -> Any:
|
|
70
|
+
"""Returns Annotated[Path, ImageSpec(...)]. Used for file-based image data."""
|
|
71
|
+
spec = ImageSpec(
|
|
72
|
+
semantics=_normalize_param(semantics),
|
|
73
|
+
layouts=_normalize_param(layouts),
|
|
74
|
+
dtypes=_normalize_param(dtypes),
|
|
75
|
+
formats=_normalize_param(formats),
|
|
76
|
+
)
|
|
77
|
+
return Annotated[Path, spec]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ImageShared(
|
|
81
|
+
semantics: Any = None,
|
|
82
|
+
layouts: Any = None,
|
|
83
|
+
dtypes: Any = None,
|
|
84
|
+
) -> Any:
|
|
85
|
+
"""Returns Annotated[SharedArray, ImageSpec(...)]. Formats is implicitly {'memory'}."""
|
|
86
|
+
spec = ImageSpec(
|
|
87
|
+
semantics=_normalize_param(semantics),
|
|
88
|
+
layouts=_normalize_param(layouts),
|
|
89
|
+
dtypes=_normalize_param(dtypes),
|
|
90
|
+
formats={"memory"},
|
|
91
|
+
)
|
|
92
|
+
return Annotated[SharedArray, spec]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class GUIMeta:
|
|
97
|
+
"""Declarative GUI hints for a tool input field.
|
|
98
|
+
|
|
99
|
+
Attach to an ``Inputs`` annotation via ``Annotated`` to control how a
|
|
100
|
+
GUI renders the field.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
connectable : bool
|
|
105
|
+
Whether this input can be bound to an upstream column. Defaults to
|
|
106
|
+
``True``. Set to ``False`` for pure user-parameters (e.g. a
|
|
107
|
+
threshold slider).
|
|
108
|
+
min : float | None
|
|
109
|
+
Minimum allowed value (numeric fields only).
|
|
110
|
+
max : float | None
|
|
111
|
+
Maximum allowed value (numeric fields only).
|
|
112
|
+
step : float | None
|
|
113
|
+
Step increment for spinbox / slider widgets (numeric fields only).
|
|
114
|
+
"""
|
|
115
|
+
connectable: bool = True
|
|
116
|
+
min: float | None = None
|
|
117
|
+
max: float | None = None
|
|
118
|
+
step: float | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract_gui_meta(annotation: Any) -> GUIMeta | None:
|
|
122
|
+
"""Extract :class:`GUIMeta` from an ``Annotated`` type, or return ``None``."""
|
|
123
|
+
if get_origin(annotation) is Annotated:
|
|
124
|
+
for arg in get_args(annotation):
|
|
125
|
+
if isinstance(arg, GUIMeta):
|
|
126
|
+
return arg
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_compatibility(producer_spec: ImageSpec, consumer_spec: ImageSpec) -> bool:
|
|
131
|
+
"""Returns True if the producer's output is acceptable for the consumer's input."""
|
|
132
|
+
for attr in ["semantics", "layouts", "dtypes", "formats"]:
|
|
133
|
+
producer_values: set[Any] = getattr(producer_spec, attr)
|
|
134
|
+
consumer_values: set[Any] = getattr(consumer_spec, attr)
|
|
135
|
+
if not consumer_values:
|
|
136
|
+
continue
|
|
137
|
+
if not producer_values:
|
|
138
|
+
warnings.warn(f"Producer does not declare '{attr}'; cannot verify.")
|
|
139
|
+
continue
|
|
140
|
+
if not producer_values.intersection(consumer_values):
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Worker-side dispatcher for Wetlands environments.
|
|
2
|
+
|
|
3
|
+
This module is imported by the orchestrator via env.import_module()
|
|
4
|
+
inside isolated Conda environments. It discovers tool classes in a
|
|
5
|
+
given module and dispatches process_row/process_batch calls.
|
|
6
|
+
|
|
7
|
+
All functions accept and return only picklable types (dicts, lists,
|
|
8
|
+
strings, numbers) to cross the Wetlands serialization boundary.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import inspect
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from bioimageflow_core.arguments import Arguments
|
|
16
|
+
from bioimageflow_core.tool import BaseTool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Per-module registries: module_path -> {class_name -> class}
|
|
20
|
+
_tool_registries: dict[str, dict[str, type]] = {}
|
|
21
|
+
# Per-module instances: module_path -> {class_name -> instance}
|
|
22
|
+
_instances: dict[str, dict[str, BaseTool]] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _discover_tools(module: object) -> dict[str, type]:
|
|
26
|
+
"""Build a name->class registry from all BaseTool subclasses in the module."""
|
|
27
|
+
registry: dict[str, type] = {}
|
|
28
|
+
for name, obj in inspect.getmembers(module, inspect.isclass):
|
|
29
|
+
if issubclass(obj, BaseTool) and obj is not BaseTool and hasattr(obj, 'name'):
|
|
30
|
+
registry[obj.__name__] = obj
|
|
31
|
+
return registry
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_instance(module_path: str, tool_class_name: str) -> BaseTool:
|
|
35
|
+
"""Get or create a cached tool instance for the given module and class."""
|
|
36
|
+
if module_path not in _tool_registries:
|
|
37
|
+
mod = importlib.import_module(module_path)
|
|
38
|
+
_tool_registries[module_path] = _discover_tools(mod)
|
|
39
|
+
_instances[module_path] = {}
|
|
40
|
+
registry = _tool_registries[module_path]
|
|
41
|
+
instances = _instances[module_path]
|
|
42
|
+
if tool_class_name not in instances:
|
|
43
|
+
if tool_class_name not in registry:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Tool class '{tool_class_name}' not found in module '{module_path}'. "
|
|
46
|
+
f"Available: {list(registry.keys())}"
|
|
47
|
+
)
|
|
48
|
+
instances[tool_class_name] = registry[tool_class_name]()
|
|
49
|
+
return instances[tool_class_name]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _outputs_to_dict(outputs: object) -> dict:
|
|
53
|
+
"""Convert an Outputs instance to a plain dict with picklable values."""
|
|
54
|
+
if hasattr(outputs, '_get_all_annotations'):
|
|
55
|
+
d: dict = {}
|
|
56
|
+
for k in outputs._get_all_annotations():
|
|
57
|
+
v = getattr(outputs, k)
|
|
58
|
+
if isinstance(v, Path):
|
|
59
|
+
v = str(v)
|
|
60
|
+
d[k] = v
|
|
61
|
+
return d
|
|
62
|
+
return {k: str(v) if isinstance(v, Path) else v for k, v in vars(outputs).items()}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_process_row(module_path: str, tool_class_name: str, arguments_dict: dict) -> list[dict]:
|
|
66
|
+
"""Dispatch a single-row call to a tool's process_row method.
|
|
67
|
+
|
|
68
|
+
Returns a list of output dicts (one per output row, usually one).
|
|
69
|
+
"""
|
|
70
|
+
tool = _get_instance(module_path, tool_class_name)
|
|
71
|
+
args = Arguments(**arguments_dict)
|
|
72
|
+
result = tool.process_row(args)
|
|
73
|
+
outputs = result if isinstance(result, list) else [result]
|
|
74
|
+
return [_outputs_to_dict(out) for out in outputs]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_process_batch(
|
|
78
|
+
module_path: str, tool_class_name: str, arguments_dicts: list[dict],
|
|
79
|
+
) -> list[list[dict]]:
|
|
80
|
+
"""Dispatch a batch call to a tool's process_batch method.
|
|
81
|
+
|
|
82
|
+
Returns a list of lists of output dicts (one inner list per input row).
|
|
83
|
+
"""
|
|
84
|
+
tool = _get_instance(module_path, tool_class_name)
|
|
85
|
+
args_list = [Arguments(**d) for d in arguments_dicts]
|
|
86
|
+
results = tool.process_batch(args_list)
|
|
87
|
+
# Auto-wrap list[Outputs] -> list[list[Outputs]] for 1-to-1 batch tools
|
|
88
|
+
if results and not isinstance(results[0], list):
|
|
89
|
+
results = [[r] for r in results]
|
|
90
|
+
return [[_outputs_to_dict(out) for out in row_outputs] for row_outputs in results]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bioimageflow-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Core types and tool base classes for BioImageFlow — zero dependencies"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = []
|
|
7
|
+
|
|
8
|
+
[build-system]
|
|
9
|
+
requires = ["hatchling"]
|
|
10
|
+
build-backend = "hatchling.build"
|