Prove adapter launcher modules are runnable
This commit is contained in:
parent
3bf3836ac3
commit
ecbf94d14a
3 changed files with 293 additions and 10 deletions
|
|
@ -1,16 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
import unittest
|
||||
import venv
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from referencing import Registry, Resource
|
||||
|
||||
from docforge.adapter_contract import AdapterNode, AdapterProject, AdapterProjection
|
||||
|
|
@ -23,6 +29,10 @@ from docforge.client_config import (
|
|||
from docforge.errors import DocForgeError
|
||||
from docforge.models import Node
|
||||
from docforge.project import Project
|
||||
from docforge.reference_mcp import (
|
||||
REFERENCE_MCP_MODULE,
|
||||
create_reference_project,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
|
|
@ -83,6 +93,26 @@ class AdapterLauncherTests(unittest.TestCase):
|
|||
shutil.copytree(FIXTURES / "alpha", root)
|
||||
return root
|
||||
|
||||
def copy_reference_fixture(self, destination: Path) -> Path:
|
||||
root = destination / "reference-python"
|
||||
shutil.copytree(FIXTURES / "reference-python", root)
|
||||
config = root / ".docforge" / "reference-adapter.toml"
|
||||
config.parent.mkdir(parents=True)
|
||||
config.write_text(
|
||||
"\n".join(
|
||||
(
|
||||
"schema_version = 1",
|
||||
'project_id = "reference-python"',
|
||||
'title = "Runnable Python reference"',
|
||||
'language = "python"',
|
||||
'source_roots = ["src"]',
|
||||
"",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root.resolve()
|
||||
|
||||
def projection(
|
||||
self,
|
||||
root: Path,
|
||||
|
|
@ -135,7 +165,7 @@ class AdapterLauncherTests(unittest.TestCase):
|
|||
project, _ = self.project(root)
|
||||
launcher = AdapterLauncherV1.for_project(
|
||||
project,
|
||||
module="fixture_adapter.mcp_server",
|
||||
module=REFERENCE_MCP_MODULE,
|
||||
)
|
||||
Draft202012Validator(LAUNCHER_SCHEMA).validate(launcher.as_dict())
|
||||
previous = os.environ.get("DOCFORGE_ADAPTER_LAUNCHER_SECRET")
|
||||
|
|
@ -167,7 +197,7 @@ class AdapterLauncherTests(unittest.TestCase):
|
|||
[
|
||||
"-I",
|
||||
"-m",
|
||||
"fixture_adapter.mcp_server",
|
||||
REFERENCE_MCP_MODULE,
|
||||
"--project-root",
|
||||
str(root),
|
||||
"--capability-mode",
|
||||
|
|
@ -209,10 +239,11 @@ class AdapterLauncherTests(unittest.TestCase):
|
|||
project, _ = self.project(root)
|
||||
launcher = AdapterLauncherV1.for_project(
|
||||
project,
|
||||
module="fixture_adapter.mcp_server",
|
||||
module=REFERENCE_MCP_MODULE,
|
||||
)
|
||||
for module in (
|
||||
"-m",
|
||||
"fixture_adapter.mcp_server",
|
||||
"fixture_adapter.mcp_server --debug",
|
||||
"fixture_adapter:mcp_server",
|
||||
"fixture_adapter/mcp_server",
|
||||
|
|
@ -254,7 +285,7 @@ class AdapterLauncherTests(unittest.TestCase):
|
|||
second, _ = self.project(second_root)
|
||||
launcher = AdapterLauncherV1.for_project(
|
||||
first,
|
||||
module="fixture_adapter.mcp_server",
|
||||
module=REFERENCE_MCP_MODULE,
|
||||
)
|
||||
|
||||
with self.assertRaises(DocForgeError) as wrong_project:
|
||||
|
|
@ -276,7 +307,7 @@ class AdapterLauncherTests(unittest.TestCase):
|
|||
)
|
||||
drifting_launcher = AdapterLauncherV1.for_project(
|
||||
drifting,
|
||||
module="fixture_adapter.mcp_server",
|
||||
module=REFERENCE_MCP_MODULE,
|
||||
)
|
||||
with self.assertRaises(DocForgeError) as source_drift:
|
||||
generate_adapter_client_configuration(
|
||||
|
|
@ -297,7 +328,7 @@ class AdapterLauncherTests(unittest.TestCase):
|
|||
with self.assertRaises(DocForgeError) as generic_launcher:
|
||||
AdapterLauncherV1.for_project(
|
||||
generic,
|
||||
module="fixture_adapter.mcp_server",
|
||||
module=REFERENCE_MCP_MODULE,
|
||||
)
|
||||
self.assertEqual("adapter_launcher_unavailable", generic_launcher.exception.code)
|
||||
|
||||
|
|
@ -308,6 +339,124 @@ class AdapterLauncherTests(unittest.TestCase):
|
|||
generate_client_configuration(custom, "codex")
|
||||
self.assertEqual("missing_config", generic_api.exception.code)
|
||||
|
||||
def test_installed_project_owned_top_level_module_launches_without_probe_execution(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
environment = root / ".launcher-venv"
|
||||
venv.EnvBuilder(with_pip=False).create(environment)
|
||||
executable = environment / "bin" / "python"
|
||||
site_packages = Path(
|
||||
subprocess.run(
|
||||
[
|
||||
str(executable),
|
||||
"-I",
|
||||
"-c",
|
||||
"import site; print(site.getsitepackages()[0])",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
)
|
||||
(site_packages / "fixture-adapter.pth").write_text(
|
||||
f"{root}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
sentinel = root / "fixture-adapter-executed"
|
||||
(root / "fixture_adapter.py").write_text(
|
||||
"\n".join(
|
||||
(
|
||||
"import argparse",
|
||||
"import json",
|
||||
"from pathlib import Path",
|
||||
"parser = argparse.ArgumentParser()",
|
||||
'parser.add_argument("--project-root", required=True)',
|
||||
(
|
||||
'parser.add_argument("--capability-mode", '
|
||||
'choices=("read",), required=True)'
|
||||
),
|
||||
"arguments = parser.parse_args()",
|
||||
f"Path({str(sentinel)!r}).write_text('executed', encoding='utf-8')",
|
||||
(
|
||||
"print(json.dumps({'project_root': arguments.project_root}, "
|
||||
"sort_keys=True))"
|
||||
),
|
||||
"",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
project, _ = self.project(root)
|
||||
with mock.patch.object(sys, "executable", str(executable)):
|
||||
launcher = AdapterLauncherV1.for_project(
|
||||
project,
|
||||
module="fixture_adapter",
|
||||
)
|
||||
result = generate_adapter_client_configuration(
|
||||
project,
|
||||
launcher,
|
||||
"codex",
|
||||
)
|
||||
self.assertFalse(sentinel.exists())
|
||||
Draft202012Validator(LAUNCHER_SCHEMA).validate(launcher.as_dict())
|
||||
binding = result["binding"]
|
||||
completed = subprocess.run(
|
||||
[binding["command"], *binding["args"]],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
self.assertEqual({"project_root": str(root)}, json.loads(completed.stdout))
|
||||
self.assertEqual("executed", sentinel.read_text(encoding="utf-8"))
|
||||
|
||||
def test_uninstalled_packages_and_non_project_modules_fail_closed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
project, _ = self.project(root)
|
||||
for module, code in (
|
||||
("fixture_adapter", "adapter_launcher_unavailable"),
|
||||
("os", "adapter_launcher_unavailable"),
|
||||
("json", "invalid_adapter_launcher"),
|
||||
):
|
||||
with self.subTest(module=module), self.assertRaises(DocForgeError) as captured:
|
||||
AdapterLauncherV1.for_project(project, module=module)
|
||||
self.assertEqual(code, captured.exception.code)
|
||||
|
||||
def test_generated_reference_binding_starts_a_real_isolated_mcp_process(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_reference_fixture(Path(directory))
|
||||
project = create_reference_project(root)
|
||||
launcher = AdapterLauncherV1.for_project(
|
||||
project,
|
||||
module=REFERENCE_MCP_MODULE,
|
||||
)
|
||||
result = generate_adapter_client_configuration(
|
||||
project,
|
||||
launcher,
|
||||
"codex",
|
||||
)
|
||||
binding = result["binding"]
|
||||
|
||||
async def inspect() -> tuple[str, ...]:
|
||||
parameters = StdioServerParameters(
|
||||
command=binding["command"],
|
||||
args=binding["args"],
|
||||
env=binding["environment"],
|
||||
)
|
||||
async with (
|
||||
stdio_client(parameters) as streams,
|
||||
ClientSession(*streams) as session,
|
||||
):
|
||||
await session.initialize()
|
||||
return tuple(tool.name for tool in (await session.list_tools()).tools)
|
||||
|
||||
tools = asyncio.run(inspect())
|
||||
self.assertIn("docforge_bootstrap", tools)
|
||||
|
||||
|
||||
def project_source_availability(result: dict[str, object]):
|
||||
from docforge.adapter_launcher import AdapterSourceAvailabilityV1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue