hud-python 0.4.48__py3-none-any.whl → 0.4.50__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hud-python might be problematic. Click here for more details.

@@ -2,101 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from pathlib import Path # noqa: TC003
6
- from unittest.mock import MagicMock, patch
5
+ from unittest.mock import patch
7
6
 
8
7
  import pytest
9
8
 
10
9
  from hud.cli.dev import (
11
- create_proxy_server,
12
- get_docker_cmd,
13
- get_image_name,
14
10
  run_mcp_dev_server,
15
- update_pyproject_toml,
16
11
  )
17
12
 
18
13
 
19
- class TestCreateMCPServer:
20
- """Test MCP server creation."""
21
-
22
- def test_create_mcp_server(self) -> None:
23
- """Test that MCP server is created with correct configuration."""
24
- mcp = create_proxy_server(".", "test-image:latest")
25
- assert mcp._mcp_server.name == "HUD Dev Proxy - test-image:latest"
26
- # Proxy server doesn't define its own tools, it forwards to Docker containers
27
-
28
-
29
- class TestDockerUtils:
30
- """Test Docker utility functions."""
31
-
32
- def test_get_docker_cmd(self) -> None:
33
- """Test extracting CMD from Docker image."""
34
- with patch("subprocess.run") as mock_run:
35
- mock_result = MagicMock()
36
- mock_result.returncode = 0
37
- mock_result.stdout = '["python", "-m", "server"]'
38
- mock_run.return_value = mock_result
39
-
40
- cmd = get_docker_cmd("test-image:latest")
41
- assert cmd is None
42
-
43
- def test_get_docker_cmd_failure(self) -> None:
44
- """Test handling when Docker inspect fails."""
45
- import subprocess
46
-
47
- with patch("subprocess.run") as mock_run:
48
- # check=True causes CalledProcessError on non-zero return
49
- mock_run.side_effect = subprocess.CalledProcessError(1, "docker inspect")
50
-
51
- cmd = get_docker_cmd("test-image:latest")
52
- assert cmd is None
53
-
54
-
55
- class TestImageResolution:
56
- """Test image name resolution."""
57
-
58
- def test_get_image_name_override(self) -> None:
59
- """Test image name with override."""
60
- name, source = get_image_name(".", "custom-image:v1")
61
- assert name == "custom-image:v1"
62
- assert source == "override"
63
-
64
- def test_get_image_name_from_pyproject(self, tmp_path: Path) -> None:
65
- """Test image name from pyproject.toml."""
66
- pyproject = tmp_path / "pyproject.toml"
67
- pyproject.write_text("""
68
- [tool.hud]
69
- image = "my-project:latest"
70
- """)
71
-
72
- name, source = get_image_name(str(tmp_path))
73
- assert name == "my-project:latest"
74
- assert source == "cache"
75
-
76
- def test_get_image_name_auto_generate(self, tmp_path: Path) -> None:
77
- """Test auto-generated image name."""
78
- test_dir = tmp_path / "my_test_project"
79
- test_dir.mkdir()
80
-
81
- name, source = get_image_name(str(test_dir))
82
- assert name == "my-test-project:dev"
83
- assert source == "auto"
84
-
85
- def test_update_pyproject_toml(self, tmp_path: Path) -> None:
86
- """Test updating pyproject.toml with image name."""
87
- pyproject = tmp_path / "pyproject.toml"
88
- pyproject.write_text("""
89
- [project]
90
- name = "test"
91
- """)
92
-
93
- update_pyproject_toml(str(tmp_path), "new-image:v1", silent=True)
94
-
95
- content = pyproject.read_text()
96
- assert "[tool.hud]" in content
97
- assert 'image = "new-image:v1"' in content
98
-
99
-
100
14
  class TestRunMCPDevServer:
101
15
  """Test the main server runner."""
102
16
 
@@ -110,16 +24,13 @@ class TestRunMCPDevServer:
110
24
  pytest.raises(click.Abort),
111
25
  ):
