hud-python 0.4.52__py3-none-any.whl → 0.4.54__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.

Files changed (70) hide show
  1. hud/agents/base.py +9 -2
  2. hud/agents/openai_chat_generic.py +15 -3
  3. hud/agents/tests/test_base.py +15 -0
  4. hud/agents/tests/test_base_runtime.py +164 -0
  5. hud/cli/__init__.py +20 -12
  6. hud/cli/build.py +35 -27
  7. hud/cli/dev.py +13 -31
  8. hud/cli/eval.py +85 -84
  9. hud/cli/tests/test_analyze_module.py +120 -0
  10. hud/cli/tests/test_build.py +24 -2
  11. hud/cli/tests/test_build_failure.py +41 -0
  12. hud/cli/tests/test_build_module.py +50 -0
  13. hud/cli/tests/test_cli_more_wrappers.py +30 -0
  14. hud/cli/tests/test_cli_root.py +134 -0
  15. hud/cli/tests/test_eval.py +6 -6
  16. hud/cli/tests/test_mcp_server.py +8 -7
  17. hud/cli/tests/test_push_happy.py +74 -0
  18. hud/cli/tests/test_push_wrapper.py +23 -0
  19. hud/cli/utils/docker.py +120 -1
  20. hud/cli/utils/runner.py +1 -1
  21. hud/cli/utils/tests/__init__.py +0 -0
  22. hud/cli/utils/tests/test_config.py +58 -0
  23. hud/cli/utils/tests/test_docker.py +93 -0
  24. hud/cli/utils/tests/test_docker_hints.py +71 -0
  25. hud/cli/utils/tests/test_env_check.py +74 -0
  26. hud/cli/utils/tests/test_environment.py +42 -0
  27. hud/cli/utils/tests/test_interactive_module.py +60 -0
  28. hud/cli/utils/tests/test_local_runner.py +50 -0
  29. hud/cli/utils/tests/test_logging_utils.py +23 -0
  30. hud/cli/utils/tests/test_metadata.py +49 -0
  31. hud/cli/utils/tests/test_package_runner.py +35 -0
  32. hud/cli/utils/tests/test_registry_utils.py +49 -0
  33. hud/cli/utils/tests/test_remote_runner.py +25 -0
  34. hud/cli/utils/tests/test_runner_modules.py +52 -0
  35. hud/cli/utils/tests/test_source_hash.py +36 -0
  36. hud/cli/utils/tests/test_tasks.py +80 -0
  37. hud/cli/utils/version_check.py +2 -2
  38. hud/datasets/tests/__init__.py +0 -0
  39. hud/datasets/tests/test_runner.py +106 -0
  40. hud/datasets/tests/test_utils.py +228 -0
  41. hud/otel/tests/__init__.py +0 -1
  42. hud/otel/tests/test_instrumentation.py +207 -0
  43. hud/server/tests/test_server_extra.py +2 -0
  44. hud/shared/exceptions.py +35 -4
  45. hud/shared/hints.py +25 -0
  46. hud/shared/requests.py +15 -3
  47. hud/shared/tests/test_exceptions.py +31 -23
  48. hud/shared/tests/test_hints.py +167 -0
  49. hud/telemetry/tests/test_async_context.py +242 -0
  50. hud/telemetry/tests/test_instrument.py +414 -0
  51. hud/telemetry/tests/test_job.py +609 -0
  52. hud/telemetry/tests/test_trace.py +183 -5
  53. hud/tools/computer/settings.py +2 -2
  54. hud/tools/tests/test_submit.py +85 -0
  55. hud/tools/tests/test_types.py +193 -0
  56. hud/types.py +17 -1
  57. hud/utils/agent_factories.py +1 -3
  58. hud/utils/mcp.py +1 -1
  59. hud/utils/tests/test_agent_factories.py +60 -0
  60. hud/utils/tests/test_mcp.py +4 -6
  61. hud/utils/tests/test_pretty_errors.py +186 -0
  62. hud/utils/tests/test_tasks.py +187 -0
  63. hud/utils/tests/test_tool_shorthand.py +154 -0
  64. hud/utils/tests/test_version.py +1 -1
  65. hud/version.py +1 -1
  66. {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/METADATA +49 -49
  67. {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/RECORD +70 -32
  68. {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/WHEEL +0 -0
  69. {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/entry_points.txt +0 -0
  70. {hud_python-0.4.52.dist-info → hud_python-0.4.54.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from typing import TYPE_CHECKING
5
+ from unittest.mock import patch
6
+
7
+ from hud.cli.utils.env_check import (
8
+ _collect_source_diffs,
9
+ _parse_generated_at,
10
+ ensure_built,
11
+ find_environment_dir,
12
+ )
13
+
14
+ if TYPE_CHECKING:
15
+ from pathlib import Path
16
+
17
+
18
+ def test_parse_generated_at_variants():
19
+ ts = _parse_generated_at({"build": {"generatedAt": datetime.now(UTC).isoformat()}})
20
+ assert isinstance(ts, float)
21
+ assert _parse_generated_at({}) is None
22
+
23
+
24
+ def test_collect_source_diffs_basic(tmp_path: Path):
25
+ env = tmp_path / "env"
26
+ env.mkdir()
27
+ # simulate files
28
+ (env / "Dockerfile").write_text("FROM python:3.11")
29
+ (env / "pyproject.toml").write_text("[tool.hud]")
30
+ (env / "a.txt").write_text("x")
31
+
32
+ # stored file list includes a non-existent file and old time
33
+ built_time = (datetime.now(UTC) - timedelta(days=1)).isoformat()
34
+ lock = {"build": {"sourceFiles": ["a.txt", "b.txt"], "generatedAt": built_time}}
35
+
36
+ # Patch list_source_files to return current env files
37
+ with patch("hud.cli.utils.env_check.list_source_files") as mock_list:
38
+ mock_list.return_value = [env / "a.txt", env / "Dockerfile"]
39
+ diffs = _collect_source_diffs(env, lock)
40
+ assert "Dockerfile" in diffs["added"]
41
+ assert "b.txt" in diffs["removed"]
42
+ assert "a.txt" in diffs["modified"] or "a.txt" in diffs["added"]
43
+
44
+
45
+ def test_find_environment_dir_prefers_lock(tmp_path: Path):
46
+ # Create env as a sibling to tasks, so it will be in the candidates list
47
+ parent = tmp_path / "parent"
48
+ parent.mkdir()
49
+ tasks = parent / "tasks.json"
50
+ tasks.write_text("[]")
51
+ env = tmp_path / "env"
52
+ env.mkdir()
53
+ (env / "hud.lock.yaml").write_text("version: 1.0")
54
+ # Set cwd to env so it's in the candidate list
55
+ with patch("pathlib.Path.cwd", return_value=env):
56
+ found = find_environment_dir(tasks)
57
+ # Should find env because cwd returns env and it has hud.lock.yaml
58
+ assert found == env
59
+
60
+
61
+ def test_ensure_built_no_lock_noninteractive(tmp_path: Path):
62
+ env = tmp_path / "e"
63
+ env.mkdir()
64
+ # Non-interactive: returns empty dict and does not raise
65
+ result = ensure_built(env, interactive=False)
66
+ assert result == {}
67
+
68
+
69
+ def test_ensure_built_interactive_build(tmp_path: Path):
70
+ env = tmp_path / "e"
71
+ env.mkdir()
72
+ # Simulate interactive=False path avoids prompts
73
+ result = ensure_built(env, interactive=False)
74
+ assert result == {}
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from hud.cli.utils.environment import get_image_name, image_exists, is_environment_directory
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+
12
+ def test_get_image_name_override():
13
+ name, source = get_image_name(".", image_override="custom:dev")
14
+ assert name == "custom:dev" and source == "override"
15
+
16
+
17
+ def test_get_image_name_auto(tmp_path: Path):
18
+ env = tmp_path / "my_env"
19
+ env.mkdir()
20
+ # Provide Dockerfile and pyproject to pass directory check later if used
21
+ (env / "Dockerfile").write_text("FROM python:3.11")
22
+ (env / "pyproject.toml").write_text("[tool.hud]\nimage='x'")
23
+ name, source = get_image_name(env)
24
+ # Because pyproject exists with image key, source should be cache
25
+ assert source == "cache"
26
+ assert name == "x"
27
+
28
+
29
+ def test_is_environment_directory(tmp_path: Path):
30
+ d = tmp_path / "env"
31
+ d.mkdir()
32
+ assert is_environment_directory(d) is False
33
+ (d / "Dockerfile").write_text("FROM python:3.11")
34
+ assert is_environment_directory(d) is False
35
+ (d / "pyproject.toml").write_text("[tool.hud]")
36
+ assert is_environment_directory(d) is True
37
+
38
+
39
+ @patch("subprocess.run")
40
+ def test_image_exists_true(mock_run):
41
+ mock_run.return_value = MagicMock(returncode=0)
42
+ assert image_exists("img") is True
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ import pytest
7
+
8
+ from hud.cli.utils.interactive import InteractiveMCPTester
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ @patch("hud.cli.utils.interactive.MCPClient")
13
+ async def test_connect_and_disconnect(MockClient):
14
+ client = AsyncMock()
15
+ client.initialize.return_value = None
16
+ client.list_tools.return_value = []
17
+ client.shutdown.return_value = None
18
+ MockClient.return_value = client
19
+
20
+ tester = InteractiveMCPTester("http://localhost:8765/mcp", verbose=False)
21
+ ok = await tester.connect()
22
+ assert ok is True
23
+ assert tester.tools == []
24
+ await tester.disconnect()
25
+
26
+
27
+ def test_display_tools_handles_empty(capfd):
28
+ tester = InteractiveMCPTester("http://x")
29
+ tester.tools = []
30
+ tester.display_tools() # prints warning
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ @patch("hud.cli.utils.interactive.questionary")
35
+ async def test_select_tool_quit(mock_questionary):
36
+ tester = InteractiveMCPTester("http://x")
37
+ tester.tools = [SimpleNamespace(name="a", description="")]
38
+ # Simulate ESC/quit
39
+ mock_questionary.select.return_value.unsafe_ask_async.return_value = "❌ Quit"
40
+ sel = await tester.select_tool()
41
+ assert sel is None
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ @patch("hud.cli.utils.interactive.console")
46
+ async def test_get_tool_arguments_no_schema(mock_console):
47
+ tester = InteractiveMCPTester("http://x")
48
+ args = await tester.get_tool_arguments(SimpleNamespace(name="t", inputSchema=None))
49
+ assert args == {}
50
+
51
+
52
+ @pytest.mark.asyncio
53
+ @patch("hud.cli.utils.interactive.console")
54
+ async def test_call_tool_success(mock_console):
55
+ tester = InteractiveMCPTester("http://x")
56
+ fake_result = SimpleNamespace(isError=False, content=[SimpleNamespace(text="ok")])
57
+ tester.client = AsyncMock()
58
+ tester.client.call_tool.return_value = fake_result
59
+ await tester.call_tool(SimpleNamespace(name="t"), {"a": 1})
60
+ assert tester.client.call_tool.awaited
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from unittest import mock
5
+
6
+ import pytest
7
+
8
+ from hud.cli.utils.local_runner import run_local_server, run_with_reload
9
+
10
+ pytestmark = pytest.mark.skipif(
11
+ sys.platform == "win32", reason="Prefers Linux (signal/stdio nuances)"
12
+ )
13
+
14
+
15
+ @mock.patch("subprocess.run")
16
+ def test_run_local_server_no_reload_http(mock_run, monkeypatch):
17
+ mock_run.return_value = mock.Mock(returncode=0)
18
+ # Ensure sys.exit is raised with code 0
19
+ with pytest.raises(SystemExit) as exc:
20
+ run_local_server("server:app", transport="http", port=8765, verbose=True, reload=False)
21
+ assert exc.value.code == 0
22
+ # Verify the command contained port and no-banner
23
+ args = mock_run.call_args[0][0]
24
+ assert "--port" in args and "--no-banner" in args
25
+
26
+
27
+ @mock.patch("hud.cli.utils.local_runner.run_with_reload")
28
+ def test_run_local_server_reload_calls_reload(mock_reload):
29
+ run_local_server("server:app", transport="stdio", port=None, verbose=False, reload=True)
30
+ mock_reload.assert_called_once()
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_run_with_reload_import_error(monkeypatch):
35
+ # Force ImportError for watchfiles
36
+ import builtins as _builtins
37
+
38
+ real_import = _builtins.__import__
39
+
40
+ def _imp(name, *args, **kwargs):
41
+ if name == "watchfiles":
42
+ raise ImportError("nope")
43
+ return real_import(name, *args, **kwargs)
44
+
45
+ monkeypatch.setattr(_builtins, "__import__", _imp)
46
+
47
+ with pytest.raises(SystemExit) as exc:
48
+ # run_with_reload is async in this module; await it
49
+ await run_with_reload("server:app", transport="stdio", verbose=False)
50
+ assert exc.value.code == 1
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from hud.cli.utils.logging import CaptureLogger, analyze_error_for_hints, is_port_free
4
+
5
+
6
+ def test_capture_logger_basic(capfd):
7
+ logger = CaptureLogger(print_output=True)
8
+ logger.success("done")
9
+ logger.error("oops")
10
+ logger.info("info")
11
+ out = logger.get_output()
12
+ assert "done" in out and "oops" in out and "info" in out
13
+
14
+
15
+ def test_analyze_error_for_hints_matches():
16
+ hint = analyze_error_for_hints("ModuleNotFoundError: x")
17
+ assert hint and "dependencies" in hint
18
+
19
+
20
+ def test_is_port_free_returns_bool():
21
+ # Probe a high port; we only assert the function returns a boolean
22
+ free = is_port_free(65500)
23
+ assert isinstance(free, bool)
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from hud.cli.utils.metadata import (
9
+ analyze_from_metadata,
10
+ check_local_cache,
11
+ fetch_lock_from_registry,
12
+ )
13
+
14
+ if TYPE_CHECKING:
15
+ from pathlib import Path
16
+
17
+
18
+ @patch("hud.cli.utils.metadata.settings")
19
+ @patch("requests.get")
20
+ def test_fetch_lock_from_registry_success(mock_get, mock_settings):
21
+ mock_settings.hud_telemetry_url = "https://api.example.com"
22
+ mock_settings.api_key = None
23
+ resp = MagicMock(status_code=200)
24
+ resp.json.return_value = {"lock": "image: img\n"}
25
+ mock_get.return_value = resp
26
+ lock = fetch_lock_from_registry("org/name:tag")
27
+ assert lock is not None and lock["image"] == "img"
28
+
29
+
30
+ def test_check_local_cache_not_found(tmp_path: Path, monkeypatch):
31
+ # Point registry to empty dir
32
+ from hud.cli.utils import registry as reg
33
+
34
+ monkeypatch.setattr(reg, "get_registry_dir", lambda: tmp_path)
35
+ assert check_local_cache("org/name:tag") is None
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ @patch("hud.cli.utils.metadata.console")
40
+ @patch("hud.cli.utils.metadata.list_registry_entries")
41
+ @patch("hud.cli.utils.metadata.load_from_registry")
42
+ @patch("hud.cli.utils.metadata.extract_digest_from_image")
43
+ async def test_analyze_from_metadata_local(mock_extract, mock_load, mock_list, mock_console):
44
+ mock_extract.return_value = "abcd"
45
+ mock_load.return_value = {"image": "img", "environment": {"toolCount": 0}}
46
+ mock_list.return_value = []
47
+ await analyze_from_metadata("img@sha256:abcd", "json", verbose=False)
48
+ # Should print JSON
49
+ assert mock_console.print_json.called
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+ from unittest import mock
5
+
6
+ import pytest
7
+
8
+ from hud.cli.utils.package_runner import run_package_as_mcp
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ @mock.patch("hud.cli.utils.package_runner.FastMCP")
13
+ async def test_run_package_as_external_command(MockFastMCP):
14
+ proxy = mock.AsyncMock()
15
+ MockFastMCP.as_proxy.return_value = proxy
16
+ await run_package_as_mcp(["python", "-m", "server"], transport="http", port=9999)
17
+ assert proxy.run_async.awaited
18
+
19
+
20
+ @pytest.mark.asyncio
21
+ @mock.patch("hud.cli.utils.package_runner.importlib.import_module")
22
+ async def test_run_package_import_module(mock_import):
23
+ server = SimpleNamespace(name="test", run_async=mock.AsyncMock())
24
+ mod = SimpleNamespace(mcp=server)
25
+ mock_import.return_value = mod
26
+ await run_package_as_mcp("module_name", transport="stdio")
27
+ assert server.run_async.awaited
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ @mock.patch("hud.cli.utils.package_runner.importlib.import_module")
32
+ async def test_run_package_import_missing_attr(mock_import):
33
+ mock_import.return_value = SimpleNamespace()
34
+ with pytest.raises(SystemExit):
35
+ await run_package_as_mcp("module_name", transport="stdio")
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from hud.cli.utils.registry import (
6
+ extract_digest_from_image,
7
+ extract_name_and_tag,
8
+ list_registry_entries,
9
+ load_from_registry,
10
+ save_to_registry,
11
+ )
12
+
13
+ if TYPE_CHECKING:
14
+ from pathlib import Path
15
+
16
+
17
+ def test_extract_digest_from_image_variants():
18
+ assert extract_digest_from_image("repo/name@sha256:abcdef1234567890") == "abcdef123456"
19
+ assert extract_digest_from_image("sha256:deadbeefcafebabe") == "deadbeefcafe"
20
+ assert extract_digest_from_image("org/name:tag") == "tag"
21
+ assert extract_digest_from_image("org/name") == "latest"
22
+
23
+
24
+ def test_extract_name_and_tag():
25
+ assert extract_name_and_tag("docker.io/hudpython/test_init:latest@sha256:abc") == (
26
+ "hudpython/test_init",
27
+ "latest",
28
+ )
29
+ assert extract_name_and_tag("myorg/myenv:v1.0") == ("myorg/myenv", "v1.0")
30
+ assert extract_name_and_tag("myorg/myenv") == ("myorg/myenv", "latest")
31
+
32
+
33
+ def test_save_load_list_registry(tmp_path: Path, monkeypatch):
34
+ # Redirect registry dir to temp
35
+ from hud.cli.utils import registry as mod
36
+
37
+ monkeypatch.setattr(mod, "get_registry_dir", lambda: tmp_path)
38
+
39
+ data = {"image": "org/name:tag", "build": {"version": "0.1.0"}}
40
+ saved = save_to_registry(data, "org/name:tag@sha256:abcdef0123456789", verbose=True)
41
+ assert saved is not None and saved.exists()
42
+
43
+ # Digest directory was created
44
+ entries = list_registry_entries()
45
+ assert len(entries) == 1
46
+
47
+ digest, _ = entries[0]
48
+ loaded = load_from_registry(digest)
49
+ assert loaded and loaded.get("image") == "org/name:tag"
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from hud.cli.utils.remote_runner import run_remote_server
6
+
7
+
8
+ def test_run_remote_server_requires_api_key(monkeypatch):
9
+ # Ensure settings.api_key is None and no api_key provided
10
+ from hud.cli.utils import remote_runner as mod
11
+
12
+ monkeypatch.setattr(mod.settings, "api_key", None, raising=True)
13
+
14
+ with pytest.raises(SystemExit) as exc:
15
+ run_remote_server(
16
+ image="img:latest",
17
+ docker_args=[],
18
+ transport="stdio",
19
+ port=8765,
20
+ url="https://api.example.com/mcp",
21
+ api_key=None,
22
+ run_id=None,
23
+ verbose=False,
24
+ )
25
+ assert exc.value.code == 1
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest import mock
4
+
5
+ import pytest
6
+
7
+ from hud.cli.utils.remote_runner import build_remote_headers, parse_env_vars, parse_headers
8
+ from hud.cli.utils.runner import run_mcp_server
9
+
10
+
11
+ def test_parse_headers_and_env_vars():
12
+ assert parse_headers(["A:B", "C=D"]) == {"A": "B", "C": "D"}
13
+ assert parse_env_vars(["API_KEY=xxx"]) == {"Env-Api-Key": "xxx"}
14
+
15
+
16
+ def test_build_remote_headers_combines():
17
+ headers = build_remote_headers(
18
+ image="img:latest", env_args=["X=1"], header_args=["H:V"], api_key="k", run_id="r"
19
+ )
20
+ assert headers["Mcp-Image"] == "img:latest"
21
+ assert headers["Authorization"].startswith("Bearer ")
22
+ assert headers["Run-Id"] == "r"
23
+ assert headers["Env-X"] == "1"
24
+ assert headers["H"] == "V"
25
+
26
+
27
+ @mock.patch("hud.cli.utils.runner.run_stdio_server")
28
+ def test_run_mcp_server_stdio(mock_stdio):
29
+ run_mcp_server("img", [], "stdio", 8765, verbose=False, interactive=False)
30
+ assert mock_stdio.called
31
+
32
+
33
+ def test_run_mcp_server_stdio_interactive_fails():
34
+ with pytest.raises(SystemExit):
35
+ run_mcp_server("img", [], "stdio", 8765, verbose=False, interactive=True)
36
+
37
+
38
+ @mock.patch("hud.cli.utils.runner.run_http_server")
39
+ def test_run_mcp_server_http(mock_http):
40
+ run_mcp_server("img", [], "http", 8765, verbose=True, interactive=False)
41
+ assert mock_http.called
42
+
43
+
44
+ @mock.patch("hud.cli.utils.runner.run_http_server_interactive")
45
+ def test_run_mcp_server_http_interactive(mock_http_int):
46
+ run_mcp_server("img", [], "http", 8765, verbose=False, interactive=True)
47
+ assert mock_http_int.called
48
+
49
+
50
+ def test_run_mcp_server_unknown():
51
+ with pytest.raises(SystemExit):
52
+ run_mcp_server("img", [], "bad", 8765, verbose=False)
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from hud.cli.utils.source_hash import compute_source_hash, list_source_files
6
+
7
+ if TYPE_CHECKING:
8
+ from pathlib import Path
9
+
10
+
11
+ def test_source_hash_changes_with_content(tmp_path: Path):
12
+ env = tmp_path / "env"
13
+ env.mkdir()
14
+ (env / "Dockerfile").write_text("FROM python:3.11")
15
+ (env / "pyproject.toml").write_text("[tool.hud]\n")
16
+ (env / "server").mkdir()
17
+ (env / "server" / "main.py").write_text("print('hi')\n")
18
+
19
+ h1 = compute_source_hash(env)
20
+ # Change file content
21
+ (env / "server" / "main.py").write_text("print('bye')\n")
22
+ h2 = compute_source_hash(env)
23
+ assert h1 != h2
24
+
25
+
26
+ def test_list_source_files_sorted(tmp_path: Path):
27
+ env = tmp_path / "env"
28
+ env.mkdir()
29
+ (env / "Dockerfile").write_text("FROM python:3.11")
30
+ (env / "environment").mkdir()
31
+ (env / "environment" / "a.py").write_text("a")
32
+ (env / "environment" / "b.py").write_text("b")
33
+
34
+ files = list_source_files(env)
35
+ rels = [str(p.resolve().relative_to(env)).replace("\\", "/") for p in files]
36
+ assert rels == ["Dockerfile", "environment/a.py", "environment/b.py"]
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from hud.cli.utils.tasks import find_tasks_file
9
+
10
+
11
+ @patch("pathlib.Path.cwd")
12
+ def test_find_tasks_file_with_arg(mock_cwd):
13
+ """Test that when a tasks file is provided, it's returned as-is."""
14
+ assert find_tasks_file("some/path.json") == "some/path.json"
15
+ mock_cwd.assert_not_called()
16
+
17
+
18
+ @patch("pathlib.Path.cwd")
19
+ def test_find_tasks_file_no_files(mock_cwd):
20
+ """Test that FileNotFoundError is raised when no task files exist."""
21
+ mock_path = MagicMock(spec=Path)
22
+ mock_path.glob.return_value = []
23
+ mock_cwd.return_value = mock_path
24
+
25
+ with pytest.raises(FileNotFoundError, match="No task JSON or JSONL files found"):
26
+ find_tasks_file(None)
27
+
28
+
29
+ @patch("hud.cli.utils.tasks.hud_console")
30
+ @patch("pathlib.Path.cwd")
31
+ def test_find_tasks_file_single_file(mock_cwd, mock_console):
32
+ """Test that when only one file exists, it's returned without prompting."""
33
+ mock_path = MagicMock(spec=Path)
34
+ mock_file = MagicMock(spec=Path)
35
+ mock_file.__str__.return_value = "test.json"
36
+
37
+ def glob_side_effect(pattern):
38
+ if pattern == "*.json":
39
+ return [mock_file]
40
+ return []
41
+
42
+ mock_path.glob.side_effect = glob_side_effect
43
+ mock_path.__str__.return_value = str(Path.cwd())
44
+ mock_cwd.return_value = mock_path
45
+
46
+ result = find_tasks_file(None)
47
+ assert result == "test.json"
48
+ mock_console.select.assert_not_called()
49
+
50
+
51
+ @patch("hud.cli.utils.tasks.hud_console")
52
+ @patch("pathlib.Path.cwd")
53
+ def test_find_tasks_file_multiple_files(mock_cwd, mock_console):
54
+ """Test that when multiple files exist, user is prompted to select one."""
55
+ mock_path = MagicMock(spec=Path)
56
+ mock_file1 = MagicMock(spec=Path)
57
+ mock_file1.__str__.return_value = "test1.json"
58
+ mock_file2 = MagicMock(spec=Path)
59
+ mock_file2.__str__.return_value = "test2.jsonl"
60
+
61
+ def glob_side_effect(pattern):
62
+ if pattern == "*.json":
63
+ return [mock_file1]
64
+ if pattern == "*.jsonl":
65
+ return [mock_file2]
66
+ return []
67
+
68
+ mock_path.glob.side_effect = glob_side_effect
69
+ mock_path.__str__.return_value = str(Path.cwd())
70
+ mock_cwd.return_value = mock_path
71
+ mock_console.select.return_value = "test2.jsonl"
72
+
73
+ result = find_tasks_file(None)
74
+
75
+ assert result == "test2.jsonl"
76
+ mock_console.select.assert_called_once()
77
+ call_args = mock_console.select.call_args
78
+ assert call_args[0][0] == "Select a tasks file"
79
+ assert "test1.json" in call_args[1]["choices"]
80
+ assert "test2.jsonl" in call_args[1]["choices"]
@@ -171,11 +171,11 @@ def check_for_updates() -> VersionInfo | None:
171
171
 
172
172
  # Try to load from cache
173
173
  cached_info = _load_cache()
174
-
174
+
175
175
  # If cache exists but current version has changed (user upgraded), invalidate cache
176
176
  if cached_info and cached_info.current != current:
177
177
  cached_info = None # Force fresh check
178
-
178
+
179
179
  if cached_info:
180
180
  # Update the current version in the cached info to reflect reality
181
181
  # but keep the cached latest version and timestamp
File without changes