Skip to content
Open
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
Empty file added 26.0.1
Empty file.
51 changes: 50 additions & 1 deletion src/deadline/client/cli/_groups/_job_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ def _resolve_job_search(config: Optional[ConfigParser], search_term: str) -> Opt
return None


def _print_job_details(config: Optional[ConfigParser], job_id: str) -> None:
def _print_job_details(
config: Optional[ConfigParser], job_id: str, show_estimates: bool = False
) -> None:
"""Fetch and print full job details."""
farm_id = config_file.get_setting("defaults.farm_id", config=config)
queue_id = config_file.get_setting("defaults.queue_id", config=config)
Expand All @@ -155,3 +157,50 @@ def _print_job_details(config: Optional[ConfigParser], job_id: str) -> None:
) from exc
response.pop("ResponseMetadata", None)
click.echo(_cli_object_repr(response))
if show_estimates:
est = _estimate_completion_time(response)
click.echo(f"estimatedRemaining: {est if est else 'N/A'}")


def _format_duration(seconds: float) -> str:
"""Format seconds as human-readable duration (e.g., '1 hour, 30 minutes')."""
if seconds < 60:
return f"{int(seconds)} seconds"
minutes = int(seconds // 60)
if minutes < 60:
return f"{minutes} minute{'s' if minutes != 1 else ''}"
hours = minutes // 60
mins = minutes % 60
parts = [f"{hours} hour{'s' if hours != 1 else ''}"]
if mins:
parts.append(f"{mins} minute{'s' if mins != 1 else ''}")
return ", ".join(parts)


def _estimate_completion_time(job: dict) -> Optional[str]:
"""Estimate job completion time based on task progress and elapsed time."""
import datetime

counts = job.get("taskRunStatusCounts", {})
started_at = job.get("startedAt")
if not counts or not started_at:
return None

completed = counts.get("SUCCEEDED", 0) + counts.get("FAILED", 0) + counts.get("CANCELED", 0)
in_progress = counts.get("RUNNING", 0) + counts.get("STARTING", 0) + counts.get("ASSIGNED", 0)
pending = counts.get("PENDING", 0) + counts.get("READY", 0) + counts.get("SCHEDULED", 0)

if completed == 0:
return None
if pending == 0 and in_progress == 0:
return None # Job is done

now = datetime.datetime.now(datetime.timezone.utc)
elapsed = (now - started_at).total_seconds()
if elapsed <= 0:
return None

avg_time_per_task = elapsed / completed
remaining_tasks = in_progress + pending
estimated_remaining = avg_time_per_task * remaining_tasks
return _format_duration(estimated_remaining)
31 changes: 22 additions & 9 deletions src/deadline/client/cli/_groups/job_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
from ._sigint_handler import SigIntHandler
from ...api._session import get_default_client_config
from .._timestamp_formatter import TimestampFormat, TimestampFormatter
from ._job_helpers import _resolve_job_search, _print_job_details
from ._job_helpers import _resolve_job_search, _print_job_details, _estimate_completion_time

logger = logging.getLogger("deadline.client.cli")

Expand Down Expand Up @@ -122,8 +122,13 @@ def cli_job():
@click.option("--queue-id", help="The queue to use.")
@click.option("--page-size", default=5, help="The number of jobs to load at a time.")
@click.option("--item-offset", default=0, help="The index of the job to start listing from.")
@click.option(
"--show-estimates",
is_flag=True,
help="Show estimated time remaining for in-progress jobs. Note: Actual completion times may vary.",
)
@_handle_error
def job_list(page_size, item_offset, **args):
def job_list(page_size, item_offset, show_estimates, **args):
"""
Lists the [Deadline Cloud jobs] in the queue.

Expand Down Expand Up @@ -158,8 +163,9 @@ def job_list(page_size, item_offset, **args):
name_field = "displayName"
if len(response["jobs"]) and "name" in response["jobs"][0]:
name_field = "name"
structured_job_list = [
{
structured_job_list = []
for job in response["jobs"]:
job_entry = {
field: job.get(field, "")
for field in [
name_field,
Expand All @@ -171,8 +177,10 @@ def job_list(page_size, item_offset, **args):
"createdAt",
]
}
for job in response["jobs"]
]
if show_estimates:
est = _estimate_completion_time(job)
job_entry["estimatedRemaining"] = est if est else "N/A"
structured_job_list.append(job_entry)

click.echo(
f"Displaying {len(structured_job_list)} of {total_results} Jobs starting at {item_offset}"
Expand All @@ -187,8 +195,13 @@ def job_list(page_size, item_offset, **args):
@click.option("--farm-id", help="The farm to use.")
@click.option("--queue-id", help="The queue to use.")
@click.option("--job-id", help="The job to get.")
@click.option(
"--show-estimates",
is_flag=True,
help="Show estimated time remaining for in-progress jobs. Note: Actual completion times may vary.",
)
@_handle_error
def job_get(search_term: Optional[str], **args):
def job_get(search_term: Optional[str], show_estimates: bool, **args):
"""
Get the details of a [Deadline Cloud job], or search for jobs with a search term.

Expand All @@ -208,7 +221,7 @@ def job_get(search_term: Optional[str], **args):
config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args)
found_job_id = _resolve_job_search(config, search_term)
if found_job_id:
_print_job_details(config, found_job_id)
_print_job_details(config, found_job_id, show_estimates)
else:
# Get job by ID (from arg or config default)
config = _apply_cli_options_to_config(
Expand All @@ -217,7 +230,7 @@ def job_get(search_term: Optional[str], **args):
job_id = config_file.get_setting("defaults.job_id", config=config)
if job_id is None:
raise DeadlineOperationError("Missing job ID. Provide a job ID or search term.")
_print_job_details(config, job_id)
_print_job_details(config, job_id, show_estimates)


@cli_job.command(name="cancel")
Expand Down
67 changes: 67 additions & 0 deletions test/unit/deadline_client/cli/test_cli_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Tests for the CLI job commands.
"""

from datetime import timezone
import datetime
import json
import os
Expand All @@ -26,6 +27,10 @@
_get_json_line,
_get_download_summary_message,
)
from deadline.client.cli._groups._job_helpers import (
_format_duration,
_estimate_completion_time,
)
from deadline.client.exceptions import DeadlineOperationError, DeadlineOperationTimedOut
from deadline.job_attachments.models import (
FileConflictResolution,
Expand Down Expand Up @@ -1665,3 +1670,65 @@ def test_cli_job_download_output_with_session_action_id(fresh_deadline_config):
session_action_id=MOCK_SESSION_ACTION_ID,
session=ANY,
)


class TestEstimateCompletionTime:
def test_format_duration_seconds(self):
assert _format_duration(30) == "30 seconds"

def test_format_duration_minutes(self):
assert _format_duration(120) == "2 minutes"
assert _format_duration(60) == "1 minute"

def test_format_duration_hours(self):
assert _format_duration(3600) == "1 hour"
assert _format_duration(5400) == "1 hour, 30 minutes"

def test_estimate_completion_time_no_tasks(self):
job = {"taskRunStatusCounts": {}, "startedAt": None}
assert _estimate_completion_time(job) is None

def test_estimate_completion_time_completed_job(self):
job = {
"taskRunStatusCounts": {"SUCCEEDED": 10, "RUNNING": 0, "READY": 0},
"startedAt": datetime.datetime.now(timezone.utc),
}
assert _estimate_completion_time(job) is None


def test_cli_job_list_with_show_estimates(fresh_deadline_config):
"""
Confirm that the CLI interface prints estimated time remaining
when --show-estimates flag is used with a running job.
"""
config.set_setting("defaults.farm_id", MOCK_FARM_ID)
config.set_setting("defaults.queue_id", MOCK_QUEUE_ID)

mock_job_with_task_counts = {
"jobId": "job-aaf4cdf8aae242f58fb84c5bb19f199b",
"name": "CLI Job",
"taskRunStatus": "RUNNING",
"lifecycleStatus": "CREATE_COMPLETE",
"createdBy": "b801f3c0-c071-70bc-b869-6804bc732408",
"createdAt": datetime.datetime(2023, 1, 27, 7, 34, 41, tzinfo=tzutc()),
"startedAt": datetime.datetime(2023, 1, 27, 7, 37, 53, tzinfo=tzutc()),
"priority": 50,
"taskRunStatusCounts": {
"SUCCEEDED": 5,
"RUNNING": 2,
"READY": 3,
},
}

with patch.object(api._session, "get_boto3_session") as session_mock:
session_mock().client("deadline").search_jobs.return_value = {
"jobs": [mock_job_with_task_counts],
"totalResults": 1,
"itemOffset": 1,
}

runner = CliRunner()
result = runner.invoke(main, ["job", "list", "--show-estimates"])

assert result.exit_code == 0
assert "estimatedRemaining:" in result.output