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

@@ -0,0 +1,450 @@
1
+ """Tests for build.py - Build HUD environments and generate lock files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import tempfile
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ import pytest
12
+ import typer
13
+ import yaml
14
+
15
+ from hud.cli.build import (
16
+ analyze_mcp_environment,
17
+ build_docker_image,
18
+ build_environment,
19
+ extract_env_vars_from_dockerfile,
20
+ get_docker_image_digest,
21
+ get_docker_image_id,
22
+ get_existing_version,
23
+ increment_version,
24
+ parse_version,
25
+ )
26
+
27
+
28
+ class TestParseVersion:
29
+ """Test version parsing functionality."""
30
+
31
+ def test_parse_standard_version(self):
32
+ """Test parsing standard semantic version."""
33
+ assert parse_version("1.2.3") == (1, 2, 3)
34
+ assert parse_version("10.20.30") == (10, 20, 30)
35
+
36
+ def test_parse_version_with_v_prefix(self):
37
+ """Test parsing version with v prefix."""
38
+ assert parse_version("v1.2.3") == (1, 2, 3)
39
+ assert parse_version("v2.0.0") == (2, 0, 0)
40
+
41
+ def test_parse_incomplete_version(self):
42
+ """Test parsing versions with missing parts."""
43
+ assert parse_version("1.2") == (1, 2, 0)
44
+ assert parse_version("1") == (1, 0, 0)
45
+ assert parse_version("") == (0, 0, 0)
46
+
47
+ def test_parse_invalid_version(self):
48
+ """Test parsing invalid versions."""
49
+ assert parse_version("abc") == (0, 0, 0)
50
+ assert parse_version("1.x.3") == (0, 0, 0)
51
+ assert parse_version("not-a-version") == (0, 0, 0)
52
+
53
+
54
+ class TestIncrementVersion:
55
+ """Test version incrementing functionality."""
56
+
57
+ def test_increment_patch(self):
58
+ """Test incrementing patch version."""
59
+ assert increment_version("1.2.3") == "1.2.4"
60
+ assert increment_version("1.2.3", "patch") == "1.2.4"
61
+ assert increment_version("1.0.0") == "1.0.1"
62
+
63
+ def test_increment_minor(self):
64
+ """Test incrementing minor version."""
65
+ assert increment_version("1.2.3", "minor") == "1.3.0"
66
+ assert increment_version("0.5.10", "minor") == "0.6.0"
67
+
68
+ def test_increment_major(self):
69
+ """Test incrementing major version."""
70
+ assert increment_version("1.2.3", "major") == "2.0.0"
71
+ assert increment_version("0.5.10", "major") == "1.0.0"
72
+
73
+ def test_increment_with_v_prefix(self):
74
+ """Test incrementing version with v prefix."""
75
+ assert increment_version("v1.2.3") == "1.2.4"
76
+ assert increment_version("v2.0.0", "major") == "3.0.0"
77
+
78
+
79
+ class TestGetExistingVersion:
80
+ """Test getting version from lock file."""
81
+
82
+ def test_get_version_from_lock(self, tmp_path):
83
+ """Test extracting version from lock file."""
84
+ lock_data = {
85
+ "build": {
86
+ "version": "1.2.3"
87
+ }
88
+ }
89
+ lock_path = tmp_path / "hud.lock.yaml"
90
+ lock_path.write_text(yaml.dump(lock_data))
91
+
92
+ assert get_existing_version(lock_path) == "1.2.3"
93
+
94
+ def test_get_version_no_build_section(self, tmp_path):
95
+ """Test when lock file has no build section."""
96
+ lock_data = {"other": "data"}
97
+ lock_path = tmp_path / "hud.lock.yaml"
98
+ lock_path.write_text(yaml.dump(lock_data))
99
+
100
+ assert get_existing_version(lock_path) is None
101
+
102
+ def test_get_version_no_file(self, tmp_path):
103
+ """Test when lock file doesn't exist."""
104
+ lock_path = tmp_path / "hud.lock.yaml"
105
+ assert get_existing_version(lock_path) is None
106
+
107
+ def test_get_version_invalid_yaml(self, tmp_path):
108
+ """Test when lock file has invalid YAML."""
109
+ lock_path = tmp_path / "hud.lock.yaml"
110
+ lock_path.write_text("invalid: yaml: content:")
111
+ assert get_existing_version(lock_path) is None
112
+
113
+
114
+ class TestGetDockerImageDigest:
115
+ """Test getting Docker image digest."""
116
+
117
+ @mock.patch("subprocess.run")
118
+ def test_get_digest_success(self, mock_run):
119
+ """Test successfully getting image digest."""
120
+ # Note: The function expects to parse a list from the string representation
121
+ mock_run.return_value = mock.Mock(
122
+ stdout="['docker.io/library/test@sha256:abc123']",
123
+ returncode=0
124
+ )
125
+
126
+ result = get_docker_image_digest("test:latest")
127
+ assert result == "docker.io/library/test@sha256:abc123"
128
+
129
+ @mock.patch("subprocess.run")
130
+ def test_get_digest_empty(self, mock_run):
131
+ """Test when docker returns empty digest list."""
132
+ mock_run.return_value = mock.Mock(stdout="[]", returncode=0)
133
+
134
+ result = get_docker_image_digest("test:latest")
135
+ assert result is None
136
+
137
+ @mock.patch("subprocess.run")
138
+ def test_get_digest_failure(self, mock_run):
139
+ """Test when docker command fails."""
140
+ mock_run.side_effect = subprocess.CalledProcessError(1, ["docker"])
141
+
142
+ result = get_docker_image_digest("test:latest")
143
+ assert result is None
144
+
145
+
146
+ class TestGetDockerImageId:
147
+ """Test getting Docker image ID."""
148
+
149
+ @mock.patch("subprocess.run")
150
+ def test_get_id_success(self, mock_run):
151
+ """Test successfully getting image ID."""
152
+ mock_run.return_value = mock.Mock(
153
+ stdout="sha256:abc123def456",
154
+ returncode=0
155
+ )
156
+
157
+ result = get_docker_image_id("test:latest")
158
+ assert result == "sha256:abc123def456"
159
+
160
+ @mock.patch("subprocess.run")
161
+ def test_get_id_empty(self, mock_run):
162
+ """Test when docker returns empty ID."""
163
+ mock_run.return_value = mock.Mock(stdout="", returncode=0)
164
+
165
+ result = get_docker_image_id("test:latest")
166
+ assert result is None
167
+
168
+ @mock.patch("subprocess.run")
169
+ def test_get_id_failure(self, mock_run):
170
+ """Test when docker command fails."""
171
+ mock_run.side_effect = subprocess.CalledProcessError(1, ["docker"])
172
+
173
+ result = get_docker_image_id("test:latest")
174
+ assert result is None
175
+
176
+
177
+ class TestExtractEnvVarsFromDockerfile:
178
+ """Test extracting environment variables from Dockerfile."""
179
+
180
+ def test_extract_required_env_vars(self, tmp_path):
181
+ """Test extracting required environment variables."""
182
+ dockerfile = tmp_path / "Dockerfile"
183
+ dockerfile.write_text("""
184
+ FROM python:3.11
185
+ ENV API_KEY
186
+ ENV SECRET_TOKEN=
187
+ ENV OTHER_VAR=default_value
188
+ """)
189
+
190
+ required, optional = extract_env_vars_from_dockerfile(dockerfile)
191
+ assert "API_KEY" in required
192
+ assert "SECRET_TOKEN" in required
193
+ assert "OTHER_VAR" not in required
194
+ assert len(optional) == 0
195
+
196
+ def test_extract_no_env_vars(self, tmp_path):
197
+ """Test Dockerfile with no ENV directives."""
198
+ dockerfile = tmp_path / "Dockerfile"
199
+ dockerfile.write_text("""
200
+ FROM python:3.11
201
+ RUN pip install fastmcp
202
+ """)
203
+
204
+ required, optional = extract_env_vars_from_dockerfile(dockerfile)
205
+ assert len(required) == 0
206
+ assert len(optional) == 0
207
+
208
+ def test_extract_no_dockerfile(self, tmp_path):
209
+ """Test when Dockerfile doesn't exist."""
210
+ dockerfile = tmp_path / "Dockerfile"
211
+ required, optional = extract_env_vars_from_dockerfile(dockerfile)
212
+ assert len(required) == 0
213
+ assert len(optional) == 0
214
+
215
+
216
+ @pytest.mark.asyncio
217
+ class TestAnalyzeMcpEnvironment:
218
+ """Test analyzing MCP environment."""
219
+
220
+ @mock.patch("hud.cli.build.MCPClient")
221
+ async def test_analyze_success(self, mock_client_class):
222
+ """Test successful environment analysis."""
223
+ # Setup mock client
224
+ mock_client = mock.AsyncMock()
225
+ mock_client_class.return_value = mock_client
226
+
227
+ # Mock tool
228
+ mock_tool = mock.Mock()
229
+ mock_tool.name = "test_tool"
230
+ mock_tool.description = "Test tool"
231
+ mock_tool.inputSchema = {"type": "object"}
232
+
233
+ mock_client.list_tools.return_value = [mock_tool]
234
+
235
+ result = await analyze_mcp_environment("test:latest")
236
+
237
+ assert result["success"] is True
238
+ assert result["toolCount"] == 1
239
+ assert len(result["tools"]) == 1
240
+ assert result["tools"][0]["name"] == "test_tool"
241
+ assert "initializeMs" in result
242
+
243
+ @mock.patch("hud.cli.build.MCPClient")
244
+ async def test_analyze_failure(self, mock_client_class):
245
+ """Test failed environment analysis."""
246
+ # Setup mock client to fail
247
+ mock_client = mock.AsyncMock()
248
+ mock_client_class.return_value = mock_client
249
+ mock_client.initialize.side_effect = Exception("Connection failed")
250
+
251
+ result = await analyze_mcp_environment("test:latest")
252
+
253
+ assert result["success"] is False
254
+ assert result["toolCount"] == 0
255
+ assert "error" in result
256
+ assert "Connection failed" in result["error"]
257
+
258
+ @mock.patch("hud.cli.build.MCPClient")
259
+ async def test_analyze_verbose_mode(self, mock_client_class):
260
+ """Test analysis in verbose mode."""
261
+ mock_client = mock.AsyncMock()
262
+ mock_client_class.return_value = mock_client
263
+ mock_client.list_tools.return_value = []
264
+
265
+ # Just test that it runs without error in verbose mode
266
+ result = await analyze_mcp_environment("test:latest", verbose=True)
267
+
268
+ assert result["success"] is True
269
+ assert "initializeMs" in result
270
+
271
+
272
+ class TestBuildDockerImage:
273
+ """Test building Docker images."""
274
+
275
+ @mock.patch("subprocess.Popen")
276
+ def test_build_success(self, mock_popen, tmp_path):
277
+ """Test successful Docker build."""
278
+ # Create Dockerfile
279
+ dockerfile = tmp_path / "Dockerfile"
280
+ dockerfile.write_text("FROM python:3.11")
281
+
282
+ # Mock successful process
283
+ mock_process = mock.Mock()
284
+ mock_process.stdout = ["Step 1/1 : FROM python:3.11\n", "Successfully built abc123\n"]
285
+ mock_process.wait.return_value = None
286
+ mock_process.returncode = 0
287
+ mock_popen.return_value = mock_process
288
+
289
+ result = build_docker_image(tmp_path, "test:latest")
290
+ assert result is True
291
+
292
+ @mock.patch("subprocess.Popen")
293
+ def test_build_failure(self, mock_popen, tmp_path):
294
+ """Test failed Docker build."""
295
+ dockerfile = tmp_path / "Dockerfile"
296
+ dockerfile.write_text("FROM python:3.11")
297
+
298
+ # Mock failed process
299
+ mock_process = mock.Mock()
300
+ mock_process.stdout = ["Error: failed to build\n"]
301
+ mock_process.wait.return_value = None
302
+ mock_process.returncode = 1
303
+ mock_popen.return_value = mock_process
304
+
305
+ result = build_docker_image(tmp_path, "test:latest")
306
+ assert result is False
307
+
308
+ def test_build_no_dockerfile(self, tmp_path):
309
+ """Test build when Dockerfile missing."""
310
+ result = build_docker_image(tmp_path, "test:latest")
311
+ assert result is False
312
+
313
+ @mock.patch("subprocess.Popen")
314
+ def test_build_with_no_cache(self, mock_popen, tmp_path):
315
+ """Test build with --no-cache flag."""
316
+ dockerfile = tmp_path / "Dockerfile"
317
+ dockerfile.write_text("FROM python:3.11")
318
+
319
+ mock_process = mock.Mock()
320
+ mock_process.stdout = []
321
+ mock_process.wait.return_value = None
322
+ mock_process.returncode = 0
323
+ mock_popen.return_value = mock_process
324
+
325
+ build_docker_image(tmp_path, "test:latest", no_cache=True)
326
+
327
+ # Check that --no-cache was included
328
+ call_args = mock_popen.call_args[0][0]
329
+ assert "--no-cache" in call_args
330
+
331
+
332
+ class TestBuildEnvironment:
333
+ """Test the main build_environment function."""
334
+
335
+ @mock.patch("hud.cli.build.build_docker_image")
336
+ @mock.patch("hud.cli.build.analyze_mcp_environment")
337
+ @mock.patch("hud.cli.build.save_to_registry")
338
+ @mock.patch("hud.cli.build.get_docker_image_id")
339
+ @mock.patch("subprocess.Popen")
340
+ @mock.patch("subprocess.run")
341
+ def test_build_environment_success(
342
+ self,
343
+ mock_run,
344
+ mock_popen,
345
+ mock_get_id,
346
+ mock_save_registry,
347
+ mock_analyze,
348
+ mock_build_docker,
349
+ tmp_path,
350
+ ):
351
+ """Test successful environment build."""
352
+ # Setup directory structure
353
+ env_dir = tmp_path / "test-env"
354
+ env_dir.mkdir()
355
+
356
+ # Create pyproject.toml
357
+ pyproject = env_dir / "pyproject.toml"
358
+ pyproject.write_text("""
359
+ [tool.hud]
360
+ image = "test/env:dev"
361
+ """)
362
+
363
+ # Create Dockerfile
364
+ dockerfile = env_dir / "Dockerfile"
365
+ dockerfile.write_text("""
366
+ FROM python:3.11
367
+ ENV API_KEY
368
+ """)
369
+
370
+ # Mock functions
371
+ mock_build_docker.return_value = True
372
+ mock_analyze.return_value = {
373
+ "success": True,
374
+ "toolCount": 2,
375
+ "initializeMs": 1500,
376
+ "tools": [
377
+ {"name": "tool1", "description": "Tool 1"},
378
+ {"name": "tool2", "description": "Tool 2"},
379
+ ],
380
+ }
381
+ mock_get_id.return_value = "sha256:abc123"
382
+
383
+ # Mock final rebuild
384
+ mock_process = mock.Mock()
385
+ # Create a mock file-like object with read method
386
+ mock_stdout = mock.Mock()
387
+ mock_stdout.read.return_value = ""
388
+ mock_process.stdout = mock_stdout
389
+ mock_process.wait.return_value = None
390
+ mock_process.returncode = 0
391
+ mock_popen.return_value = mock_process
392
+
393
+ # Run build
394
+ build_environment(str(env_dir), "test/env:latest")
395
+
396
+ # Check lock file was created
397
+ lock_file = env_dir / "hud.lock.yaml"
398
+ assert lock_file.exists()
399
+
400
+ # Verify lock file content
401
+ with open(lock_file) as f:
402
+ lock_data = yaml.safe_load(f)
403
+
404
+ assert lock_data["image"] == "test/env:latest@sha256:abc123"
405
+ assert lock_data["build"]["version"] == "0.1.0"
406
+ assert lock_data["environment"]["toolCount"] == 2
407
+ assert len(lock_data["tools"]) == 2
408
+
409
+ def test_build_environment_no_directory(self):
410
+ """Test build when directory doesn't exist."""
411
+ with pytest.raises(typer.Exit):
412
+ build_environment("/nonexistent/path")
413
+
414
+ def test_build_environment_no_pyproject(self, tmp_path):
415
+ """Test build when pyproject.toml missing."""
416
+ with pytest.raises(typer.Exit):
417
+ build_environment(str(tmp_path))
418
+
419
+ @mock.patch("hud.cli.build.build_docker_image")
420
+ def test_build_environment_docker_failure(self, mock_build, tmp_path):
421
+ """Test when Docker build fails."""
422
+ env_dir = tmp_path / "test-env"
423
+ env_dir.mkdir()
424
+ (env_dir / "pyproject.toml").write_text("[tool.hud]")
425
+ (env_dir / "Dockerfile").write_text("FROM python:3.11")
426
+
427
+ mock_build.return_value = False
428
+
429
+ with pytest.raises(typer.Exit):
430
+ build_environment(str(env_dir))
431
+
432
+ @mock.patch("hud.cli.build.build_docker_image")
433
+ @mock.patch("hud.cli.build.analyze_mcp_environment")
434
+ def test_build_environment_analysis_failure(self, mock_analyze, mock_build, tmp_path):
435
+ """Test when MCP analysis fails."""
436
+ env_dir = tmp_path / "test-env"
437
+ env_dir.mkdir()
438
+ (env_dir / "pyproject.toml").write_text("[tool.hud]")
439
+ (env_dir / "Dockerfile").write_text("FROM python:3.11")
440
+
441
+ mock_build.return_value = True
442
+ mock_analyze.return_value = {
443
+ "success": False,
444
+ "error": "Connection failed",
445
+ "toolCount": 0,
446
+ "tools": [],
447
+ }
448
+
449
+ with pytest.raises(typer.Exit):
450
+ build_environment(str(env_dir))