112
26
  run_mcp_dev_server(
113
- directory=".",
114
- image="missing:latest",
115
- build=False,
116
- no_cache=False,
117
- transport="http",
27
+ module=".",
28
+ stdio=False,
118
29
  port=8765,
119
- no_reload=False,
120
30
  verbose=False,
121
31
  inspector=False,
122
- no_logs=False,
123
- docker_args=[],
124
32
  interactive=False,
33
+ watch=[],
34
+ docker=False,
35
+ docker_args=[],
125
36
  )
@@ -175,16 +175,16 @@ def ensure_built(env_dir: Path, *, interactive: bool = True) -> dict[str, Any]:
175
175
  _print_section("Added files", diffs.get("added", []))
176
176
  _print_section("Removed files", diffs.get("removed", []))
177
177
 
178
- if interactive:
179
- if hud_console.confirm("Rebuild now (runs 'hud build')?", default=True):
180
- require_docker_running()
181
- build_environment(str(env_dir), platform="linux/amd64")
182
- with open(lock_path) as f:
183
- lock_data = yaml.safe_load(f) or {}
184
- else:
185
- hud_console.hint("Continuing without rebuild; this may use an outdated image.")
178
+ # if interactive:
179
+ if hud_console.confirm("Rebuild now (runs 'hud build')?", default=True):
180
+ require_docker_running()
181
+ build_environment(str(env_dir), platform="linux/amd64")
182
+ with open(lock_path) as f:
183
+ lock_data = yaml.safe_load(f) or {}
186
184
  else:
187
- hud_console.hint("Run 'hud build' to update the image before proceeding.")
185
+ hud_console.hint("Continuing without rebuild; this may use an outdated image.")
186
+ # else:
187
+ # hud_console.hint("Run 'hud build' to update the image before proceeding.")
188
188
  elif not stored_hash:
