fix: add import path support for python sources
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}."):
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user