Files
tono/tono.py

229 lines
7.7 KiB
Python

"""
tono — topographical signal processing library.
This module provides a convenient ``import tono`` entry point for using
tono's processing nodes as a standalone Python library, without running
the web server.
Quick start::
import tono
# Load SPM data
fields = tono.load("scan.gwy")
# Typed call syntax — help(tono.GaussianFilter) shows the signature
result = tono.GaussianFilter(fields[0], sigma=3.0)
# String-based dispatch for dynamic cases
result = tono.apply("GaussianFilter", fields[0], sigma=3.0)
# Create a field from a numpy array
import numpy as np
f = tono.field(np.random.randn(256, 256))
See ``backend.api`` for the full API documentation.
"""
from __future__ import annotations
import inspect
from typing import Any, Callable, Literal
from backend.api import ( # noqa: F401
apply,
channel_names,
describe,
field,
get_node,
load,
nodes,
supported_formats,
)
from backend.api import _ensure_registry as _ensure_registry
from backend.data_types import ( # noqa: F401
DataField,
DataTable,
LineData,
MeshModel,
RecordTable,
)
# ── Runtime node wrappers ─────────────────────────────────────────────
#
# PEP 562 module __getattr__: expose every registered node as a
# top-level callable so users can write ``tono.GaussianFilter(field, sigma=2)``
# instead of ``tono.apply("GaussianFilter", field, sigma=2)``. Each wrapper
# carries a real inspect.Signature synthesised from the node's INPUT_TYPES,
# so help(tono.GaussianFilter), Jupyter's ``?``, and IPython tab-completion
# all show the correct parameters, defaults, and enum choices.
_NODE_WRAPPER_CACHE: dict[str, Callable[..., Any]] = {}
# Map tono type-system names to Python runtime types used in annotations.
# Unknown names fall back to ``typing.Any``.
_TONO_TYPE_MAP: dict[str, Any] = {
"DATA_FIELD": DataField,
"IMAGE": DataField,
"FLOAT": float,
"INT": int,
"STRING": str,
"BOOL": bool,
"LINE": LineData,
"RECORD_TABLE": RecordTable,
"TABLE": RecordTable,
"DATA_TABLE": DataTable,
"MESH": MeshModel,
"COORD": tuple,
}
def _tono_type_to_python(spec: Any) -> Any:
"""Resolve a tono input/output type spec to a Python annotation.
``spec`` is the first element of an INPUT_TYPES tuple — either a string
type name (``"FLOAT"``) or a list of choices (``["sobel", "prewitt"]``).
"""
if isinstance(spec, (list, tuple)) and all(isinstance(x, str) for x in spec):
# Enum input — use typing.Literal so help() shows the exact choices.
if len(spec) == 0:
return str
return Literal[tuple(spec)] # type: ignore[valid-type]
if isinstance(spec, str):
return _TONO_TYPE_MAP.get(spec, Any)
return Any
def _build_node_wrapper(name: str, cls: type) -> Callable[..., Any]:
"""Build a callable wrapper with a synthesised inspect.Signature for ``cls``."""
input_types = cls.INPUT_TYPES()
required = input_types.get("required", {})
optional = input_types.get("optional", {})
params: list[inspect.Parameter] = []
param_doc_lines: list[str] = []
EMPTY = inspect.Parameter.empty
def _param(arg_name: str, raw_spec: Any, force_optional: bool) -> None:
# raw_spec is a tuple like ("FLOAT", {...}) or (["sobel","prewitt"],)
type_token = raw_spec[0] if isinstance(raw_spec, (list, tuple)) and raw_spec else raw_spec
meta: dict = {}
if isinstance(raw_spec, (list, tuple)) and len(raw_spec) >= 2 and isinstance(raw_spec[1], dict):
meta = raw_spec[1]
annotation = _tono_type_to_python(type_token)
if force_optional:
default: Any = None
elif "default" in meta:
default = meta["default"]
else:
default = EMPTY
params.append(
inspect.Parameter(
arg_name,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=default,
annotation=annotation,
)
)
# Docstring line
if isinstance(type_token, (list, tuple)):
type_desc = "{" + ", ".join(repr(c) for c in type_token) + "}"
elif isinstance(type_token, str):
type_desc = type_token
else:
type_desc = "Any"
default_desc = "" if default is EMPTY else f" (default: {default!r})"
param_doc_lines.append(f" {arg_name} : {type_desc}{default_desc}")
for arg_name, raw_spec in required.items():
_param(arg_name, raw_spec, force_optional=False)
# Python signatures require that any parameter with a default is followed
# only by parameters that also have defaults. Some nodes declare required
# inputs in an order that violates this (e.g. ThresholdMask has ``threshold``
# with a default followed by ``direction`` without). Walk the required
# params in reverse and clear defaults that would break ordering — the
# default still fires at runtime via apply()'s metadata default-filling,
# it just isn't displayed in the signature for that slot.
_seen_no_default = False
for i in range(len(params) - 1, -1, -1):
if params[i].default is EMPTY:
_seen_no_default = True
elif _seen_no_default:
params[i] = params[i].replace(default=EMPTY)
for arg_name, raw_spec in optional.items():
_param(arg_name, raw_spec, force_optional=True)
# Return annotation: single-output nodes return the unwrapped Python type;
# multi-output nodes return a tuple.
outputs = getattr(cls, "OUTPUTS", ())
if len(outputs) == 1:
return_annotation: Any = _tono_type_to_python(outputs[0][0])
else:
return_annotation = tuple
signature = inspect.Signature(params, return_annotation=return_annotation)
description = (getattr(cls, "DESCRIPTION", "") or "").strip()
output_lines = [
f" {out_name} : {out_type}"
for out_type, out_name, *_ in outputs
]
doc_parts: list[str] = []
if description:
doc_parts.append(description)
doc_parts.append("")
doc_parts.append("Parameters")
doc_parts.append("----------")
doc_parts.extend(param_doc_lines if param_doc_lines else [" (none)"])
if output_lines:
doc_parts.append("")
doc_parts.append("Returns")
doc_parts.append("-------")
doc_parts.extend(output_lines)
docstring = "\n".join(doc_parts)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return apply(name, *args, **kwargs)
wrapper.__name__ = name
wrapper.__qualname__ = f"tono.{name}"
wrapper.__module__ = "tono"
wrapper.__doc__ = docstring
wrapper.__signature__ = signature # type: ignore[attr-defined]
wrapper.__wrapped_node__ = name # type: ignore[attr-defined]
return wrapper
def __getattr__(name: str) -> Any:
# Skip dunder lookups so imports, pickling, and debuggers don't trigger
# registry loading or spurious AttributeError chains.
if name.startswith("__") and name.endswith("__"):
raise AttributeError(f"module 'tono' has no attribute {name!r}")
from backend.node_registry import NODE_CLASS_MAPPINGS
_ensure_registry()
if name not in NODE_CLASS_MAPPINGS:
raise AttributeError(f"module 'tono' has no attribute {name!r}")
cached = _NODE_WRAPPER_CACHE.get(name)
if cached is None:
cached = _build_node_wrapper(name, NODE_CLASS_MAPPINGS[name])
_NODE_WRAPPER_CACHE[name] = cached
return cached
def __dir__() -> list[str]:
from backend.node_registry import NODE_CLASS_MAPPINGS
_ensure_registry()
return sorted({*globals().keys(), *NODE_CLASS_MAPPINGS.keys()})