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_platform.refs import CapabilityRef
|
||||
|
||||
SourceKind = Literal["system", "connection"]
|
||||
SourceKind = Literal["system", "connection", "python"]
|
||||
JsonObject = dict[str, Any]
|
||||
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