fix: add import path support for python sources

This commit is contained in:
lda
2026-06-12 09:07:05 +07:00 Verified
parent 4bd321e905
commit a0bc27b051
9 changed files with 135 additions and 2 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ McpSourceConnection
For Python, the provider is simpler: For Python, the provider is simpler:
```text ```text
PythonSourceConfig(module, registry) PythonSourceConfig(path, module, registry)
-> import module -> import module
-> load NodeSpec registry -> load NodeSpec registry
-> qualify specs under source id -> qualify specs under source id
@@ -56,6 +56,7 @@ Start with static config only:
{ {
"kind": "python", "kind": "python",
"id": "local.ops", "id": "local.ops",
"path": ".",
"module": "my_project.workflow_ops", "module": "my_project.workflow_ops",
"registry": "registry" "registry": "registry"
} }
@@ -119,7 +120,7 @@ Implemented:
- `wf_config` accepts `server.sources[]` entries with `kind: "python"`. - `wf_config` accepts `server.sources[]` entries with `kind: "python"`.
- `wf_sources_python` loads trusted in-process `NodeSpec` registries from - `wf_sources_python` loads trusted in-process `NodeSpec` registries from
`module:registry`. `path` plus `module:registry`.
- `wf_server.config` composes Python sources into local/static servers. - `wf_server.config` composes Python sources into local/static servers.
- Capability listing/calling works over JSON-RPC. - Capability listing/calling works over JSON-RPC.
+28
View File
@@ -0,0 +1,28 @@
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 the submitted text through a project-local Python source."""
return EchoOutput(echoed=payload.text)
@node(name="authoring.upper")
def upper(payload: EchoInput) -> EchoOutput:
"""Upper-case text while exercising authoring-name qualification."""
return EchoOutput(echoed=payload.text.upper())
registry = [echo, upper]
+22
View File
@@ -5,7 +5,9 @@ from pathlib import Path
from .models import ( from .models import (
FilesystemStoreConfig, FilesystemStoreConfig,
PythonSourceConfig,
ServerStoresConfig, ServerStoresConfig,
SourceConfig,
StoreConfig, StoreConfig,
WorkflowConfigFile, WorkflowConfigFile,
) )
@@ -49,6 +51,10 @@ def _resolve_store_paths(
update={ update={
"store": _resolve_store(server.store, base_dir=base_dir), "store": _resolve_store(server.store, base_dir=base_dir),
"stores": resolved_stores, "stores": resolved_stores,
"sources": _resolve_source_paths(
server.sources,
base_dir=base_dir,
),
} }
) )
} }
@@ -63,3 +69,19 @@ def _resolve_store(
if isinstance(store, FilesystemStoreConfig) and not store.root.is_absolute(): if isinstance(store, FilesystemStoreConfig) and not store.root.is_absolute():
return store.model_copy(update={"root": (base_dir / store.root).resolve()}) return store.model_copy(update={"root": (base_dir / store.root).resolve()})
return store return store
def _resolve_source_paths(
sources: list[SourceConfig],
*,
base_dir: Path,
) -> list[SourceConfig]:
resolved: list[SourceConfig] = []
for source in sources:
if isinstance(source, PythonSourceConfig) and not source.path.is_absolute():
resolved.append(
source.model_copy(update={"path": (base_dir / source.path).resolve()})
)
else:
resolved.append(source)
return resolved
+1
View File
@@ -104,6 +104,7 @@ class PythonSourceConfig(WorkflowConfigModel):
kind: Literal["python"] = "python" kind: Literal["python"] = "python"
id: str id: str
enabled: bool = True enabled: bool = True
path: Path = Path(".")
module: str = Field(min_length=1) module: str = Field(min_length=1)
registry: str = Field(default="registry", min_length=1) registry: str = Field(default="registry", min_length=1)
+1
View File
@@ -20,6 +20,7 @@ def _python_sources(config: WorkflowConfigFile) -> dict[str, CapabilitySource]:
return { return {
source.id: load_python_source( source.id: load_python_source(
source_id=source.id, source_id=source.id,
path=source.path,
module=source.module, module=source.module,
registry=source.registry, registry=source.registry,
enabled=source.enabled, enabled=source.enabled,
+14
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import sys
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from importlib import import_module from importlib import import_module
from pathlib import Path
from typing import Any, Protocol from typing import Any, Protocol
from wf_authoring import NodeSpec, node from wf_authoring import NodeSpec, node
@@ -15,6 +17,7 @@ from wf_platform import (
class PythonSourceConfigLike(Protocol): class PythonSourceConfigLike(Protocol):
id: str id: str
path: Path
module: str module: str
registry: str registry: str
enabled: bool enabled: bool
@@ -26,8 +29,11 @@ def load_python_source(
module: str, module: str,
registry: str = "registry", registry: str = "registry",
enabled: bool = True, enabled: bool = True,
path: str | Path | None = None,
) -> CapabilitySource: ) -> CapabilitySource:
"""Load a trusted in-process Python source from a module registry object.""" """Load a trusted in-process Python source from a module registry object."""
if path is not None:
_ensure_import_path(Path(path))
module_obj = import_module(module) module_obj = import_module(module)
if not hasattr(module_obj, registry): if not hasattr(module_obj, registry):
raise ValueError(f"missing registry object {registry!r} in module {module!r}") raise ValueError(f"missing registry object {registry!r} in module {module!r}")
@@ -59,6 +65,7 @@ def load_python_source(
def python_capability_source(config: PythonSourceConfigLike) -> CapabilitySource: def python_capability_source(config: PythonSourceConfigLike) -> CapabilitySource:
return load_python_source( return load_python_source(
source_id=config.id, source_id=config.id,
path=config.path,
module=config.module, module=config.module,
registry=config.registry, registry=config.registry,
enabled=config.enabled, enabled=config.enabled,
@@ -83,6 +90,13 @@ def _coerce_specs(raw_registry: object) -> list[NodeSpec[Any, Any]]:
return specs return specs
def _ensure_import_path(path: Path) -> None:
"""Keep configured trusted source roots importable for deferred imports."""
resolved = str(path.resolve())
if resolved not in sys.path:
sys.path.insert(0, resolved)
def _qualify_spec(source_id: str, spec: NodeSpec[Any, Any]) -> NodeSpec[Any, Any]: def _qualify_spec(source_id: str, spec: NodeSpec[Any, Any]) -> NodeSpec[Any, Any]:
local_name = spec.name.removeprefix("authoring.") local_name = spec.name.removeprefix("authoring.")
if local_name.startswith(f"{source_id}."): if local_name.startswith(f"{source_id}."):
+29
View File
@@ -406,10 +406,39 @@ def test_workflow_config_parses_python_source() -> None:
source = config.server.sources[0] source = config.server.sources[0]
assert source.kind == "python" assert source.kind == "python"
assert source.id == "local.ops" assert source.id == "local.ops"
assert source.path == Path(".")
assert source.module == "tests.fixtures.python_source_ops" assert source.module == "tests.fixtures.python_source_ops"
assert source.registry == "registry" assert source.registry == "registry"
def test_load_workflow_config_resolves_python_source_path(tmp_path: Path) -> None:
config_path = tmp_path / "wf.json"
config_path.write_text(
json.dumps(
{
"version": 1,
"server": {
"sources": [
{
"kind": "python",
"id": "local.ops",
"path": "src",
"module": "project_ops",
}
]
},
}
),
encoding="utf-8",
)
config = load_workflow_config(config_path)
source = config.server.sources[0]
assert source.kind == "python"
assert source.path == (tmp_path / "src").resolve()
def test_server_config_resolves_missing_role_stores_to_default_store() -> None: def test_server_config_resolves_missing_role_stores_to_default_store() -> None:
config = WorkflowConfigFile.model_validate( config = WorkflowConfigFile.model_validate(
{ {
+37
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import pytest import pytest
from wf_sources_python import load_python_source from wf_sources_python import load_python_source
@@ -34,6 +36,41 @@ def test_load_python_source_from_callable_registry() -> None:
} }
def test_load_python_source_uses_configured_import_path(tmp_path: Path) -> None:
source_root = tmp_path / "source_root"
source_root.mkdir()
(source_root / "project_ops.py").write_text(
"""
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)
registry = [echo]
""",
encoding="utf-8",
)
source = load_python_source(
source_id="local.ops",
path=source_root,
module="project_ops",
registry="registry",
)
assert set(source.capabilities.node_specs) == {"local.ops.echo"}
def test_load_python_source_propagates_enabled_flag() -> None: def test_load_python_source_propagates_enabled_flag() -> None:
source = load_python_source( source = load_python_source(
source_id="local.ops", source_id="local.ops",