Browse Source

Initial garden layer package and tests

Lukas Goldschmidt 1 month ago
parent
commit
30d8d9bf04
9 changed files with 211 additions and 2 deletions
  1. 1 0
      .gitignore
  2. 22 1
      README.md
  3. 3 0
      pyproject.toml
  4. 19 0
      src/garden_layer/__init__.py
  5. 15 0
      src/garden_layer/config.py
  6. 100 0
      src/garden_layer/helpers.py
  7. 3 0
      src/garden_layer/plugin.py
  8. 1 0
      test.sh
  9. 47 1
      test_garden_layer.py

+ 1 - 0
.gitignore

@@ -2,3 +2,4 @@ __pycache__/
 .pytest_cache/
 .pytest_cache/
 .venv/
 .venv/
 *.pyc
 *.pyc
+*.egg-info/

+ 22 - 1
README.md

@@ -42,4 +42,25 @@ garden.add_seedling(
 
 
 ## Testing
 ## Testing
 
 
-`garden_layer/test_garden_layer.py` exercises the garden helpers (`traverse`, `describe_subject`, `path_traverse`, `property_usage_statistics`, and `batch_insert`) against a running MCP server. Start the MCP service (`./virtuoso_mcp/run.sh` or `./restart.sh`) before running the tests and invoke them with `python3 -m pytest garden_layer/test_garden_layer.py`. If `pytest` is not installed, install it inside a virtual environment (e.g., `python3 -m venv .env && .env/bin/pip install pytest`).
+`garden_layer/test_garden_layer.py` exercises the garden helpers (`traverse`, `describe_subject`, `path_traverse`, `property_usage_statistics`, and `batch_insert`) against a running MCP server. The suite now also posts directly to the `garden_*` MCP tools (e.g., `garden_describe_subject` and `garden_property_usage_statistics`) to confirm the server endpoint is registered and returns expected bindings. Start the MCP service (`./virtuoso_mcp/run.sh` or `./restart.sh`) before running the tests and invoke them with `python3 -m pytest garden_layer/test_garden_layer.py`. If `pytest` is not installed, install it inside a virtual environment (e.g., `python3 -m venv .env && .env/bin/pip install pytest`).
+
+## Installation & usage
+
+1. Publish the `garden_layer` repo to your private git server (e.g., `https://repo.home.world.eu.org/lucky/garden_layer.git`).
+2. Install it inside the MCP host environment:
+   ```bash
+   pip install --upgrade git+https://repo.home.world.eu.org/lucky/garden_layer.git
+   ```
+   The package can be uninstalled later with `pip uninstall garden-layer`.
+3. Restart the MCP server so it picks up the new helpers (our `restart.sh` already calls `killserver.sh` before `run.sh`):
+   ```bash
+   cd /home/lucky/.openclaw/workspace/virtuoso_mcp
+   ./restart.sh
+   ```
+4. Re-run the garden-layer suite with `GARDEN_LAYER_VENV` pointing at whatever virtualenv you want to use (our example venv lives at `garden_layer/.venv`):
+   ```bash
+   cd /home/lucky/.openclaw/workspace/garden_layer
+   GARDEN_LAYER_VENV="$PWD/.venv" ./test.sh
+   ```
+
+Once the package is installed you can import `garden_layer.GardenLayer` from your own MCP host code (or use it in agents) to orchestrate the domain helpers; building additional `/mcp` endpoints that simply call `GardenLayer` keeps your plugins reusable and the core MCP server generic.

+ 3 - 0
pyproject.toml

@@ -15,6 +15,9 @@ test = [
   "pytest>=8.4",
   "pytest>=8.4",
 ]
 ]
 
 
+[tool.setuptools.packages.find]
+where = ["src"]
+
 [build-system]
 [build-system]
 requires = ["setuptools>=61.0", "wheel"]
 requires = ["setuptools>=61.0", "wheel"]
 build-backend = "setuptools.build_meta"
 build-backend = "setuptools.build_meta"

+ 19 - 0
src/garden_layer/__init__.py

