hte-cli 0.2.26__tar.gz → 0.2.28__tar.gz

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.
Files changed (32) hide show
  1. {hte_cli-0.2.26 → hte_cli-0.2.28}/PKG-INFO +1 -1
  2. {hte_cli-0.2.26 → hte_cli-0.2.28}/pyproject.toml +1 -1
  3. {hte_cli-0.2.26 → hte_cli-0.2.28}/src/hte_cli/cli.py +93 -8
  4. {hte_cli-0.2.26 → hte_cli-0.2.28}/src/hte_cli/image_utils.py +187 -0
  5. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/e2e/automated_runner.py +30 -5
  6. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/unit/test_image_utils.py +225 -0
  7. {hte_cli-0.2.26 → hte_cli-0.2.28}/uv.lock +1 -1
  8. {hte_cli-0.2.26 → hte_cli-0.2.28}/.gitignore +0 -0
  9. {hte_cli-0.2.26 → hte_cli-0.2.28}/README.md +0 -0
  10. {hte_cli-0.2.26 → hte_cli-0.2.28}/src/hte_cli/__init__.py +0 -0
  11. {hte_cli-0.2.26 → hte_cli-0.2.28}/src/hte_cli/__main__.py +0 -0
  12. {hte_cli-0.2.26 → hte_cli-0.2.28}/src/hte_cli/api_client.py +0 -0
  13. {hte_cli-0.2.26 → hte_cli-0.2.28}/src/hte_cli/config.py +0 -0
  14. {hte_cli-0.2.26 → hte_cli-0.2.28}/src/hte_cli/errors.py +0 -0
  15. {hte_cli-0.2.26 → hte_cli-0.2.28}/src/hte_cli/events.py +0 -0
  16. {hte_cli-0.2.26 → hte_cli-0.2.28}/src/hte_cli/runner.py +0 -0
  17. {hte_cli-0.2.26 → hte_cli-0.2.28}/src/hte_cli/scorers.py +0 -0
  18. {hte_cli-0.2.26 → hte_cli-0.2.28}/src/hte_cli/version_check.py +0 -0
  19. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/__init__.py +0 -0
  20. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/e2e/__init__.py +0 -0
  21. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/e2e/conftest.py +0 -0
  22. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/e2e/e2e_test.py +0 -0
  23. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/e2e/test_benchmark_flows.py +0 -0
  24. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/e2e/test_eval_logs.py +0 -0
  25. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/e2e/test_infrastructure.py +0 -0
  26. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/e2e/test_runtime_imports.py +0 -0
  27. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/e2e/test_session_lifecycle.py +0 -0
  28. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/e2e/verify_docker_deps.py +0 -0
  29. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/unit/__init__.py +0 -0
  30. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/unit/conftest.py +0 -0
  31. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/unit/test_runner.py +0 -0
  32. {hte_cli-0.2.26 → hte_cli-0.2.28}/tests/unit/test_scorers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hte-cli
3
- Version: 0.2.26
3
+ Version: 0.2.28
4
4
  Summary: Human Time-to-Completion Evaluation CLI
5
5
  Project-URL: Homepage, https://github.com/sean-peters-au/lyptus-mono
6
6
  Author: Lyptus Research
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hte-cli"
3
- version = "0.2.26"
3
+ version = "0.2.28"
4
4
  description = "Human Time-to-Completion Evaluation CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -3,6 +3,7 @@
3
3
  Uses Click for command parsing and Rich for pretty output.
