Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions tests/cli/test_cli_artifacts.py

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Mock Artifacts.get always returns bytes — only the raw_response=True path is tested

The mock Artifacts.get in tests/conftest.py:750-765 always returns b'mock artifact content' regardless of the raw_response parameter. This means the tests only exercise the isinstance(result, bytes) branch (line 104) and the _write_to_output bytes path (line 160-161). The Path branch (line 110), the PIL Image fallback (line 124), and the corresponding _write_to_output branches for Path (line 162-165) and PIL Image (line 167-168) are never tested. This is a gap in test coverage for the new feature.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Test artifacts subcommand."""

from __future__ import annotations

from vlmrun.cli.cli import app


def test_get_artifact_by_session_id(runner, mock_client, config_file):
"""Test getting an artifact with --session-id."""
result = runner.invoke(
app,
[
"artifacts",
"get",
"test-object-456",
"--session-id",
"550e8400-e29b-41d4-a716-446655440000",
"--raw",
],
)
assert result.exit_code == 0
assert "bytes" in result.stdout.lower() or "Received" in result.stdout


def test_get_artifact_by_execution_id(runner, mock_client, config_file):
"""Test getting an artifact with --execution-id."""
result = runner.invoke(
app,
[
"artifacts",
"get",
"test-object-456",
"--execution-id",
"exec-001",
"--raw",
],
)
assert result.exit_code == 0


def test_get_artifact_to_output_file(runner, mock_client, config_file, tmp_path):
"""Test saving an artifact to a specific output path."""
out = tmp_path / "artifact.bin"
result = runner.invoke(
app,
[
"artifacts",
"get",
"test-object-456",
"--session-id",
"550e8400-e29b-41d4-a716-446655440000",
"--raw",
"--output",
str(out),
],
)
assert result.exit_code == 0
assert out.exists()
assert out.read_bytes() == b"mock artifact content"


def test_get_artifact_missing_ids(runner, mock_client, config_file):
"""Test that omitting both --session-id and --execution-id fails."""
result = runner.invoke(
app,
["artifacts", "get", "test-object-456"],
)
assert result.exit_code == 1
assert "session-id" in result.stdout.lower() or "Error" in result.stdout


def test_get_artifact_both_ids(runner, mock_client, config_file):
"""Test that providing both --session-id and --execution-id fails."""
result = runner.invoke(
app,
[
"artifacts",
"get",
"test-object-456",
"--session-id",
"sess-001",
"--execution-id",
"exec-001",
],
)
assert result.exit_code == 1
assert "only one" in result.stdout.lower() or "Error" in result.stdout


def test_artifacts_no_args(runner, mock_client, config_file):
"""Test that running `vlmrun artifacts` with no args shows help."""
result = runner.invoke(app, ["artifacts"])
# Typer returns exit code 0 or 2 for no_args_is_help
assert result.exit_code in (0, 2)
168 changes: 168 additions & 0 deletions vlmrun/cli/_cli/artifacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""Artifacts CLI commands — retrieve artifacts by session or execution ID."""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

import typer
from rich.console import Console
from rich.panel import Panel
from rich.text import Text

if TYPE_CHECKING:
from vlmrun.client import VLMRun

app = typer.Typer(
help="Retrieve artifacts generated by chat completions or agent executions.",
add_completion=False,
no_args_is_help=True,
)

console = Console()


@app.command()
def get(
ctx: typer.Context,
object_id: str = typer.Argument(
...,
help="Object ID for the artifact (format: <type>_<6-hex-chars>, e.g. img_a1b2c3)",
),
session_id: str | None = typer.Option(
None,
"--session-id",
"-s",
help="Session ID from chat completions (mutually exclusive with --execution-id)",
),
execution_id: str | None = typer.Option(
None,
"--execution-id",
"-e",
help="Execution ID from agent executions (mutually exclusive with --session-id)",
),
output: Path | None = typer.Option(
None,
"--output",
"-o",
help="Output file path. If omitted, the artifact is saved to the default cache directory.",
),
raw: bool = typer.Option(
False,
"--raw",
help="Return raw bytes instead of converting to the appropriate type.",
),
) -> None:
"""Download an artifact by object ID and session/execution ID."""
client: VLMRun = ctx.obj

