Source code for bioimageflow.registry
"""Public tool-registry abstraction.
A :class:`ToolRegistry` wraps :func:`load_versioned_package`,
:func:`resolve_tool_class`, and :func:`serialize_input_schema` /
:func:`serialize_output_schema` behind a single object that GUIs and
other consumers can use without rebuilding metadata serialization or
package-resolution layers themselves.
The registry deliberately separates **install** (a slow, network-bound
side effect) from **register** (a fast, in-process index lookup):
- :meth:`ToolRegistry.install_package` installs a package into the
tool store, doing the network work.
- :meth:`ToolRegistry.register_package` only loads what is already
installed and indexes its tools — never installs.
GUIs that validate on every keystroke must call ``register_package``
on hot paths and ``install_package`` only from a user-initiated
action.
Workflow exports may also carry embedded custom tool modules. GUIs can
call :meth:`ToolRegistry.register_workflow` to discover those local tools
for the specific workflow being edited without promoting them to a
versioned tool package.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from bioimageflow.paths import get_tool_store_path
from bioimageflow.tool_loader import (
ensure_installed,
load_versioned_package,
)
from bioimageflow.validation import (
serialize_input_schema,
serialize_output_schema,
)
logger = logging.getLogger("bioimageflow")
_RegistryKey = tuple[str, str, str, str]
[docs]
@dataclass(frozen=True)
class ToolMetadata:
"""Public, serialized description of a tool class.
Produced by :meth:`ToolRegistry.register_package` for every
BaseTool subclass found in the loaded package.
Attributes
----------
package
The tool's package name (e.g. ``"my_tools"``).
version
Pinned package version (e.g. ``"1.2.3"``).
module
Canonical module path (e.g. ``"my_tools.alpha"``).
class_name
The class name as written in the source.
inputs_schema
Output of :func:`serialize_input_schema` for this class.
outputs_schema
Output of :func:`serialize_output_schema` for this class.
display_name
Human-readable label declared on the class, or the class name.
tags
Free-form tags declared on the class (empty list if none).
"""
package: str
version: str
module: str
class_name: str
inputs_schema: dict[str, Any]
outputs_schema: dict[str, Any]
display_name: str
tags: tuple[str, ...] = field(default_factory=tuple)
[docs]
class ToolRegistry:
"""Stateful index of tool classes loaded from packages or workflows."""
[docs]
def __init__(self, *, store_path: Path | None = None) -> None:
self._store_path: Path = (
store_path if store_path is not None else get_tool_store_path()
)
self._classes: dict[_RegistryKey, type] = {}
self._metadata: dict[_RegistryKey, ToolMetadata] = {}
# -- install vs register ------------------------------------------------
[docs]
def install_package(self, name: str, version: str) -> None:
"""Install a versioned package into the tool store.
This is the slow, network-bound side effect — it does not load
or index anything. Call :meth:`register_package` separately
once the install completes.
``name`` is the import name (the module a workflow expects to
import). ``ensure_installed`` infers the PyPI name from this
by replacing underscores with hyphens, matching the convention
used by :meth:`Workflow.from_dict`'s auto-installer.
"""
pypi_name = name.replace("_", "-")
ensure_installed(name, version, pypi_name, self._store_path)
[docs]
def register_package(self, name: str, version: str) -> list[ToolMetadata]:
"""Load an *already installed* package and index its tools.
Returns the metadata for every BaseTool subclass
discovered in the package. Raises :class:`FileNotFoundError`
if the package is not present in the store — the caller must
install it first via :meth:`install_package` (or skip
registration on the hot path).
"""
from bioimageflow_core.tool import BaseTool
# load_versioned_package raises FileNotFoundError if the package
# is not installed — we deliberately propagate that error rather
# than auto-installing.
mod = load_versioned_package(name, version, self._store_path)
discovered: list[ToolMetadata] = []
seen: set[type] = set()
unstamped: dict[type, str] = {}
# Walk the loaded module and any submodules registered under its
# scoped name. Tool classes stamped by _stamp_tool_classes carry
# _bif_canonical_module pointing at the original module path.
import sys
scoped_prefix = mod.__name__
for sys_name, sys_mod in list(sys.modules.items()):
if sys_mod is None:
continue
if sys_name != scoped_prefix and not sys_name.startswith(
scoped_prefix + "."
):
continue
for attr_name in dir(sys_mod):
try:
obj = getattr(sys_mod, attr_name)
except Exception:
continue
if not isinstance(obj, type):
continue
if obj in seen:
continue
if not issubclass(obj, BaseTool):
continue
if obj is BaseTool:
continue
if getattr(obj, "_bif_package", None) != name:
# A tool-like class that wasn't stamped for this
# package. If its __module__ points at the canonical
# package name, it's almost certainly an absolute
# import in __init__.py bypassing the scoped loader.
# Track it so we can warn once per offending class
# below — silent skip would leave the GUI's tool
# list empty with no actionable diagnostic.
obj_module = getattr(obj, "__module__", "")
if (
obj_module == name
or obj_module.startswith(name + ".")
) and obj not in unstamped:
unstamped[obj] = obj_module
continue
seen.add(obj)
meta = self._build_metadata(obj)
key = self._key(meta)
self._classes[key] = obj
self._metadata[key] = meta
discovered.append(meta)
for cls, obj_module in unstamped.items():
logger.warning(
"Tool class %s.%s found in package %r v%s but missing "
"scope marker — this usually means the package's "
"__init__.py uses absolute imports "
"(`from %s.module import X`) instead of relative ones "
"(`from .module import X`). The class will not be "
"registered. See specs.md §Tool Packages.",
obj_module, cls.__name__, name, version, name,
)
return discovered
[docs]
def register_workflow(self, workflow: Any) -> list[ToolMetadata]:
"""Index custom tools carried by a workflow.
``workflow`` may be either a live :class:`bioimageflow.Workflow`
instance or an exported workflow dict. Live workflows are
inspected recursively; exported archives carry one deduplicated
``custom_sources`` table.
Package tools referenced by the workflow are not loaded or
installed here; use :meth:`register_package` for those.
"""
from bioimageflow.workflow_node import WorkflowNode
from bioimageflow.workflow import (
Workflow,
_is_workflow_custom_class,
_load_custom_sources,
_resolve_custom_tool_class,
)
classes: list[type] = []
if isinstance(workflow, Workflow):
def visit_live(definition: Workflow) -> None:
for node in definition.nodes.values():
if isinstance(node, WorkflowNode):
visit_live(node.workflow)
continue
cls = type(node.tool)
if _is_workflow_custom_class(cls):
classes.append(cls)
visit_live(workflow)
elif isinstance(workflow, dict):
if set(workflow) == {"archive_version", "workflow", "custom_sources"}:
modules = _load_custom_sources(workflow["custom_sources"])
graph = workflow["workflow"]
else:
modules = {}
graph = workflow
def visit_graph(definition: dict[str, Any]) -> None:
for node_data in definition.get("nodes", []):
if node_data.get("type") == "workflow":
visit_graph(node_data["workflow"])
continue
source_id = node_data.get("source_module")
if source_id:
classes.append(_resolve_custom_tool_class(
modules,
source_id,
node_data["tool_module"],
node_data["tool_class"],
))
visit_graph(graph)
else:
raise TypeError(
"register_workflow expects a Workflow instance or workflow dict"
)
discovered: list[ToolMetadata] = []
seen: set[type] = set()
for cls in classes:
if cls in seen:
continue
seen.add(cls)
meta = self._build_metadata(cls)
key = self._key(meta)
self._classes[key] = cls
self._metadata[key] = meta
discovered.append(meta)
return discovered
@staticmethod
def _key(meta: ToolMetadata) -> _RegistryKey:
return (meta.package, meta.version, meta.module, meta.class_name)
def _build_metadata(self, cls: type) -> ToolMetadata:
canonical = getattr(cls, "_bif_canonical_module", cls.__module__)
package = getattr(cls, "_bif_package", "")
version = getattr(cls, "_bif_package_version", "")
try:
inputs_schema = serialize_input_schema(cls)
except Exception:
inputs_schema = {}
try:
outputs_schema = serialize_output_schema(cls)
except Exception:
outputs_schema = {}
display_name = (
getattr(cls, "display_name", None) or cls.__name__
)
tags = tuple(getattr(cls, "tags", ()) or ())
return ToolMetadata(
package=package,
version=version,
module=canonical,
class_name=cls.__name__,
inputs_schema=inputs_schema,
outputs_schema=outputs_schema,
display_name=display_name,
tags=tags,
)
# -- lookups ------------------------------------------------------------
[docs]
def get_class(
self,
class_name: str,
*,
package: str | None = None,
version: str | None = None,
module: str | None = None,
) -> type | None:
"""Return a registered class, or ``None`` if not registered.
When several package versions expose the same class name, callers can
pass ``package`` / ``version`` / ``module`` to select the intended
class. A name-only lookup keeps the historical behavior and returns the
most recently registered matching class.
"""
match = self._find_key(class_name, package=package, version=version, module=module)
if match is None:
return None
return self._classes[match]
[docs]
def get_metadata(
self,
class_name: str,
*,
package: str | None = None,
version: str | None = None,
module: str | None = None,
) -> ToolMetadata | None:
"""Return registered :class:`ToolMetadata`, or ``None``."""
match = self._find_key(class_name, package=package, version=version, module=module)
if match is None:
return None
return self._metadata[match]
[docs]
def list_tools(self) -> list[ToolMetadata]:
"""Return all registered tool metadata, in insertion order."""
return list(self._metadata.values())
[docs]
def forget(
self,
class_name: str,
*,
package: str | None = None,
version: str | None = None,
module: str | None = None,
) -> None:
"""Drop matching classes from the registry. No-op if none match."""
for key in list(self._matching_keys(
class_name,
package=package,
version=version,
module=module,
)):
self._classes.pop(key, None)
self._metadata.pop(key, None)
def _find_key(
self,
class_name: str,
*,
package: str | None = None,
version: str | None = None,
module: str | None = None,
) -> _RegistryKey | None:
matches = list(self._matching_keys(
class_name,
package=package,
version=version,
module=module,
))
if not matches:
return None
return matches[-1]
def _matching_keys(
self,
class_name: str,
*,
package: str | None = None,
version: str | None = None,
module: str | None = None,
) -> list[_RegistryKey]:
return [
key
for key in self._metadata
if key[3] == class_name
and (package is None or key[0] == package)
and (version is None or key[1] == version)
and (module is None or key[2] == module)
]