4
4
  """
5
5
 
6
+ import os
6
7
  import sys
7
8
  import webbrowser
8
9
 
@@ -173,12 +174,18 @@ def session_join(ctx, session_id: str, force_setup: bool):
173
174
  sys.exit(1)
174
175
 
175
176
  # Check Docker is running before we start (with retry prompt)
177
+ # In non-interactive mode (CI/automation), fail immediately instead of prompting
178
+ non_interactive = os.environ.get("HTE_NON_INTERACTIVE", "").lower() in ("1", "true", "yes")
179
+
176
180
  while True:
177
181
  docker_ok, docker_error = _check_docker()
178
182
  if docker_ok:
179
183
  console.print("[dim]✓ Docker running[/dim]")
180
184
  break
181
185
  console.print(f"[red]{docker_error}[/red]")
186
+ if non_interactive:
187
+ console.print("[dim]Non-interactive mode - exiting[/dim]")
188
+ sys.exit(1)
182
189
  console.print()
183
190
  if not click.confirm("Start Docker and retry?", default=True):
184
191
  sys.exit(1)
@@ -238,6 +245,10 @@ def session_join(ctx, session_id: str, force_setup: bool):
238
245
  extract_images_from_compose,
239
246
  extract_image_platforms_from_compose,
240
247
  pull_image_with_progress,
248
+ check_image_architecture_matches_host,
249
+ fix_image_architecture,
250
+ get_host_docker_platform,
251
+ is_running_in_linux_vm_on_arm,
241
252
  )
242
253
 
243
254
  # Create event streamer
@@ -317,6 +328,22 @@ def session_join(ctx, session_id: str, force_setup: bool):
317
328
  if images:
318
329
  from hte_cli.image_utils import check_image_exists_locally
319
330
 
331
+ # Detect host architecture for smart image handling
332
+ is_linux_arm = is_running_in_linux_vm_on_arm()
333
+ host_platform = get_host_docker_platform()
334
+
335
+ if is_linux_arm:
336
+ console.print(
337
+ f"[yellow]![/yellow] Detected [bold]Linux ARM64[/bold] environment"
338
+ )
339
+ console.print(
340
+ f" [dim]Will verify cached images match host architecture ({host_platform})[/dim]"
341
+ )
342
+ console.print(
343
+ f" [dim]Mismatched images will be automatically re-pulled[/dim]"
344
+ )
345
+ console.print()
346
+
320
347
  console.print(f"[bold]Step 2:[/bold] Pulling {len(images)} Docker image(s)...")
321
348
  pull_start = time.monotonic()
322
349
  pull_errors = {}
@@ -327,13 +354,57 @@ def session_join(ctx, session_id: str, force_setup: bool):
327
354
 
328
355
  # Check if already cached
329
356
  if check_image_exists_locally(img):
330
- console.print(f" [green]✓[/green] {short_name} [dim](cached)[/dim]")
331
- cached_images.append(img)
332
- continue
357
+ # Verify architecture matches host (important for Linux ARM64)
358
+ matches, image_arch, host_arch = check_image_architecture_matches_host(img)
359
+
360
+ if matches:
361
+ # Show architecture info on Linux ARM64 for transparency
362
+ if is_linux_arm and image_arch:
363
+ console.print(
364
+ f" [green]✓[/green] {short_name} [dim](cached, arch: {image_arch})[/dim]"
365
+ )
366
+ else:
367
+ console.print(f" [green]✓[/green] {short_name} [dim](cached)[/dim]")
368
+ cached_images.append(img)
369
+ continue
370
+ else:
371
+ # Architecture mismatch detected - this is the key fix for Linux ARM64
372
+ console.print(
373
+ f" [yellow]⚠[/yellow] {short_name} [yellow]architecture mismatch![/yellow]"
374
+ )
375
+ console.print(
376
+ f" [dim]Cached image: {image_arch} | Host: {host_arch}[/dim]"
377
+ )
378
+ console.print(
379
+ f" [dim]Removing cached image and re-pulling correct architecture...[/dim]"
380
+ )
381
+
382
+ needed_fix, fix_msg = fix_image_architecture(img)
383
+ if needed_fix:
384
+ console.print(
385
+ f" [green]✓[/green] {short_name} [green]fixed![/green] [dim]({fix_msg})[/dim]"
386
+ )
387
+ pulled_images.append(img)
388
+ continue
389
+ else:
390
+ console.print(f" [red]✗[/red] {short_name} [dim]({fix_msg})[/dim]")
391
+ failed_images.append(img)
392
+ pull_errors[img] = fix_msg
393
+ continue
333
394
 
334
395
  # Need to pull - show progress
335
396
  last_status = ["connecting..."]
336
397
  last_error = [""]
398
+
399
+ # On Linux ARM64, use host platform if no explicit platform in compose
400
+ # This prevents pulling amd64 images that won't run
401
+ pull_platform = platform
402
+ if not pull_platform and is_linux_arm and host_platform:
403
+ pull_platform = host_platform
404
+ console.print(
405
+ f" [dim]Pulling {short_name} with platform {host_platform}...[/dim]"
406
+ )
407
+
337
408
  with console.status(
338
409
  f"[yellow]↓[/yellow] {short_name} [dim]connecting...[/dim]"
339
410
  ) as status:
@@ -357,14 +428,21 @@ def session_join(ctx, session_id: str, force_setup: bool):
357
428
  last_error[0] = line
358
429
 
359
430
  success = pull_image_with_progress(
360
- img, platform=platform, on_progress=show_progress
431
+ img, platform=pull_platform, on_progress=show_progress
361
432
  )
362
433
 
363
434
  if success:
364
- console.print(f" [green]✓[/green] {short_name} [dim](downloaded)[/dim]")
435
+ # Show platform info on Linux ARM64 for confirmation
436
+ if is_linux_arm and pull_platform:
437
+ arch_short = pull_platform.split("/")[-1] # e.g., "arm64"
438
+ console.print(
439
+ f" [green]✓[/green] {short_name} [dim](downloaded, arch: {arch_short})[/dim]"
440
+ )
441
+ else:
442
+ console.print(f" [green]✓[/green] {short_name} [dim](downloaded)[/dim]")
365
443
  pulled_images.append(img)
366
444
  else:
367
- platform_note = f" (platform: {platform})" if platform else ""
445
+ platform_note = f" (platform: {pull_platform})" if pull_platform else ""
368
446
  console.print(f" [red]✗[/red] {short_name}{platform_note} [dim](failed)[/dim]")
369
447
  if last_error[0]:
370
448
  console.print(f" [dim]{last_error[0][:60]}[/dim]")
@@ -388,8 +466,15 @@ def session_join(ctx, session_id: str, force_setup: bool):
388
466
  console.print()
389
467
  console.print("[yellow]Troubleshooting:[/yellow]")
390
468
  console.print(" 1. Check Docker is running: docker info")
391
- console.print(" 2. Try manual pull: docker pull python:3.12-slim --platform linux/amd64")
392
- console.print(" 3. Check network connectivity")
469
+
470
+ # Architecture-specific advice
471
+ if is_linux_arm:
472
+ console.print(f" 2. You're on Linux ARM64 - try: docker pull <image> --platform linux/arm64")
473
+ console.print(" 3. For x86-only images, enable QEMU: docker run --privileged --rm tonistiigi/binfmt --install all")
474
+ else:
475
+ console.print(" 2. Try manual pull: docker pull <image>")
476
+
477
+ console.print(" 4. Check network connectivity")
393
478
  console.print()
394
479
  console.print("Session remains active - you can retry with: hte-cli session join " + session_id)
395
480
  sys.exit(1)
@@ -2,6 +2,7 @@
2
2
 
3
3
  import logging
4
4
  import os
5
+ import platform
5
6
  import pty
6
7
  import re
7
8
  import select
@@ -13,6 +14,119 @@ import yaml
13
14
  logger = logging.getLogger(__name__)
14
15
 
15
16
 
17
+ # Architecture mapping: Python's platform.machine() -> Docker platform
18
+ ARCH_TO_DOCKER_PLATFORM = {
19
+ "x86_64": "linux/amd64",
20
+ "amd64": "linux/amd64",
21
+ "aarch64": "linux/arm64",
22
+ "arm64": "linux/arm64",
23
+ }
24
+
25
+ # Docker image architecture names (from docker inspect)
26
+ DOCKER_ARCH_TO_PLATFORM = {
27
+ "amd64": "linux/amd64",
28
+ "arm64": "linux/arm64",
29
+ }
30
+
31
+
32
+ def get_host_architecture() -> str:
33
+ """
34
+ Get the host machine's architecture.
35
+
36
+ Returns:
37
+ Architecture string (e.g., "x86_64", "arm64", "aarch64")
38
+ """
39
+ return platform.machine()
40
+
41
+
42
+ def get_host_docker_platform() -> str | None:
43
+ """
44
+ Get the Docker platform string for the host architecture.
45
+
46
+ Returns:
47
+ Docker platform (e.g., "linux/amd64", "linux/arm64") or None if unknown
48
+ """
49
+ arch = get_host_architecture()
50
+ return ARCH_TO_DOCKER_PLATFORM.get(arch)
51
+
52
+
53
+ def get_image_architecture(image: str) -> str | None:
54
+ """
55
+ Get the architecture of a locally cached Docker image.
56
+
57
+ Args:
58
+ image: Image name (e.g., "python:3.12-slim")
59
+
60
+ Returns:
61
+ Architecture string (e.g., "amd64", "arm64") or None if not found/error
62
+ """
63
+ try:
64
+ result = subprocess.run(
65
+ ["docker", "image", "inspect", image, "--format", "{{.Architecture}}"],
66
+ capture_output=True,
67
+ text=True,
68
+ timeout=10,
69
+ )
70
+ if result.returncode == 0:
71
+ return result.stdout.strip()
72
+ return None
73
+ except (subprocess.TimeoutExpired, FileNotFoundError):
74
+ return None
75
+
76
+
77
+ def check_image_architecture_matches_host(image: str) -> tuple[bool, str | None, str | None]:
78
+ """
79
+ Check if a cached image's architecture matches the host.
80
+
81
+ Args:
82
+ image: Image name to check
83
+
84
+ Returns:
85
+ Tuple of (matches, image_arch, host_arch):
86
+ - matches: True if architectures match or image not cached
87
+ - image_arch: The cached image's architecture (None if not cached)
88
+ - host_arch: The host's architecture
89
+ """
90
+ host_arch = get_host_architecture()
91
+ host_platform = get_host_docker_platform()
92
+
93
+ if not host_platform:
94
+ # Unknown host architecture - can't check
95
+ logger.warning(f"Unknown host architecture: {host_arch}")
96
+ return (True, None, host_arch)
97
+
98
+ image_arch = get_image_architecture(image)
99
+ if not image_arch:
100
+ # Image not cached - nothing to check
101
+ return (True, None, host_arch)
102
+
103
+ image_platform = DOCKER_ARCH_TO_PLATFORM.get(image_arch)
104
+ matches = image_platform == host_platform
105
+
106
+ if not matches:
107
+ logger.info(
108
+ f"Architecture mismatch for {image}: "
109
+ f"cached={image_arch} ({image_platform}), host={host_arch} ({host_platform})"
110
+ )
111
+
112
+ return (matches, image_arch, host_arch)
113
+
114
+
115
+ def is_running_in_linux_vm_on_arm() -> bool:
116
+ """
117
+ Detect if running Linux ARM64 (likely a VM on Apple Silicon).
118
+
119
+ This is a common setup that causes architecture issues because:
120
+ - macOS Docker Desktop handles multi-arch via Rosetta
121
+ - Linux ARM64 in a VM doesn't have that, needs explicit platform handling
122
+
123
+ Returns:
124
+ True if running Linux on ARM64
125
+ """
126
+ import sys
127
+ return sys.platform == "linux" and get_host_architecture() in ("aarch64", "arm64")
128
+
129
+
16
130
  def extract_images_from_compose(compose_yaml: str) -> list[str]:
17
131
  """