if session_id is None and execution_id is None:
console.print(
"[red bold]Error:[/] Either --session-id or --execution-id is required."
)
raise typer.Exit(1)

if session_id is not None and execution_id is not None:
console.print(
"[red bold]Error:[/] Only one of --session-id or --execution-id is allowed, not both."
)
raise typer.Exit(1)

try:
result = client.artifacts.get(
object_id=object_id,
session_id=session_id,
execution_id=execution_id,
raw_response=raw,
)
except Exception as exc:
console.print(f"[red bold]Error:[/] {exc}")
raise typer.Exit(1) from exc

id_label = (
f"session_id={session_id}" if session_id else f"execution_id={execution_id}"
)

if output is not None:
_write_to_output(result, output)
console.print(
Panel(
Text.from_markup(
f"[bold]Object ID:[/] {object_id}\n"
f"[bold]{id_label}[/]\n"
f"[bold]Saved to:[/] [green]{output}[/]"
),
title="[bold]Artifact Downloaded[/bold]",
title_align="left",
border_style="blue",
padding=(0, 1),
)
)
return

# No explicit output — display what the SDK returned
if isinstance(result, bytes):
if output is None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Redundant if output is None: guard is always True — dead code

At line 86, the code checks if output is not None: and returns at line 101. By the time execution reaches line 105, output is guaranteed to be None. The if output is None: check on line 105 is therefore always True and serves no purpose. While it doesn't cause incorrect behavior, it suggests an incomplete refactoring and could mislead future developers into thinking there's a code path where output is not None past line 101.

Suggested change
if output is None:
console.print(
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

console.print(
f"[yellow]Received {len(result)} bytes. "
"Use --output to save to a file.[/]"
)
elif isinstance(result, Path):
console.print(
Panel(
Text.from_markup(
f"[bold]Object ID:[/] {object_id}\n"
f"[bold]{id_label}[/]\n"
f"[bold]Saved to:[/] [green]{result}[/]"
),
title="[bold]Artifact Downloaded[/bold]",
title_align="left",
border_style="blue",
padding=(0, 1),
)
)
else:
# PIL Image or other types — save to cache and report
try:
from vlmrun.constants import VLMRUN_ARTIFACTS_CACHE_DIR

sess_id: str = session_id or execution_id # type: ignore[assignment]
cache_dir = VLMRUN_ARTIFACTS_CACHE_DIR / sess_id
cache_dir.mkdir(parents=True, exist_ok=True)
dest = cache_dir / f"{object_id}.jpg"
result.save(str(dest))
console.print(
Panel(
Text.from_markup(
f"[bold]Object ID:[/] {object_id}\n"
f"[bold]{id_label}[/]\n"
f"[bold]Saved to:[/] [green]{dest}[/]"
),
title="[bold]Artifact Downloaded[/bold]",
title_align="left",
border_style="blue",
padding=(0, 1),
)
)
except Exception:
console.print(f"[green]Artifact retrieved:[/] {type(result).__name__}")


def _write_to_output(result: object, output: Path) -> None:
"""Write an artifact result to the given output path.

Args:
result: The artifact content (bytes, Path, or PIL Image).
output: Destination file path.
"""
output.parent.mkdir(parents=True, exist_ok=True)

if isinstance(result, bytes):
output.write_bytes(result)
elif isinstance(result, Path):
import shutil

shutil.copy2(result, output)
else:
# Assume PIL Image
result.save(str(output))
2 changes: 2 additions & 0 deletions vlmrun/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from rich.text import Text

from vlmrun.client import VLMRun
from vlmrun.cli._cli.artifacts import app as artifacts_app
from vlmrun.cli._cli.chat import CHAT_HELP, chat
from vlmrun.cli._cli.config import app as config_app, resolve_config
from vlmrun.cli._cli.execute import EXECUTE_HELP, execute
Expand Down Expand Up @@ -129,6 +130,7 @@ def main(
app.add_typer(hub_app, name="hub")
app.add_typer(models_app, name="models")
app.add_typer(skills_app, name="skills")
app.add_typer(artifacts_app, name="artifacts")
app.add_typer(config_app, name="config")

if __name__ == "__main__":
Expand Down
Loading