hud-python 0.4.52__py3-none-any.whl → 0.4.53__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 (69) 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 +6 -3
  6. hud/cli/build.py +35 -27
  7. hud/cli/dev.py +11 -29
  8. hud/cli/eval.py +61 -61
  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_mcp_server.py +8 -7
  16. hud/cli/tests/test_push_happy.py +74 -0
  17. hud/cli/tests/test_push_wrapper.py +23 -0
  18. hud/cli/utils/docker.py +120 -1
  19. hud/cli/utils/runner.py +1 -1
  20. hud/cli/utils/tests/__init__.py +0 -0
  21. hud/cli/utils/tests/test_config.py +58 -0
  22. hud/cli/utils/tests/test_docker.py +93 -0
  23. hud/cli/utils/tests/test_docker_hints.py +71 -0
  24. hud/cli/utils/tests/test_env_check.py +74 -0
  25. hud/cli/utils/tests/test_environment.py +42 -0
  26. hud/cli/utils/tests/test_interactive_module.py +60 -0
  27. hud/cli/utils/tests/test_local_runner.py +50 -0
  28. hud/cli/utils/tests/test_logging_utils.py +23 -0
  29. hud/cli/utils/tests/test_metadata.py +49 -0
  30. hud/cli/utils/tests/test_package_runner.py +35 -0
  31. hud/cli/utils/tests/test_registry_utils.py +49 -0
  32. hud/cli/utils/tests/test_remote_runner.py +25 -0
  33. hud/cli/utils/tests/test_runner_modules.py +52 -0
  34. hud/cli/utils/tests/test_source_hash.py +36 -0
  35. hud/cli/utils/tests/test_tasks.py +80 -0
  36. hud/cli/utils/version_check.py +2 -2
  37. hud/datasets/tests/__init__.py +0 -0
  38. hud/datasets/tests/test_runner.py +106 -0
  39. hud/datasets/tests/test_utils.py +228 -0
  40. hud/otel/tests/__init__.py +0 -1
  41. hud/otel/tests/test_instrumentation.py +207 -0
  42. hud/server/tests/test_server_extra.py +2 -0
  43. hud/shared/exceptions.py +35 -4
  44. hud/shared/hints.py +25 -0
  45. hud/shared/requests.py +15 -3
  46. hud/shared/tests/test_exceptions.py +31 -23
  47. hud/shared/tests/test_hints.py +167 -0
  48. hud/telemetry/tests/test_async_context.py +242 -0
  49. hud/telemetry/tests/test_instrument.py +414 -0
  50. hud/telemetry/tests/test_job.py +609 -0
  51. hud/telemetry/tests/test_trace.py +183 -5
  52. hud/tools/computer/settings.py +2 -2
  53. hud/tools/tests/test_submit.py +85 -0
  54. hud/tools/tests/test_types.py +193 -0
  55. hud/types.py +7 -1
  56. hud/utils/agent_factories.py +1 -3
  57. hud/utils/mcp.py +1 -1
  58. hud/utils/tests/test_agent_factories.py +60 -0
  59. hud/utils/tests/test_mcp.py +4 -6
  60. hud/utils/tests/test_pretty_errors.py +186 -0
  61. hud/utils/tests/test_tasks.py +187 -0
  62. hud/utils/tests/test_tool_shorthand.py +154 -0
  63. hud/utils/tests/test_version.py +1 -1
  64. hud/version.py +1 -1
  65. {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/METADATA +47 -48
  66. {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/RECORD +69 -31
  67. {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/WHEEL +0 -0
  68. {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/entry_points.txt +0 -0
  69. {hud_python-0.4.52.dist-info → hud_python-0.4.53.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from hud.cli.analyze import (
9
+ analyze_environment,
10
+ analyze_environment_from_config,
11
+ analyze_environment_from_mcp_config,
12
+ display_interactive,
13
+ display_markdown,
14
+ parse_docker_command,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+
21
+ # Mark entire module as asyncio to ensure async tests run with pytest-asyncio
22
+ pytestmark = pytest.mark.asyncio
23
+
24
+
25
+ def test_parse_docker_command():
26
+ cmd = ["docker", "run", "--rm", "-i", "img"]
27
+ cfg = parse_docker_command(cmd)
28
+ assert cfg == {"local": {"command": "docker", "args": ["run", "--rm", "-i", "img"]}}
29
+
30
+
31
+ @pytest.mark.asyncio
32
+ @patch("hud.cli.analyze.MCPClient")
33
+ @patch("hud.cli.analyze.console")
34
+ async def test_analyze_environment_success_json(mock_console, MockClient):
35
+ client = AsyncMock()
36
+ client.initialize.return_value = None
37
+ client.analyze_environment.return_value = {"tools": [], "resources": []}
38
+ client.shutdown.return_value = None
39
+ MockClient.return_value = client
40
+
41
+ await analyze_environment(["docker", "run", "img"], output_format="json", verbose=False)
42
+ assert client.initialize.awaited
43
+ assert client.analyze_environment.awaited
44
+ assert client.shutdown.awaited
45
+ assert mock_console.print_json.called
46
+
47
+
48
+ @pytest.mark.asyncio
49
+ @patch("hud.cli.analyze.MCPClient")
50
+ @patch("hud.cli.analyze.console")
51
+ async def test_analyze_environment_failure(mock_console, MockClient):
52
+ client = AsyncMock()
53
+ client.initialize.side_effect = RuntimeError("boom")
54
+ client.shutdown.return_value = None
55
+ MockClient.return_value = client
56
+
57
+ # Should swallow exception and return without raising
58
+ await analyze_environment(["docker", "run", "img"], output_format="json", verbose=True)
59
+ assert client.shutdown.awaited
60
+ assert mock_console.print_json.called is False
61
+
62
+
63
+ def test_display_interactive_metadata_only(monkeypatch):
64
+ import hud.cli.analyze as mod
65
+
66
+ monkeypatch.setattr(mod, "console", MagicMock(), raising=False)
67
+ monkeypatch.setattr(mod, "hud_console", MagicMock(), raising=False)
68
+
69
+ analysis = {
70
+ "image": "img:latest",
71
+ "status": "cached",
72
+ "tool_count": 2,
73
+ "tools": [
74
+ {"name": "t1", "description": "d1", "inputSchema": {"type": "object"}},
75
+ {"name": "t2", "description": "d2"},
76
+ ],
77
+ "resources": [],
78
+ }
79
+ display_interactive(analysis)
80
+
81
+
82
+ def test_display_markdown_both_paths(capsys):
83
+ # metadata-only
84
+ md_only = {"image": "img:latest", "tool_count": 0, "tools": [], "resources": []}
85
+ display_markdown(md_only)
86
+
87
+ # live metadata
88
+ live = {"metadata": {"servers": ["s1"], "initialized": True}, "tools": [], "resources": []}
89
+ display_markdown(live)
90
+
91
+ # Check that output was generated
92
+ captured = capsys.readouterr()
93
+ assert "MCP Environment Analysis" in captured.out
94
+
95
+
96
+ @patch("hud.cli.analyze.MCPClient")
97
+ async def test_analyze_environment_from_config(MockClient, tmp_path: Path):
98
+ client = AsyncMock()
99
+ client.initialize.return_value = None
100
+ client.analyze_environment.return_value = {"tools": [], "resources": []}
101
+ client.shutdown.return_value = None
102
+ MockClient.return_value = client
103
+
104
+ cfg = tmp_path / "mcp.json"
105
+ cfg.write_text('{"local": {"command": "docker", "args": ["run", "img"]}}')
106
+ await analyze_environment_from_config(cfg, output_format="json", verbose=False)
107
+ assert client.initialize.awaited and client.shutdown.awaited
108
+
109
+
110
+ @patch("hud.cli.analyze.MCPClient")
111
+ async def test_analyze_environment_from_mcp_config(MockClient):
112
+ client = AsyncMock()
113
+ client.initialize.return_value = None
114
+ client.analyze_environment.return_value = {"tools": [], "resources": []}
115
+ client.shutdown.return_value = None
116
+ MockClient.return_value = client
117
+
118
+ mcp_config = {"local": {"command": "docker", "args": ["run", "img"]}}
119
+ await analyze_environment_from_mcp_config(mcp_config, output_format="json", verbose=False)
120
+ assert client.initialize.awaited and client.shutdown.awaited
@@ -219,6 +219,17 @@ class TestAnalyzeMcpEnvironment:
219
219
  mock_tool.description = "Test tool"
220
220
  mock_tool.inputSchema = {"type": "object"}
221
221
 
222
+ # Prefer analyze_environment path (aligns with analyze CLI tests)
223
+ mock_client.analyze_environment = mock.AsyncMock(
224
+ return_value={
225
+ "metadata": {"servers": ["local"], "initialized": True},
226
+ "tools": [{"name": "test_tool", "description": "Test tool"}],
227
+ "hub_tools": {},
228
+ "resources": [],
229
+ "telemetry": {},
230
+ }
231
+ )
232
+ # Fallback still defined for completeness
222
233
  mock_client.list_tools.return_value = [mock_tool]
223
234
 
224
235
  result = await analyze_mcp_environment("test:latest")
@@ -237,7 +248,9 @@ class TestAnalyzeMcpEnvironment:
237
248
  mock_client_class.return_value = mock_client
238
249
  mock_client.initialize.side_effect = ConnectionError("Connection failed")
239
250
 
240
- with pytest.raises(ConnectionError):
251
+ from hud.shared.exceptions import HudException
252
+
253
+ with pytest.raises(HudException, match="Connection failed"):
241
254
  await analyze_mcp_environment("test:latest")
242
255
 
243
256
  @mock.patch("hud.cli.build.MCPClient")
@@ -245,6 +258,15 @@ class TestAnalyzeMcpEnvironment:
245
258
  """Test analysis in verbose mode."""
246
259
  mock_client = mock.AsyncMock()
247
260
  mock_client_class.return_value = mock_client
261
+ mock_client.analyze_environment = mock.AsyncMock(
262
+ return_value={
263
+ "metadata": {"servers": ["local"], "initialized": True},
264
+ "tools": [],
265
+ "hub_tools": {},
266
+ "resources": [],
267
+ "telemetry": {},
268
+ }
269
+ )
248
270
  mock_client.list_tools.return_value = []
249
271
 
250
272
  # Just test that it runs without error in verbose mode
@@ -363,7 +385,7 @@ ENV API_KEY
363
385
  mock_run.return_value = mock_result
364
386
 
365
387
  # Run build
366
- build_environment(str(env_dir), "test/env:latest")
388
+ build_environment(str(env_dir), "test-env:latest")
367
389
 
368
390
  # Check lock file was created
369
391
  lock_file = env_dir / "hud.lock.yaml"
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+ import typer
8
+
9
+ from hud.cli.build import build_environment
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ @patch("hud.cli.build.compute_source_hash", return_value="deadbeef")
16
+ @patch(
17
+ "hud.cli.build.analyze_mcp_environment",
18
+ return_value={"initializeMs": 10, "toolCount": 0, "tools": []},
19
+ )
20
+ @patch("hud.cli.build.build_docker_image", return_value=True)
21
+ def test_build_label_rebuild_failure(_bd, _an, _hash, tmp_path: Path, monkeypatch):
22
+ # Minimal environment dir
23
+ env = tmp_path / "env"
24
+ env.mkdir()
25
+ (env / "Dockerfile").write_text("FROM python:3.11")
26
+
27
+ # Ensure subprocess.run returns non-zero for the second build (label build)
28
+ import types
29
+
30
+ def run_side_effect(cmd, *a, **k):
31
+ # Return 0 for first docker build, 1 for label build
32
+ if isinstance(cmd, list) and cmd[:2] == ["docker", "build"] and "--label" in cmd:
33
+ return types.SimpleNamespace(returncode=1, stderr="boom")
34
+ return types.SimpleNamespace(returncode=0, stdout="")
35
+
36
+ monkeypatch.setenv("FASTMCP_DISABLE_BANNER", "1")
37
+ with (
38
+ patch("hud.cli.build.subprocess.run", side_effect=run_side_effect),
39
+ pytest.raises(typer.Exit),
40
+ ):
41
+ build_environment(str(env), verbose=False)
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest import mock
5
+
6
+ from hud.cli.build import (
7
+ extract_env_vars_from_dockerfile,
8
+ get_docker_image_digest,
9
+ get_docker_image_id,
10
+ )
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+
16
+ def test_extract_env_vars_from_dockerfile_complex(tmp_path: Path):
17
+ dockerfile = tmp_path / "Dockerfile"
18
+ dockerfile.write_text(
19
+ """
20
+ FROM python:3.11
21
+ ARG BUILD_TOKEN
22
+ ARG DEFAULTED=1
23
+ ENV RUNTIME_KEY
24
+ ENV FROM_ARG=$BUILD_TOKEN
25
+ ENV WITH_DEFAULT=val
26
+ """
27
+ )
28
+ required, optional = extract_env_vars_from_dockerfile(dockerfile)
29
+ # BUILD_TOKEN required (ARG without default)
30
+ assert "BUILD_TOKEN" in required
31
+ # RUNTIME_KEY required (ENV without value)
32
+ assert "RUNTIME_KEY" in required
33
+ # FROM_ARG references BUILD_TOKEN -> required
34
+ assert "FROM_ARG" in required
35
+ # DEFAULTED and WITH_DEFAULT should not be marked required by default
36
+ assert "DEFAULTED" not in required
37
+ assert "WITH_DEFAULT" not in required
38
+ assert optional == []
39
+
40
+
41
+ @mock.patch("subprocess.run")
42
+ def test_get_docker_image_digest_none(mock_run):
43
+ mock_run.return_value = mock.Mock(stdout="[]", returncode=0)
44
+ assert get_docker_image_digest("img") is None
45
+
46
+
47
+ @mock.patch("subprocess.run")
48
+ def test_get_docker_image_id_ok(mock_run):
49
+ mock_run.return_value = mock.Mock(stdout="sha256:abc", returncode=0)
50
+ assert get_docker_image_id("img") == "sha256:abc"
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import patch
4
+
5
+ import hud.cli as cli
6
+
7
+
8
+ def test_version_does_not_crash():
9
+ # Just ensure it runs without raising
10
+ cli.version()
11
+
12
+
13
+ @patch("hud.cli.list_module.list_command")
14
+ def test_list_environments_wrapper(mock_list):
15
+ cli.list_environments(filter_name=None, json_output=False, show_all=False, verbose=False)
16
+ assert mock_list.called
17
+
18
+
19
+ @patch("hud.cli.clone_repository", return_value=(True, "/tmp/repo"))
20
+ @patch("hud.cli.get_clone_message", return_value={})
21
+ @patch("hud.cli.print_tutorial")
22
+ def test_clone_wrapper(mock_tutorial, _msg, _clone):
23
+ cli.clone("https://example.com/repo.git")
24
+ assert mock_tutorial.called
25
+
26
+
27
+ @patch("hud.cli.remove_command")
28
+ def test_remove_wrapper(mock_remove):
29
+ cli.remove(target="all", yes=True, verbose=False)
30
+ assert mock_remove.called
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ import pytest
7
+
8
+ import hud.cli as cli
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+
14
+ @patch("hud.cli.utils.metadata.analyze_from_metadata", new_callable=AsyncMock)
15
+ @patch("asyncio.run")
16
+ def test_analyze_params_metadata(mock_run, mock_analyze):
17
+ # image only -> metadata path
18
+ cli.analyze(params=["img:latest"], output_format="json", verbose=False)
19
+ assert mock_run.called
20
+
21
+
22
+ @patch("hud.cli.analyze.analyze_environment", new_callable=AsyncMock)
23
+ @patch("hud.cli.utils.docker.build_run_command")
24
+ @patch("asyncio.run")
25
+ def test_analyze_params_live(mock_run, mock_build_cmd, mock_analyze_env):
26
+ mock_build_cmd.return_value = ["docker", "run", "img", "-e", "K=V"]
27
+ # docker args trigger live path
28
+ cli.analyze(params=["img:latest", "-e", "K=V"], output_format="json", verbose=True)
29
+ assert mock_run.called
30
+
31
+
32
+ def test_analyze_no_params_errors():
33
+ import typer
34
+
35
+ # When no params provided, analyze prints help and exits(1)
36
+ with pytest.raises(typer.Exit):
37
+ cli.analyze(params=None, config=None, cursor=None, output_format="json", verbose=False) # type: ignore
38
+
39
+
40
+ @patch("hud.cli.analyze.analyze_environment_from_config", new_callable=AsyncMock)
41
+ @patch("asyncio.run")
42
+ def test_analyze_from_config(mock_run, mock_func, tmp_path: Path):
43
+ cfg = tmp_path / "cfg.json"
44
+ cfg.write_text("{}")
45
+ cli.analyze(params=None, config=cfg, cursor=None, output_format="json", verbose=False) # type: ignore
46
+ assert mock_run.called
47
+
48
+
49
+ @patch("hud.cli.parse_cursor_config")
50
+ @patch("hud.cli.analyze.analyze_environment_from_mcp_config", new_callable=AsyncMock)
51
+ @patch("asyncio.run")
52
+ def test_analyze_from_cursor(mock_run, mock_analyze, mock_parse):
53
+ mock_parse.return_value = (["cmd", "arg"], None)
54
+ cli.analyze(params=None, config=None, cursor="server", output_format="json", verbose=False) # type: ignore
55
+ assert mock_run.called
56
+
57
+
58
+ @patch("hud.cli.build_command")
59
+ def test_build_env_var_parsing(mock_build):
60
+ cli.build(
61
+ params=[".", "-e", "A=B", "--env=C=D", "--env", "E=F"],
62
+ tag=None,
63
+ no_cache=False,
64
+ verbose=False,
65
+ platform=None,
66
+ )
67
+ assert mock_build.called
68
+ # args: directory, tag, no_cache, verbose, env_vars, platform
69
+ env_vars = mock_build.call_args[0][4]
70
+ assert env_vars == {"A": "B", "C": "D", "E": "F"}
71
+
72
+
73
+ @patch("hud.cli.utils.runner.run_mcp_server")
74
+ def test_run_local_calls_runner(mock_runner):
75
+ cli.run(
76
+ params=["img:latest"],
77
+ local=True,
78
+ transport="stdio",
79
+ port=1234,
80
+ url=None, # type: ignore
81
+ api_key=None,
82
+ run_id=None,
83
+ verbose=False,
84
+ )
85
+ assert mock_runner.called
86
+
87
+
88
+ @patch("hud.cli.utils.remote_runner.run_remote_server")
89
+ def test_run_remote_calls_remote(mock_remote):
90
+ cli.run(
91
+ params=["img:latest"],
92
+ local=False,
93
+ transport="http",
94
+ port=8765,
95
+ url="https://x",
96
+ api_key=None,
97
+ run_id=None,
98
+ verbose=True,
99
+ )
100
+ assert mock_remote.called
101
+
102
+
103
+ def test_run_no_params_errors():
104
+ import typer
105
+
106
+ with pytest.raises(typer.Exit):
107
+ cli.run(params=None) # type: ignore
108
+
109
+
110
+ @patch("hud.cli.run_mcp_dev_server")
111
+ def test_dev_calls_runner(mock_dev):
112
+ cli.dev(
113
+ params=["server.main"],
114
+ docker=False,
115
+ stdio=False,
116
+ port=9000,
117
+ verbose=False,
118
+ inspector=False,
119
+ interactive=False,
120
+ watch=None, # type: ignore
121
+ )
122
+ assert mock_dev.called
123
+
124
+
125
+ @patch("hud.cli.pull_command")
126
+ def test_pull_command_wrapper(mock_pull):
127
+ cli.pull(target="org/name:tag", lock_file=None, yes=True, verify_only=True, verbose=False)
128
+ assert mock_pull.called
129
+
130
+
131
+ @patch("hud.cli.push_command")
132
+ def test_push_command_wrapper(mock_push, tmp_path: Path):
133
+ cli.push(directory=str(tmp_path), image=None, tag=None, sign=False, yes=True, verbose=True)
134
+ assert mock_push.called
@@ -15,22 +15,23 @@ class TestRunMCPDevServer:
15
15
  """Test the main server runner."""
16
16
 
17
17
  def test_run_dev_server_image_not_found(self) -> None:
18
- """Test handling when Docker image doesn't exist."""
19
- import click
18
+ """When using Docker mode without a lock file, exits with typer.Exit(1)."""
19
+ import typer
20
20
 
21
21
  with (
22
- patch("hud.cli.utils.environment.image_exists", return_value=False),
23
- patch("click.confirm", return_value=False),
24
- pytest.raises(click.Abort),
22
+ patch("hud.cli.dev.should_use_docker_mode", return_value=True),
23
+ patch("hud.cli.dev.Path.cwd"),
24
+ patch("hud.cli.dev.hud_console"),
25
+ pytest.raises(typer.Exit),
25
26
  ):
26
27
  run_mcp_dev_server(
27
- module=".",
28
+ module=None,
28
29
  stdio=False,
29
30
  port=8765,
30
31
  verbose=False,
31
32
  inspector=False,
32
33
  interactive=False,
33
34
  watch=[],
34
- docker=False,
35
+ docker=True,
35
36
  docker_args=[],
36
37
  )
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+ from typing import TYPE_CHECKING
5
+ from unittest.mock import patch
6
+
7
+ from hud.cli.push import push_environment
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+
13
+ @patch("hud.cli.push.get_docker_username", return_value="tester")
14
+ @patch(
15
+ "hud.cli.push.get_docker_image_labels",
16
+ return_value={"org.hud.manifest.head": "abc", "org.hud.version": "0.1.0"},
17
+ )
18
+ @patch("hud.cli.push.requests.post")
19
+ @patch("hud.cli.push.subprocess.Popen")
20
+ @patch("hud.cli.push.subprocess.run")
21
+ def test_push_happy_path(
22
+ mock_run, mock_popen, mock_post, _labels, _user, tmp_path: Path, monkeypatch
23
+ ):
24
+ # Prepare minimal environment with lock file
25
+ env_dir = tmp_path
26
+ (env_dir / "hud.lock.yaml").write_text(
27
+ "images:\n local: org/env:latest\nbuild:\n version: 0.1.0\n"
28
+ )
29
+
30
+ # Provide API key via main settings module
31
+ monkeypatch.setattr("hud.settings.settings.api_key", "sk-test", raising=False)
32
+
33
+ # ensure_built noop - patch from the right module
34
+ monkeypatch.setattr("hud.cli.utils.env_check.ensure_built", lambda *_a, **_k: {})
35
+
36
+ # Mock subprocess.run behavior depending on command
37
+ def run_side_effect(args, *a, **k):
38
+ cmd = list(args)
39
+ # docker inspect checks
40
+ if cmd[:2] == ["docker", "inspect"]:
41
+ # For label digest query at end
42
+ if "--format" in cmd and "{{index .RepoDigests 0}}" in cmd:
43
+ return SimpleNamespace(returncode=0, stdout="org/env@sha256:deadbeef")
44
+ # Existence checks succeed
45
+ return SimpleNamespace(returncode=0, stdout="")
46
+ # docker tag success
47
+ if cmd[:2] == ["docker", "tag"]:
48
+ return SimpleNamespace(returncode=0, stdout="")
49
+ return SimpleNamespace(returncode=0, stdout="")
50
+
51
+ mock_run.side_effect = run_side_effect
52
+
53
+ # Mock Popen push pipeline
54
+ class _Proc:
55
+ def __init__(self):
56
+ self.stdout = ["digest: sha256:deadbeef\n", "pushed\n"]
57
+ self.returncode = 0
58
+
59
+ def wait(self):
60
+ return 0
61
+
62
+ mock_popen.return_value = _Proc()
63
+
64
+ # Mock registry POST success
65
+ mock_post.return_value = SimpleNamespace(status_code=201, json=lambda: {"ok": True}, text="")
66
+
67
+ # Execute
68
+ push_environment(
69
+ directory=str(env_dir), image=None, tag=None, sign=False, yes=True, verbose=False
70
+ )
71
+
72
+ # Lock file updated with pushed entry
73
+ data = (env_dir / "hud.lock.yaml").read_text()
74
+ assert "pushed:" in data
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+ import typer
8
+
9
+ from hud.cli.push import push_environment
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+
15
+ @patch("hud.cli.push.ensure_built")
16
+ @patch("hud.cli.push.HUDConsole")
17
+ @patch("hud.cli.push.subprocess.run")
18
+ def test_push_environment_missing_lock_raises(mock_run, mock_console, _ensure, tmp_path: Path):
19
+ # No hud.lock.yaml → Exit(1)
20
+ with pytest.raises(typer.Exit):
21
+ push_environment(
22
+ directory=str(tmp_path), image=None, tag=None, sign=False, yes=True, verbose=False
23
+ )