hte-cli 0.2.27__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.
- {hte_cli-0.2.27 → hte_cli-0.2.28}/PKG-INFO +1 -1
- {hte_cli-0.2.27 → hte_cli-0.2.28}/pyproject.toml +1 -1
- {hte_cli-0.2.27 → hte_cli-0.2.28}/src/hte_cli/cli.py +86 -8
- {hte_cli-0.2.27 → hte_cli-0.2.28}/src/hte_cli/image_utils.py +187 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/e2e/automated_runner.py +31 -10
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/unit/test_image_utils.py +225 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/uv.lock +1 -1
- {hte_cli-0.2.27 → hte_cli-0.2.28}/.gitignore +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/README.md +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/src/hte_cli/__init__.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/src/hte_cli/__main__.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/src/hte_cli/api_client.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/src/hte_cli/config.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/src/hte_cli/errors.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/src/hte_cli/events.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/src/hte_cli/runner.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/src/hte_cli/scorers.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/src/hte_cli/version_check.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/__init__.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/e2e/__init__.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/e2e/conftest.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/e2e/e2e_test.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/e2e/test_benchmark_flows.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/e2e/test_eval_logs.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/e2e/test_infrastructure.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/e2e/test_runtime_imports.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/e2e/test_session_lifecycle.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/e2e/verify_docker_deps.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/unit/__init__.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/unit/conftest.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/unit/test_runner.py +0 -0
- {hte_cli-0.2.27 → hte_cli-0.2.28}/tests/unit/test_scorers.py +0 -0
|
@@ -245,6 +245,10 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
245
245
|
extract_images_from_compose,
|
|
246
246
|
extract_image_platforms_from_compose,
|
|
247
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,
|
|
248
252
|
)
|
|
249
253
|
|
|
250
254
|
# Create event streamer
|
|
@@ -324,6 +328,22 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
324
328
|
if images:
|
|
325
329
|
from hte_cli.image_utils import check_image_exists_locally
|
|
326
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
|
+
|
|
327
347
|
console.print(f"[bold]Step 2:[/bold] Pulling {len(images)} Docker image(s)...")
|
|
328
348
|
pull_start = time.monotonic()
|
|
329
349
|
pull_errors = {}
|
|
@@ -334,13 +354,57 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
334
354
|
|
|
335
355
|
# Check if already cached
|
|
336
356
|
if check_image_exists_locally(img):
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
340
394
|
|
|
341
395
|
# Need to pull - show progress
|
|
342
396
|
last_status = ["connecting..."]
|
|
343
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
|
+
|
|
344
408
|
with console.status(
|
|
345
409
|
f"[yellow]↓[/yellow] {short_name} [dim]connecting...[/dim]"
|
|
346
410
|
) as status:
|
|
@@ -364,14 +428,21 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
364
428
|
last_error[0] = line
|
|
365
429
|
|
|
366
430
|
success = pull_image_with_progress(
|
|
367
|
-
img, platform=
|
|
431
|
+
img, platform=pull_platform, on_progress=show_progress
|
|
368
432
|
)
|
|
369
433
|
|
|
370
434
|
if success:
|
|
371
|
-
|
|
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]")
|
|
372
443
|
pulled_images.append(img)
|
|
373
444
|
else:
|
|
374
|
-
platform_note = f" (platform: {
|
|
445
|
+
platform_note = f" (platform: {pull_platform})" if pull_platform else ""
|
|
375
446
|
console.print(f" [red]✗[/red] {short_name}{platform_note} [dim](failed)[/dim]")
|
|
376
447
|
if last_error[0]:
|
|
377
448
|
console.print(f" [dim]{last_error[0][:60]}[/dim]")
|
|
@@ -395,8 +466,15 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
395
466
|
console.print()
|
|
396
467
|
console.print("[yellow]Troubleshooting:[/yellow]")
|
|
397
468
|
console.print(" 1. Check Docker is running: docker info")
|
|
398
|
-
|
|
399
|
-
|
|
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")
|
|
400
478
|
console.print()
|
|
401
479
|
console.print("Session remains active - you can retry with: hte-cli session join " + session_id)
|
|
402
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,
|
|
@@ -465,11 +465,7 @@ def run_automated_test(task_id: str, benchmark: str, timeout: int = 300) -> list
|
|
|
465
465
|
f"{HTE_CLI_PATH} session join {session_id}",
|
|
466
466
|
encoding="utf-8",
|
|
467
467
|
timeout=timeout,
|
|
468
|
-
env={
|
|
469
|
-
**os.environ,
|
|
470
|
-
"TERM": "dumb", # Disable colors for easier parsing
|
|
471
|
-
"HTE_NON_INTERACTIVE": "1", # Skip interactive prompts (Docker retry, etc.)
|
|
472
|
-
},
|
|
468
|
+
env={**os.environ, "TERM": "dumb"}, # Disable colors for easier parsing
|
|
473
469
|
)
|
|
474
470
|
|
|
475
471
|
# Log file for debugging AND stream to stdout
|
|
@@ -480,11 +476,36 @@ def run_automated_test(task_id: str, benchmark: str, timeout: int = 300) -> list
|
|
|
480
476
|
child.logfile = TeeWriter(log_file, sys.stdout)
|
|
481
477
|
|
|
482
478
|
try:
|
|
483
|
-
# session join flow:
|
|
484
|
-
#
|
|
485
|
-
console.print("Waiting for session join...")
|
|
486
|
-
idx = child.expect(
|
|
487
|
-
|
|
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
|
|
488
509
|
results.append(TestResult("CLI startup", False, "", "Never got 'Session Joined'"))
|
|
489
510
|
return results
|
|
490
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|