189
189
  hud_console.dim_info(
190
190
  "Info",
@@ -41,7 +41,7 @@ EXCLUDE_FILES = {
41
41
  }
42
42
 
43
43
  INCLUDE_FILES = {"Dockerfile", "pyproject.toml"}
44
- INCLUDE_DIRS = {"controller", "environment"}
44
+ INCLUDE_DIRS = {"server", "mcp", "controller", "environment"}
45
45
 
46
46
 
47
47
  def iter_source_files(root: Path) -> Iterable[Path]:
hud/server/__init__.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from .router import MCPRouter
3
4
  from .server import MCPServer
4
5
 
5
- __all__ = ["MCPServer"]
6
+ __all__ = ["MCPRouter", "MCPServer"]
hud/server/router.py ADDED
@@ -0,0 +1,160 @@
1
+ """MCP Router utilities for FastAPI-like composition patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from hud.server import MCPServer
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+
13
+ from fastmcp import FastMCP
14
+ from fastmcp.tools import Tool
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # MCPRouter is just an alias to FastMCP for FastAPI-like patterns
19
+ MCPRouter = MCPServer
20
+
21
+ # Prefix for internal tool names
22
+ _INTERNAL_PREFIX = "int_"
23
+
24
+
25
+ class HiddenRouter(MCPRouter):
26
+ """Wraps a FastMCP router to provide a single dispatcher tool for its sub-tools.
27
+
28
+ Instead of exposing all tools at the top level, this creates a single tool
29
+ (named after the router) that dispatches to the router's tools internally.
30
+
31
+ Useful for setup/evaluate patterns where you want:
32
+ - A single 'setup' tool that can call setup_basic(), setup_advanced(), etc.
33
+ - A single 'evaluate' tool that can call evaluate_score(), evaluate_complete(), etc.
34
+
35
+ Example:
36
+ # Create a router with multiple setup functions
37
+ setup_router = MCPRouter(name="setup")
38
+
39
+ @setup_router.tool
40
+ async def reset():
41
+ return "Environment reset"
42
+
43
+ @setup_router.tool
44
+ async def seed_data():
45
+ return "Data seeded"
46
+
47
+ # Wrap in HiddenRouter
48
+ hidden_setup = HiddenRouter(setup_router)
49
+
50
+ # Now you have one 'setup' tool that dispatches to reset/seed_data
51
+ mcp.include_router(hidden_setup)
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ router: FastMCP,
57
+ *,
58
+ title: str | None = None,
59
+ description: str | None = None,
60
+ meta: dict[str, Any] | None = None,
61
+ ) -> None:
62
+ """Wrap an existing router with a dispatcher pattern.
63
+
64
+ Args:
65
+ router: The FastMCP router to wrap
66
+ title: Optional title for the dispatcher tool (defaults to "{name} Dispatcher")
67
+ description: Optional description for the dispatcher tool
68
+ meta: Optional metadata for the dispatcher tool
69
+ """
70
+ name = router.name or "router"
71
+
72
+ # Naming scheme for hidden/internal tools
73
+ self._prefix_fn: Callable[[str], str] = lambda n: f"{_INTERNAL_PREFIX}{n}"
74
+
75
+ super().__init__(name=name)
76
+
77
+ # Set up dispatcher tool
78
+ dispatcher_title = title or f"{name.title()} Dispatcher"
79
+ dispatcher_desc = description or f"Call internal '{name}' functions"
80
+
81
+ # Register dispatcher that routes to hidden tools
82
+ async def _dispatch(
83
+ name: str,
84
+ arguments: dict | str | None = None,
85
+ ctx: Any | None = None,
86
+ ) -> Any:
87
+ """Gateway to hidden tools.
88
+
89
+ Args:
90
+ name: Internal function name (without prefix)
91
+ arguments: Arguments to forward to the internal tool (dict or JSON string)
92
+ ctx: Request context injected by FastMCP
93
+ """
94
+ # Handle JSON string inputs
95
+ if isinstance(arguments, str):
96
+ import json
97
+
98
+ try:
99
+ arguments = json.loads(arguments)
100
+ except json.JSONDecodeError:
101
+ arguments = {}
102
+
103
+ # Call the internal tool
104
+ return await self._tool_manager.call_tool(self._prefix_fn(name), arguments or {}) # type: ignore
105
+
106
+ from fastmcp.tools.tool import FunctionTool
107
+
108
+ dispatcher_tool = FunctionTool.from_function(
109
+ _dispatch,
110
+ name=name,
111
+ title=dispatcher_title,
112
+ description=dispatcher_desc,
113
+ tags=set(),
114
+ meta=meta,
115
+ )
116
+ self._tool_manager.add_tool(dispatcher_tool)
117
+
118
+ # Copy all tools from source router as hidden tools
119
+ for tool in router._tool_manager._tools.values():
120
+ tool._key = self._prefix_fn(tool.name)
121
+ self._tool_manager.add_tool(tool)
122
+
123
+ # Expose list of available functions via resource
124
+ async def _functions_catalogue() -> list[str]:
125
+ """List all internal function names without prefix."""
126
+ return [
127
+ key.removeprefix(_INTERNAL_PREFIX)
128
+ for key in self._tool_manager._tools
129
+ if key.startswith(_INTERNAL_PREFIX)
130
+ ]
131
+
132
+ from fastmcp.resources import Resource
133
+
134
+ catalogue_resource = Resource.from_function(
135
+ _functions_catalogue,
136
+ uri=f"{name}://functions",
137
+ name=f"{name.title()} Functions",
138
+ description=f"List of available {name} functions",
139
+ )
140
+ self._resource_manager.add_resource(catalogue_resource)
141
+
142
+ # Override _list_tools to hide internal tools when mounted
143
+ async def _list_tools(self) -> list[Tool]:
144
+ """Override _list_tools to hide internal tools when mounted."""
145
+ return [
146
+ tool
147
+ for key, tool in self._tool_manager._tools.items()
148
+ if not key.startswith(_INTERNAL_PREFIX)
149
+ ]
150
+
151
+ def _sync_list_tools(self) -> dict[str, Tool]:
152
+ """Override _list_tools to hide internal tools when mounted."""
153
+ return {
154
+ key: tool
155
+ for key, tool in self._tool_manager._tools.items()
156
+ if not key.startswith(_INTERNAL_PREFIX)
157
+ }
158
+
159
+
160
+ __all__ = ["HiddenRouter", "MCPRouter"]