@@ -0,0 +1,19 @@
+from typing import Callable, Dict
+
+from .helpers import GardenLayer
+
+__all__ = ["GardenLayer", "register_layer"]
+
+
+def _make_tool(func: Callable[..., object]) -> Callable[[Dict[str, object]], object]:
+    return lambda input_data: func(**input_data)
+
+
+def register_layer(tools: Dict[str, Callable[[Dict[str, object]], object]]) -> None:
+    garden = GardenLayer()
+    tools["garden_add_seedling"] = _make_tool(garden.add_seedling)
+    tools["garden_describe_subject"] = _make_tool(garden.describe_subject)
+    tools["garden_path_traverse"] = _make_tool(garden.path_traverse)
+    tools["garden_property_usage_statistics"] = _make_tool(garden.property_usage_statistics)
+    tools["garden_batch_insert"] = _make_tool(garden.batch_insert)
+    tools["garden_cycle_plants"] = _make_tool(garden.list_cycle_plants)

+ 15 - 0
src/garden_layer/config.py

@@ -0,0 +1,15 @@
+from pathlib import Path
+import os
+
+from dotenv import load_dotenv
+
+BASE_DIR = Path(__file__).resolve().parent
+load_dotenv(BASE_DIR.parent / ".env")
+
+MCP_URL = os.getenv("GARDEN_MCP_URL", "http://127.0.0.1:8501/mcp")
+GRAPH = os.getenv("GARDEN_GRAPH", "http://world.eu.org/example1")
+CLONE_OF = "http://world.eu.org/cannabis-breeding#cloneOf"
+IN_CYCLE = "http://world.eu.org/cannabis-breeding#inCycle"
+SEED_PRODUCT = "http://world.eu.org/example1#SeedProduct_7b71b88b-17f"
+CYCLE_2026_3 = "http://world.eu.org/example1#ProductionCycle_21d96b6c-179"
+KEROSENE_ROOT = "http://world.eu.org/example1#Plant_90d53925-bb5"

+ 100 - 0
src/garden_layer/helpers.py

@@ -0,0 +1,100 @@
+from typing import Any, Dict, List, Optional
+
+import requests
+
+from .config import GRAPH, MCP_URL
+
+
+class GardenLayer:
+    """Domain helpers that orchestrate MCP calls for your garden project."""
+
+    def __init__(self, mcp_url: str = MCP_URL):
+        self.mcp_url = mcp_url
+
+    def _call(self, tool: str, payload: Dict[str, Any]) -> Any:
+        response = requests.post(self.mcp_url, json={"tool": tool, "input": payload}, timeout=10)
+        response.raise_for_status()
+        body = response.json()
+        if body.get("status") != "ok":
+            raise RuntimeError(body.get("detail", "MCP tool failed"))
+        return body["result"]
+
+    def traverse(self, subject_uri: str, property_uri: str, direction: str = "outgoing", limit: int = 20) -> Any:
+        return self._call("traverse_property", {
+            "subject_uri": subject_uri,
+            "property_uri": property_uri,
+            "direction": direction,
+            "limit": limit,
+        })
+
+    def describe_subject(self, subject_uri: str, limit: int = 10) -> Any:
+        return self._call("describe_subject", {
+            "subject_uri": subject_uri,
+            "limit": limit,
+        })
+
+    def path_traverse(
+        self,
+        subject_uri: str,
+        property_path: List[str],
+        direction: str = "outgoing",
+        limit: int = 10,
+    ) -> Any:
+        return self._call("path_traverse", {
+            "subject_uri": subject_uri,
+            "property_path": property_path,
+            "direction": direction,
+            "limit": limit,
+        })
+
+    def property_usage_statistics(self, property_uri: str, examples_limit: int = 5) -> Any:
+        return self._call("property_usage_statistics", {
+            "property_uri": property_uri,
+            "examples_limit": examples_limit,
+        })
+
+    def batch_insert(self, ttl: str, graph: Optional[str] = None) -> Any:
+        payload = {"ttl": ttl}
+        if graph:
+            payload["graph"] = graph
+        return self._call("batch_insert", payload)
+
+    def reassign_cycle(self, plant_uri: str, new_cycle_uri: str, old_cycle_uri: Optional[str] = None) -> Any:
+        payload = {
+            "subject": plant_uri,
+            "new_cycle": new_cycle_uri,
+        }
+        if old_cycle_uri:
+            payload["old_cycle"] = old_cycle_uri
+        return self._call("reassign_cycle", payload)
+
+    def list_cycle_plants(self, cycle_uri: str, limit: int = 50) -> Any:
+        return self._call("cycle_plants", {
+            "cycle_uri": cycle_uri,
+            "limit": limit,
+        })
+
+    def add_seedling(
+        self,
+        plant_uri: str,
+        seed_product_uri: str,
+        cycle_uri: str,
+        label: str,
+        strain_uri: str = "http://world.eu.org/example1#Strain_lucky_experimental_line",
+        graph: str = GRAPH,
+    ) -> None:
+        triples = [
+            ("http://www.w3.org/1999/02/22-rdf-syntax-ns#type", "http://world.eu.org/cannabis-breeding#IndividualPlant", "uri"),
+            ("http://www.w3.org/2000/01/rdf-schema#label", label, "literal"),
+            ("http://world.eu.org/cannabis-breeding#inCycle", cycle_uri, "uri"),
+            ("http://world.eu.org/cannabis-breeding#grownFromSeedProduct", seed_product_uri, "uri"),
+            ("http://world.eu.org/cannabis-breeding#belongsToStrain", strain_uri, "uri"),
+        ]
+        for predicate, obj, obj_type in triples:
+            self._call("insert_triple", {
+                "subject": plant_uri,
+                "predicate": predicate,
+                "object": obj,
+                "object_type": obj_type,
+                "graph": graph,
+            })

+ 3 - 0
src/garden_layer/plugin.py

@@ -0,0 +1,3 @@
+from . import register_layer
+
+__all__ = ["register_layer"]

+ 1 - 0
test.sh

@@ -10,4 +10,5 @@ if [[ -n "${GARDEN_LAYER_VENV:-}" ]]; then
   fi
   fi
 fi
 fi
 
 
+export PYTHONPATH="$PWD/src${PYTHONPATH:+:$PYTHONPATH}"
 python3 -m pytest -v -s test_garden_layer.py
 python3 -m pytest -v -s test_garden_layer.py

+ 47 - 1
test_garden_layer.py

@@ -1,8 +1,11 @@
 import pytest
 import pytest
 import requests
 import requests
+from typing import Any, Dict
 
 
 from garden_layer import GardenLayer
 from garden_layer import GardenLayer
-from garden_layer.config import CLONE_OF, GRAPH, KEROSENE_ROOT
+from garden_layer.config import CLONE_OF, GRAPH, KEROSENE_ROOT, MCP_URL
+
+CYCLE_2026_3 = "http://world.eu.org/example1#ProductionCycle_21d96b6c-179"
 
 
 
 
 def pytest_report_header(config):
 def pytest_report_header(config):
@@ -77,3 +80,46 @@ def test_batch_insert(garden_layer):
     assert "INSERT DATA" in result.get("query", "").upper()
     assert "INSERT DATA" in result.get("query", "").upper()
     print("batch_insert generated:")
     print("batch_insert generated:")
     print(result.get("query", ""))
     print(result.get("query", ""))
+
+
+def call_mcp_tool(tool_name: str, payload: Dict[str, Any]) -> Any:
+    response = requests.post(MCP_URL, json={"tool": tool_name, "input": payload}, timeout=10)
+    response.raise_for_status()
+    body = response.json()
+    assert body.get("status") == "ok", f"{tool_name} failed: {body.get('detail') or 'unknown reason'}"
+    return body["result"]
+
+
+def test_mcp_garden_describe_subject():
+    result = call_mcp_tool(
+        "garden_describe_subject",
+        {"subject_uri": KEROSENE_ROOT, "limit": 5},
+    )
+    bindings = result.get("results", {}).get("bindings", [])
+    assert bindings, "garden_describe_subject should return bindings"
+    print("garden_describe_subject returned", len(bindings), "bindings via /mcp")
+
+
+def test_mcp_garden_property_usage_statistics():
+    result = call_mcp_tool(
+        "garden_property_usage_statistics",
+        {"property_uri": CLONE_OF, "examples_limit": 2},
+    )
+    count_bindings = result.get("count", {}).get("results", {}).get("bindings", [])
+    assert count_bindings, "Expected usage count bindings from the garden tool"
+    examples = result.get("examples", {}).get("results", {}).get("bindings", [])
+    assert isinstance(examples, list)
+    print("garden_property_usage_statistics sample bindings:", len(examples))
+
+
+def test_mcp_garden_cycle_plants():
+    result = call_mcp_tool(
+        "garden_cycle_plants",
+        {"cycle_uri": CYCLE_2026_3, "limit": 5},
+    )
+    bindings = result.get("results", {}).get("bindings", [])
+    assert bindings, "Expected at least one plant binding from the cycle"
+    print("garden_cycle_plants returned", len(bindings), "plants for cycle", CYCLE_2026_3)
+    for binding in bindings:
+        plant_uri = binding.get("plant", {}).get("value")
+        assert plant_uri, "Each binding should include a plant URI"