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.
@@ -0,0 +1,9 @@
1
+ .DS_Store
2
+ bif_data/
3
+ data/
4
+ .pytest_cache/
5
+ .claude/
6
+ .venv/
7
+ __pycache__/
8
+ IMPLEMENTATION_PLAN.md
9
+ docs/build/
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: bioimageflow-core
3
+ Version: 0.1.0
4
+ Summary: Core types and tool base classes for BioImageFlow — zero dependencies
5
+ Requires-Python: >=3.10
@@ -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"