18
132
  Extract Docker image names from a compose.yaml string.
@@ -86,6 +200,79 @@ def check_image_exists_locally(image: str) -> bool:
86
200
  return False
87
201
 
88
202
 
203
+ def remove_image(image: str) -> bool:
204
+ """
205
+ Remove a Docker image from local cache.
206
+
207
+ Args:
208
+ image: Image name to remove
209
+
210
+ Returns:
211
+ True if removed successfully, False otherwise
212
+ """
213
+ try:
214
+ result = subprocess.run(
215
+ ["docker", "rmi", image],
216
+ capture_output=True,
217
+ timeout=30,
218
+ )
219
+ return result.returncode == 0
220
+ except (subprocess.TimeoutExpired, FileNotFoundError):
221
+ return False
222
+
223
+
224
+ def fix_image_architecture(
225
+ image: str,
226
+ on_status: Callable[[str], None] | None = None,
227
+ ) -> tuple[bool, str]:
228
+ """
229
+ Check if a cached image has wrong architecture and fix it if needed.
230
+
231
+ For Linux ARM64 hosts (e.g., VM on Apple Silicon), this:
232
+ 1. Checks if the cached image is amd64 when host is arm64
233
+ 2. Removes the wrongly-cached image
234
+ 3. Re-pulls with explicit --platform linux/arm64
235
+
236
+ Args:
237
+ image: Image name to check/fix
238
+ on_status: Callback for status updates
239
+
240
+ Returns:
241
+ Tuple of (needed_fix, message):
242
+ - needed_fix: True if image was re-pulled
243
+ - message: Description of what happened
244
+ """
245
+ matches, image_arch, host_arch = check_image_architecture_matches_host(image)
246
+
247
+ if matches:
248
+ if image_arch:
249
+ return (False, f"architecture OK ({image_arch})")
250
+ else:
251
+ return (False, "not cached")
252
+
253
+ # Architecture mismatch detected
254
+ host_platform = get_host_docker_platform()
255
+ if not host_platform:
256
+ return (False, f"unknown host architecture: {host_arch}")
257
+
258
+ if on_status:
259
+ on_status(f"Cached image is {image_arch}, host is {host_arch} - re-pulling...")
260
+
261
+ # Remove the wrongly-cached image
262
+ logger.info(f"Removing wrongly-cached {image_arch} image: {image}")
263
+ if not remove_image(image):
264
+ return (False, f"failed to remove cached {image_arch} image")
265
+
266
+ # Re-pull with correct platform
267
+ logger.info(f"Re-pulling {image} with platform {host_platform}")
268
+ success = pull_image_with_progress(image, platform=host_platform)
269
+
270
+ if success:
271
+ return (True, f"re-pulled as {host_platform.split('/')[-1]}")
272
+ else:
273
+ return (False, f"failed to re-pull with platform {host_platform}")
274
+
275
+
89
276
  def pull_image_with_progress(
90
277
  image: str,
91
278
  platform: str | None = None,
@@ -476,11 +476,36 @@ def run_automated_test(task_id: str, benchmark: str, timeout: int = 300) -> list
476
476
  child.logfile = TeeWriter(log_file, sys.stdout)
477
477
 
478
478
  try:
479
- # session join flow: no "Ready to start?" prompt - goes straight into setup
480
- # Wait for "Session Joined" panel
481
- console.print("Waiting for session join...")
482
- idx = child.expect([r"Session Joined", pexpect.TIMEOUT, pexpect.EOF], timeout=60)
483
- if idx != 0:
479
+ # session join flow: first checks Docker, then joins session
480
+ # Handle Docker check prompt if it appears (Docker not running = test failure)
481
+ console.print("Waiting for Docker check / session join...")
482
+ idx = child.expect(
483
+ [
484
+ r"Docker running", # Docker OK - continue
485
+ r"Start Docker and retry\?", # Docker not running - fail test
486
+ r"Session Joined", # Skipped Docker message, got session
487
+ pexpect.TIMEOUT,
488
+ pexpect.EOF,
489
+ ],
490
+ timeout=60,
491
+ )
492
+
493
+ if idx == 1: # Docker not running prompt
494
+ child.sendline("n") # Don't retry - fail the test
495
+ results.append(
496
+ TestResult("Docker check", False, "", "Docker not running - test requires Docker")
497
+ )
498
+ return results
499
+ elif idx == 0: # Docker OK
500
+ results.append(TestResult("Docker check", True, "Docker running"))
501
+ # Now wait for Session Joined
502
+ idx = child.expect([r"Session Joined", pexpect.TIMEOUT, pexpect.EOF], timeout=60)
503
+ if idx != 0:
504
+ results.append(TestResult("CLI startup", False, "", "Never got 'Session Joined'"))
505
+ return results
506
+ elif idx == 2: # Got Session Joined directly
507
+ pass # Continue below
508
+ else: # TIMEOUT or EOF
484
509
  results.append(TestResult("CLI startup", False, "", "Never got 'Session Joined'"))
485
510
  return results
486
511
 
@@ -9,6 +9,13 @@ from hte_cli.image_utils import (
9
9
  extract_images_from_compose,
10
10
  prepull_compose_images,
11
11
  pull_image_with_progress,
12
+ get_host_architecture,
13
+ get_host_docker_platform,
14
+ get_image_architecture,
15
+ check_image_architecture_matches_host,
16
+ is_running_in_linux_vm_on_arm,
17
+ remove_image,
18
+ fix_image_architecture,
12
19
  )
13
20
 
14
21
 
@@ -396,3 +403,221 @@ services:
396
403
  assert pulled == 2
397
404
  assert failed == 0
398
405
  mock_pull.assert_called_once() # Only one pull
406
+
407
+
408
+ class TestGetHostArchitecture:
409
+ """Tests for get_host_architecture."""
410
+
411
+ @patch("hte_cli.image_utils.platform.machine")
412
+ def test_returns_platform_machine(self, mock_machine):
413
+ """Returns the result of platform.machine()."""
414
+ mock_machine.return_value = "x86_64"
415
+ assert get_host_architecture() == "x86_64"
416
+
417
+ mock_machine.return_value = "aarch64"
418
+ assert get_host_architecture() == "aarch64"
419
+
420
+
421
+ class TestGetHostDockerPlatform:
422
+ """Tests for get_host_docker_platform."""
423
+
424
+ @patch("hte_cli.image_utils.platform.machine")
425
+ def test_returns_linux_amd64_for_x86(self, mock_machine):
426
+ """Maps x86_64 to linux/amd64."""
427
+ mock_machine.return_value = "x86_64"
428
+ assert get_host_docker_platform() == "linux/amd64"
429
+
430
+ @patch("hte_cli.image_utils.platform.machine")
431
+ def test_returns_linux_arm64_for_aarch64(self, mock_machine):
432
+ """Maps aarch64 to linux/arm64."""
433
+ mock_machine.return_value = "aarch64"
434
+ assert get_host_docker_platform() == "linux/arm64"
435
+
436
+ @patch("hte_cli.image_utils.platform.machine")
437
+ def test_returns_linux_arm64_for_arm64(self, mock_machine):
438
+ """Maps arm64 to linux/arm64."""
439
+ mock_machine.return_value = "arm64"
440
+ assert get_host_docker_platform() == "linux/arm64"
441
+
442
+ @patch("hte_cli.image_utils.platform.machine")
443
+ def test_returns_none_for_unknown(self, mock_machine):
444
+ """Returns None for unknown architectures."""
445
+ mock_machine.return_value = "i386"
446
+ assert get_host_docker_platform() is None
447
+
448
+
449
+ class TestGetImageArchitecture:
450
+ """Tests for get_image_architecture."""
451
+
452
+ @patch("subprocess.run")
453
+ def test_returns_architecture_from_docker(self, mock_run):
454
+ """Returns architecture string from docker inspect."""
455
+ mock_run.return_value = MagicMock(returncode=0, stdout="amd64\n")
456
+ assert get_image_architecture("python:3.12-slim") == "amd64"
457
+
458
+ @patch("subprocess.run")
459
+ def test_returns_none_when_image_not_found(self, mock_run):
460
+ """Returns None when image doesn't exist."""
461
+ mock_run.return_value = MagicMock(returncode=1, stdout="")
462
+ assert get_image_architecture("nonexistent:image") is None
463
+
464
+ @patch("subprocess.run")
465
+ def test_returns_none_on_timeout(self, mock_run):
466
+ """Returns None on subprocess timeout."""
467
+ mock_run.side_effect = subprocess.TimeoutExpired(cmd="docker", timeout=10)
468
+ assert get_image_architecture("python:3.12-slim") is None
469
+
470
+
471
+ class TestCheckImageArchitectureMatchesHost:
472
+ """Tests for check_image_architecture_matches_host."""
473
+
474
+ @patch("hte_cli.image_utils.get_image_architecture")
475
+ @patch("hte_cli.image_utils.platform.machine")
476
+ def test_matches_when_both_amd64(self, mock_machine, mock_get_arch):
477
+ """Returns True when both image and host are amd64."""
478
+ mock_machine.return_value = "x86_64"
479
+ mock_get_arch.return_value = "amd64"
480
+
481
+ matches, image_arch, host_arch = check_image_architecture_matches_host("python:3.12-slim")
482
+
483
+ assert matches is True
484
+ assert image_arch == "amd64"
485
+ assert host_arch == "x86_64"
486
+
487
+ @patch("hte_cli.image_utils.get_image_architecture")
488
+ @patch("hte_cli.image_utils.platform.machine")
489
+ def test_matches_when_both_arm64(self, mock_machine, mock_get_arch):
490
+ """Returns True when both image and host are arm64."""
491
+ mock_machine.return_value = "aarch64"
492
+ mock_get_arch.return_value = "arm64"
493
+
494
+ matches, image_arch, host_arch = check_image_architecture_matches_host("python:3.12-slim")
495
+
496
+ assert matches is True
497
+ assert image_arch == "arm64"
498
+ assert host_arch == "aarch64"
499
+
500
+ @patch("hte_cli.image_utils.get_image_architecture")
501
+ @patch("hte_cli.image_utils.platform.machine")
502
+ def test_mismatch_amd64_on_arm_host(self, mock_machine, mock_get_arch):
503
+ """Returns False when amd64 image on arm64 host."""
504
+ mock_machine.return_value = "aarch64"
505
+ mock_get_arch.return_value = "amd64"
506
+
507
+ matches, image_arch, host_arch = check_image_architecture_matches_host("python:3.12-slim")
508
+
509
+ assert matches is False
510
+ assert image_arch == "amd64"
511
+ assert host_arch == "aarch64"
512
+
513
+ @patch("hte_cli.image_utils.get_image_architecture")
514
+ @patch("hte_cli.image_utils.platform.machine")
515
+ def test_returns_true_when_image_not_cached(self, mock_machine, mock_get_arch):
516
+ """Returns True (no issue) when image isn't cached."""
517
+ mock_machine.return_value = "aarch64"
518
+ mock_get_arch.return_value = None # Not cached
519
+
520
+ matches, image_arch, host_arch = check_image_architecture_matches_host("python:3.12-slim")
521
+
522
+ assert matches is True
523
+ assert image_arch is None
524
+
525
+
526
+ class TestIsRunningInLinuxVmOnArm:
527
+ """Tests for is_running_in_linux_vm_on_arm."""
528
+
529
+ @patch("hte_cli.image_utils.platform.machine")
530
+ @patch("sys.platform", "linux")
531
+ def test_true_for_linux_aarch64(self, mock_machine):
532
+ """Returns True for Linux on aarch64."""
533
+ mock_machine.return_value = "aarch64"
534
+ assert is_running_in_linux_vm_on_arm() is True
535
+
536
+ @patch("hte_cli.image_utils.platform.machine")
537
+ @patch("sys.platform", "linux")
538
+ def test_true_for_linux_arm64(self, mock_machine):
539
+ """Returns True for Linux on arm64."""
540
+ mock_machine.return_value = "arm64"
541
+ assert is_running_in_linux_vm_on_arm() is True
542
+
543
+ @patch("hte_cli.image_utils.platform.machine")
544
+ @patch("sys.platform", "darwin")
545
+ def test_false_for_macos_arm64(self, mock_machine):
546
+ """Returns False for macOS even on ARM."""
547
+ mock_machine.return_value = "arm64"
548
+ assert is_running_in_linux_vm_on_arm() is False
549
+
550
+ @patch("hte_cli.image_utils.platform.machine")
551
+ @patch("sys.platform", "linux")
552
+ def test_false_for_linux_x86(self, mock_machine):
553
+ """Returns False for Linux on x86."""
554
+ mock_machine.return_value = "x86_64"
555
+ assert is_running_in_linux_vm_on_arm() is False
556
+
557
+
558
+ class TestRemoveImage:
559
+ """Tests for remove_image."""
560
+
561
+ @patch("subprocess.run")
562
+ def test_returns_true_on_success(self, mock_run):
563
+ """Returns True when docker rmi succeeds."""
564
+ mock_run.return_value = MagicMock(returncode=0)
565
+ assert remove_image("python:3.12-slim") is True
566
+
567
+ @patch("subprocess.run")
568
+ def test_returns_false_on_failure(self, mock_run):
569
+ """Returns False when docker rmi fails."""
570
+ mock_run.return_value = MagicMock(returncode=1)
571
+ assert remove_image("python:3.12-slim") is False
572
+
573
+
574
+ class TestFixImageArchitecture:
575
+ """Tests for fix_image_architecture."""
576
+
577
+ @patch("hte_cli.image_utils.pull_image_with_progress")
578
+ @patch("hte_cli.image_utils.remove_image")
579
+ @patch("hte_cli.image_utils.check_image_architecture_matches_host")
580
+ def test_no_fix_needed_when_matches(self, mock_check, mock_remove, mock_pull):
581
+ """Returns (False, message) when architecture already matches."""
582
+ mock_check.return_value = (True, "arm64", "aarch64")
583
+
584
+ needed_fix, message = fix_image_architecture("python:3.12-slim")
585
+
586
+ assert needed_fix is False
587
+ assert "architecture OK" in message
588
+ mock_remove.assert_not_called()
589
+ mock_pull.assert_not_called()
590
+
591
+ @patch("hte_cli.image_utils.pull_image_with_progress")
592
+ @patch("hte_cli.image_utils.remove_image")
593
+ @patch("hte_cli.image_utils.check_image_architecture_matches_host")
594
+ @patch("hte_cli.image_utils.platform.machine")
595
+ def test_fixes_mismatch_by_repulling(self, mock_machine, mock_check, mock_remove, mock_pull):
596
+ """Removes and re-pulls when architecture mismatches."""
597
+ mock_machine.return_value = "aarch64"
598
+ mock_check.return_value = (False, "amd64", "aarch64") # Mismatch!
599
+ mock_remove.return_value = True
600
+ mock_pull.return_value = True
601
+
602
+ needed_fix, message = fix_image_architecture("python:3.12-slim")
603
+
604
+ assert needed_fix is True
605
+ assert "re-pulled" in message
606
+ mock_remove.assert_called_once_with("python:3.12-slim")
607
+ mock_pull.assert_called_once_with("python:3.12-slim", platform="linux/arm64")
608
+
609
+ @patch("hte_cli.image_utils.pull_image_with_progress")
610
+ @patch("hte_cli.image_utils.remove_image")
611
+ @patch("hte_cli.image_utils.check_image_architecture_matches_host")
612
+ @patch("hte_cli.image_utils.platform.machine")
613
+ def test_returns_false_when_repull_fails(self, mock_machine, mock_check, mock_remove, mock_pull):
614
+ """Returns (False, message) when re-pull fails."""
615
+ mock_machine.return_value = "aarch64"
616
+ mock_check.return_value = (False, "amd64", "aarch64")
617
+ mock_remove.return_value = True
618
+ mock_pull.return_value = False # Pull fails
619
+
620
+ needed_fix, message = fix_image_architecture("python:3.12-slim")
621
+
622
+ assert needed_fix is False
623
+ assert "failed to re-pull" in message
@@ -625,7 +625,7 @@ wheels = [
625
625
 
626
626
  [[package]]
627
627
  name = "hte-cli"
628
- version = "0.2.24"
628
+ version = "0.2.28"
629
629
  source = { editable = "." }
630
630
  dependencies = [
631
631
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes