feat: add python source loader
This commit is contained in:
@@ -8,7 +8,7 @@ from wf_core import ReducerSpec
|
|||||||
from wf_core.runtime.ops.merges import ReducerDefinition
|
from wf_core.runtime.ops.merges import ReducerDefinition
|
||||||
from wf_platform.refs import CapabilityRef
|
from wf_platform.refs import CapabilityRef
|
||||||
|
|
||||||
SourceKind = Literal["system", "connection"]
|
SourceKind = Literal["system", "connection", "python"]
|
||||||
JsonObject = dict[str, Any]
|
JsonObject = dict[str, Any]
|
||||||
SOURCE_PREVIEW_LIMIT = 3
|
SOURCE_PREVIEW_LIMIT = 3
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .loader import PythonSourceConfigLike, load_python_source, python_capability_source
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PythonSourceConfigLike",
|
||||||
|
"load_python_source",
|
||||||
|
"python_capability_source",
|
||||||
|
]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from wf_authoring import NodeSpec, node
|
||||||
|
from wf_platform import (
|
||||||
|
CapabilityBuckets,
|
||||||
|
CapabilitySource,
|
||||||
|
SourcePermissions,
|
||||||
|
SourceVisibility,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PythonSourceConfigLike(Protocol):
|
||||||
|
id: str
|
||||||
|
module: str
|
||||||
|
registry: str
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
def load_python_source(
|
||||||
|
*,
|
||||||
|
source_id: str,
|
||||||
|
module: str,
|
||||||
|
registry: str = "registry",
|
||||||
|
enabled: bool = True,
|
||||||
|
) -> CapabilitySource:
|
||||||
|
"""Load a trusted in-process Python source from a module registry object."""
|
||||||
|
module_obj = import_module(module)
|
||||||
|
if not hasattr(module_obj, registry):
|
||||||
|
raise ValueError(f"missing registry object {registry!r} in module {module!r}")
|
||||||
|
raw_registry = getattr(module_obj, registry)
|
||||||
|
if callable(raw_registry) and not isinstance(raw_registry, NodeSpec):
|
||||||
|
raw_registry = raw_registry()
|
||||||
|
specs = _coerce_specs(raw_registry)
|
||||||
|
qualified = [_qualify_spec(source_id, spec) for spec in specs]
|
||||||
|
names = [spec.name for spec in qualified]
|
||||||
|
if len(names) != len(set(names)):
|
||||||
|
raise ValueError(f"duplicate NodeSpec names in Python source {source_id!r}")
|
||||||
|
return CapabilitySource(
|
||||||
|
id=source_id,
|
||||||
|
kind="python",
|
||||||
|
enabled=enabled,
|
||||||
|
capabilities=CapabilityBuckets(
|
||||||
|
node_specs={spec.name: spec for spec in qualified},
|
||||||
|
),
|
||||||
|
visibility=SourceVisibility(
|
||||||
|
planner=True,
|
||||||
|
mcp_client=True,
|
||||||
|
admin_dashboard=True,
|
||||||
|
),
|
||||||
|
permissions=SourcePermissions(safe_for_workflow=True),
|
||||||
|
description=f"Python source loaded from {module}:{registry}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def python_capability_source(config: PythonSourceConfigLike) -> CapabilitySource:
|
||||||
|
return load_python_source(
|
||||||
|
source_id=config.id,
|
||||||
|
module=config.module,
|
||||||
|
registry=config.registry,
|
||||||
|
enabled=config.enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_specs(raw_registry: object) -> list[NodeSpec[Any, Any]]:
|
||||||
|
if isinstance(raw_registry, Mapping):
|
||||||
|
values = list(raw_registry.values())
|
||||||
|
elif isinstance(raw_registry, Sequence) and not isinstance(raw_registry, str):
|
||||||
|
values = list(raw_registry)
|
||||||
|
else:
|
||||||
|
values = [raw_registry]
|
||||||
|
|
||||||
|
specs: list[NodeSpec[Any, Any]] = []
|
||||||
|
for value in values:
|
||||||
|
if not isinstance(value, NodeSpec):
|
||||||
|
raise TypeError(f"expected NodeSpec in Python source registry, got {type(value).__name__}")
|
||||||
|
specs.append(value)
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
def _qualify_spec(source_id: str, spec: NodeSpec[Any, Any]) -> NodeSpec[Any, Any]:
|
||||||
|
local_name = spec.name.removeprefix("authoring.")
|
||||||
|
if local_name.startswith(f"{source_id}."):
|
||||||
|
return spec
|
||||||
|
return node(spec, name=f"{source_id}.{local_name}")
|
||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from wf_authoring import node
|
||||||
|
|
||||||
|
|
||||||
|
class EchoInput(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class EchoOutput(BaseModel):
|
||||||
|
echoed: str
|
||||||
|
|
||||||
|
|
||||||
|
@node(name="echo")
|
||||||
|
def echo(payload: EchoInput) -> EchoOutput:
|
||||||
|
return EchoOutput(echoed=payload.text)
|
||||||
|
|
||||||
|
|
||||||
|
@node(name="authoring.upper")
|
||||||
|
def upper(payload: EchoInput) -> EchoOutput:
|
||||||
|
return EchoOutput(echoed=payload.text.upper())
|
||||||
|
|
||||||
|
|
||||||
|
registry = [echo, upper]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wf_sources_python import load_python_source
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_python_source_from_sequence_registry() -> None:
|
||||||
|
source = load_python_source(
|
||||||
|
source_id="local.ops",
|
||||||
|
module="tests.fixtures.python_source_ops",
|
||||||
|
registry="registry",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert source.id == "local.ops"
|
||||||
|
assert source.kind == "python"
|
||||||
|
assert set(source.capabilities.node_specs) == {
|
||||||
|
"local.ops.echo",
|
||||||
|
"local.ops.upper",
|
||||||
|
}
|
||||||
|
assert source.permissions.safe_for_workflow is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_python_source_rejects_missing_registry() -> None:
|
||||||
|
with pytest.raises(ValueError, match="missing registry object"):
|
||||||
|
load_python_source(
|
||||||
|
source_id="local.ops",
|
||||||
|
module="tests.fixtures.python_source_ops",
|
||||||
|
registry="missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_python_source_rejects_non_node_spec() -> None:
|
||||||
|
with pytest.raises(TypeError, match="expected NodeSpec"):
|
||||||
|
load_python_source(
|
||||||
|
source_id="local.ops",
|
||||||
|
module="math",
|
||||||
|
registry="pi",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user