229 lines
7.7 KiB
Python
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()})
|