rework ergonomics for standalone use
This commit is contained in:
190
tono.py
190
tono.py
@@ -12,7 +12,10 @@ Quick start::
|
||||
# Load SPM data
|
||||
fields = tono.load("scan.gwy")
|
||||
|
||||
# Process
|
||||
# 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
|
||||
@@ -22,15 +25,22 @@ Quick start::
|
||||
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,
|
||||
@@ -38,3 +48,181 @@ from backend.data_types import ( # noqa: F401
|
||||
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()})
|
||||
|
||||
Reference in New Issue
Block a user