diff --git a/docs/cli_reference/deadline_browse.md b/docs/cli_reference/deadline_browse.md new file mode 100644 index 000000000..69f384152 --- /dev/null +++ b/docs/cli_reference/deadline_browse.md @@ -0,0 +1,7 @@ +# Deadline CLI Reference + +::: mkdocs-click + :module: deadline.client.cli._groups.browse_group + :command: cli_browse + :prog_name: deadline browse + :depth: 1 diff --git a/docs/design/cli-browser-README.md b/docs/design/cli-browser-README.md new file mode 100644 index 000000000..81f630f92 --- /dev/null +++ b/docs/design/cli-browser-README.md @@ -0,0 +1,201 @@ +# Deadline Job TUI - User Guide + +An interactive terminal UI for browsing jobs, steps, tasks, sessions, and attachments in AWS Deadline Cloud. + +## Quick Start + +```bash +# Interactive job/step/task browser +deadline job tui + +# Direct job attachment browser (browse files of a specific job) +deadline browse --job-id job-abc123def456 +``` + +## Job List Screen + +When you run `deadline job tui`, you see a paginated list of recent jobs. The page size adapts to your terminal height. + +``` +╭─────────────────── Deadline Job TUI ───────────────────────╮ +│ 🎬 Jobs Queue: queue-xx│ +╰────────────────────────────────────────────────────────────╯ + +▶ ✓ SUCCEEDED My Render Job 2m ago ...abc123 + ● RUNNING Animation Batch 10m ago ...def456 + ✗ FAILED Test Job 1h ago ...ghi789 +``` + +### Controls +| Key | Action | +|-----|--------| +| ↑/↓ | Move selection up/down | +| →/Enter | Open step list for selected job | +| J | Browse job attachments | +| c | Copy full job ID to clipboard | +| n/p | Next/previous page | +| r | Refresh | +| q | Quit | + +## Step List Screen + +After selecting a job, browse its steps: + +``` +╭─────────────────── Deadline Job TUI ───────────────────────╮ +│ 🎬 My Render Job ✓ SUCCEEDED │ +╰────────────────────────────────────────────────────────────╯ +📍 Steps + +▶ ✓ SUCCEEDED Render Frames 120 tasks step-aaa111 + ✓ SUCCEEDED Composite 1 task step-bbb222 + ✗ FAILED Cleanup 3 tasks step-ccc333 +``` + +### Controls +| Key | Action | +|-----|--------| +| ↑/↓ | Move selection up/down | +| →/Enter | Open task list for selected step | +| ←/Esc | Back to job list | +| c | Copy full step ID to clipboard | +| n/p | Next/previous page | +| r | Refresh | +| q | Quit | + +## Task List Screen + +After selecting a step, browse its tasks: + +``` +╭─────────────────── Deadline Job TUI ───────────────────────╮ +│ 🎬 My Render Job › Render Frames ✓ SUCCEEDED │ +╰────────────────────────────────────────────────────────────╯ +📍 Tasks + +▶ ✓ SUCCEEDED Frame=1 task-001 + ✓ SUCCEEDED Frame=2 task-002 + ✗ FAILED Frame=3 task-003 +``` + +### Controls +| Key | Action | +|-----|--------| +| ↑/↓ | Move selection up/down | +| ←/Esc | Back to step list | +| l | List sessions for selected task | +| j | Browse task attachments | +| c | Copy full task ID to clipboard | +| n/p | Next/previous page | +| r | Refresh | +| q | Quit | + +## Session List + +Press `l` on a task to see its sessions: + +``` +╭─────────────────── Deadline Job TUI ───────────────────────╮ +│ 📋 Sessions for Task Frame=3 │ +╰────────────────────────────────────────────────────────────╯ + +▶ ● ENDED session-aaa worker-xxx 2m ago + ● ENDED session-bbb worker-yyy 5m ago +``` + +### Controls +| Key | Action | +|-----|--------| +| ↑/↓ | Move selection | +| Enter | Show session details | +| Esc | Back to task list | +| q | Quit | + +## Attachment Browser + +Press `J` on a job or `j` on a task to browse input/output file attachments: + +``` +╭─────────────────── Deadline Job TUI ───────────────────────╮ +│ 🎬 My Render Job ✓ SUCCEEDED │ +╰────────────────────────────────────────────────────────────╯ +📍 Job › output › /renders + +▶ 💾 /renders/frames 120 files + 📁 textures 45 files + 📄 scene.blend 2.4 MB + 🖼️ preview.png 156 KB +``` + +### Controls +| Key | Action | +|-----|--------| +| ↑/↓ | Move selection | +| ←/→/Enter | Navigate file tree | +| d | Download file or folder | +| i | Show file info | +| v | Open image in system viewer | +| m | List all manifests | +| Esc | Back to job/task list | +| q | Quit | + +### Downloading Files + +Press `d` on any file or folder: + +``` +Download to: /home/user/downloads +Downloading... ━━━━━━━━━━━━━━━━━━━━ 100% +✅ Downloaded 120 files to /home/user/downloads +``` + +## File Icons + +| Icon | Type | +|------|------| +| 📦 | Category (input/output) | +| 💾 | Manifest root path | +| 📁 | Folder | +| 📄 | File | +| 🖼️ | Image file | +| 📝 | Text/log file | + +## Options + +```bash +# Job TUI (hierarchical browser) +deadline job tui [OPTIONS] + +Options: + --farm-id TEXT Override default farm + --queue-id TEXT Override default queue + --profile TEXT AWS profile to use + +# Direct attachment browser (existing) +deadline browse [OPTIONS] + +Options: + --job-id TEXT Job ID to browse directly + --farm-id TEXT Override default farm + --queue-id TEXT Override default queue + --profile TEXT AWS profile to use +``` + +## Examples + +```bash +# Browse jobs interactively, drill into steps and tasks +deadline job tui + +# Use a specific farm/queue +deadline job tui --farm-id farm-xxx --queue-id queue-yyy + +# Directly browse a specific job's attachments +deadline browse --job-id job-f2bb8e71194c418db5a57b45590638d5 +``` + +## Requirements + +- Interactive terminal (TTY) +- AWS credentials configured +- Default farm and queue set in Deadline config (or provided via options) diff --git a/docs/design/cli-browser.md b/docs/design/cli-browser.md new file mode 100644 index 000000000..b66ec2abf --- /dev/null +++ b/docs/design/cli-browser.md @@ -0,0 +1,718 @@ +# CLI Job TUI Design + +## Overview + +Interactive TUI (Terminal User Interface) for browsing Deadline Cloud jobs, steps, tasks, sessions, and job attachments. Uses the `rich` library for terminal rendering. Provides two entry points: + +1. `deadline job tui` — hierarchical browser: jobs → steps → tasks, with session listing and attachment browsing +2. `deadline browse --job-id ` — direct job attachment browser (existing, unchanged) + +## Commands + +```bash +# Hierarchical job/step/task browser +deadline job tui [--farm-id] [--queue-id] [--profile] + +# Direct job attachment browser (existing entry point, kept as-is) +deadline browse [--job-id ] [--farm-id] [--queue-id] [--profile] +``` + +## Navigation Model + +``` +deadline job tui + │ + ▼ +┌─────────┐ Enter ┌──────────┐ Enter ┌──────────┐ +│ Job List │ ───────► │ Step List│ ───────► │ Task List│ +└─────────┘ Esc ◄── └──────────┘ Esc ◄── └──────────┘ + │ │ │ + │ a (job attachments) l │ │ a (task attachments) + ▼ ▼ │ ▼ +┌──────────────────┐ ┌──────────┐│ ┌──────────────────┐ +│ Job Attachment │ │ Session ││ │ Task Attachment │ +│ Browser (in/out) │ │ List ││ │ Browser (in/out) │ +└──────────────────┘ └──────────┘│ └──────────────────┘ + Esc back to Job List │ Esc back to Task List + │ + Esc back to Task List +``` + +## Pagination + +All list screens (jobs, steps, tasks) use the same pagination strategy: + +1. On launch, detect terminal height and compute `page_size = terminal_lines - chrome_lines` (header + help bar + padding, ~8 lines) +2. Fetch exactly `page_size` items per page using the search/list API +3. `n`/`p` keys move between pages, each page triggers a fresh API call +4. Cursor wraps within the current page only +5. ←/→ arrows navigate the hierarchy (→ goes deeper, ← goes back) + +For jobs, use `search_jobs` with `pageSize` and `itemOffset`. +For steps, use `list_steps` with `maxResults` and `nextToken`. +For tasks, use `list_tasks` with `maxResults` and `nextToken`. + +## UI Screens + +### Job List Screen + +Jobs have two status fields: +- `taskRunStatus` — aggregate task run status (PENDING, READY, ASSIGNED, STARTING, SCHEDULED, INTERRUPTING, RUNNING, SUSPENDED, CANCELED, FAILED, SUCCEEDED, NOT_COMPATIBLE) +- `targetTaskRunStatus` — the target status the job is transitioning to (READY, FAILED, SUCCEEDED, CANCELED, SUSPENDED, PENDING). Only shown when set and differs from `taskRunStatus`. + +The TUI shows `taskRunStatus` as the primary indicator. When `targetTaskRunStatus` is set and differs from `taskRunStatus`, it's shown as a secondary arrow badge (e.g. `→CANCELED`). + +``` +╭─────────────────── Deadline Job TUI ───────────────────────╮ +│ 🎬 Jobs Queue: queue-xx│ +╰────────────────────────────────────────────────────────────╯ + +▶ ✓ SUCCEEDED My Render Job 2m ago job-abc123 + ● RUNNING Animation Batch →CANCELED 10m ago job-def456 + ✗ FAILED Test Job 1h ago job-ghi789 + ✓ SUCCEEDED Scene Export 2h ago job-jkl012 + + Page 1/5 (1-15 of 73) + +╭────────────────────────────────────────────────────────────╮ +│ ↑↓ nav →/Enter steps J attachments n/p page r refresh q quit │ +╰────────────────────────────────────────────────────────────╯ +``` + +| Key | Action | +|-----|--------| +| ↑/↓ | Move cursor | +| →/Enter | Open step list for selected job | +| a | Browse job attachments (input + output) | +| c | Copy full job ID to clipboard | +| n/p | Next/previous page | +| r | Refresh current page | +| q | Quit TUI | + +### Step List Screen + +Steps have three status fields: +- `taskRunStatus` — aggregate task run status (same values as job: PENDING, READY, ASSIGNED, STARTING, SCHEDULED, INTERRUPTING, RUNNING, SUSPENDED, CANCELED, FAILED, SUCCEEDED, NOT_COMPATIBLE) +- `targetTaskRunStatus` — the target status the step is transitioning to (READY, FAILED, SUCCEEDED, CANCELED, SUSPENDED, PENDING). Only shown when set and differs from `taskRunStatus`. +- `lifecycleStatus` — step lifecycle (CREATE_COMPLETE, UPDATE_IN_PROGRESS, UPDATE_FAILED, UPDATE_SUCCEEDED) + +The TUI shows `taskRunStatus` as the primary indicator. When `targetTaskRunStatus` is set and differs from `taskRunStatus`, it's shown as an arrow badge (e.g. `→CANCELED`). When `lifecycleStatus` is not CREATE_COMPLETE, it's shown as a secondary bracket badge. + +``` +╭─────────────────── Deadline Job TUI ───────────────────────╮ +│ 🎬 My Render Job ✓ SUCCEEDED │ +╰────────────────────────────────────────────────────────────╯ +📍 Steps + +▶ ✓ SUCCEEDED Render Frames 120 tasks step-aaa111 + ● RUNNING Composite →CANCELED 1 task step-bbb222 + ✗ FAILED Cleanup [UPD_FAIL] 3 tasks step-ccc333 + +IDs are displayed as short hashes (e.g. `step-aaa111`) like git short hashes, truncated from the full 32-char hex ID. + + Page 1/1 (1-3 of 3) + +╭────────────────────────────────────────────────────────────╮ +│ ↑↓ nav →/Enter tasks ←/Esc back n/p page r refresh q quit │ +╰────────────────────────────────────────────────────────────╯ +``` + +Lifecycle status styling: +| lifecycleStatus | Badge | Color | +|-----------------|-------|-------| +| CREATE_COMPLETE | (hidden) | — | +| UPDATE_IN_PROGRESS | [UPDATING] | yellow | +| UPDATE_FAILED | [UPD_FAIL] | red | +| UPDATE_SUCCEEDED | [UPDATED] | green | + +| Key | Action | +|-----|--------| +| ↑/↓ | Move cursor | +| →/Enter | Open task list for selected step | +| ←/Esc | Back to job list | +| c | Copy full step ID to clipboard | +| n/p | Next/previous page | +| r | Refresh | +| q | Quit | + +### Task List Screen + +Tasks have two status fields: +- `runStatus` — current task status (PENDING, READY, ASSIGNED, STARTING, SCHEDULED, INTERRUPTING, RUNNING, SUSPENDED, CANCELED, FAILED, SUCCEEDED, NOT_COMPATIBLE) +- `targetRunStatus` — the target status the task is transitioning to (READY, FAILED, SUCCEEDED, CANCELED, SUSPENDED, PENDING). Only shown when set and differs from `runStatus`. + +The TUI shows `runStatus` as the primary indicator. When `targetRunStatus` is set and differs from `runStatus`, it's shown as a secondary arrow badge (e.g. `→CANCELED`). + +``` +╭─────────────────── Deadline Job TUI ───────────────────────╮ +│ 🎬 My Render Job › Render Frames ✓ SUCCEEDED │ +╰────────────────────────────────────────────────────────────╯ +📍 Tasks + +▶ ✓ SUCCEEDED Frame=1 task-001 + ✓ SUCCEEDED Frame=2 task-002 + ✗ FAILED Frame=3 task-003 + ● RUNNING Frame=4 →CANCELED task-004 + + Page 1/8 (1-15 of 120) + +╭────────────────────────────────────────────────────────────╮ +│ ↑↓ nav ←/Esc back l sessions j attachments n/p page q quit │ +╰────────────────────────────────────────────────────────────╯ +``` + +| Key | Action | +|-----|--------| +| ↑/↓ | Move cursor | +| ←/Esc | Back to step list | +| l | List sessions for selected task | +| a | Browse task attachments (input + output) | +| c | Copy full task ID to clipboard | +| n/p | Next/previous page | +| r | Refresh | +| q | Quit | + +### Session List Screen + +Shown as an overlay/sub-screen when pressing `l` on a task. Lists all sessions +that ran this task by filtering `list_session_actions` for the task's step+task, +then resolving unique session IDs. + +``` +╭─────────────────── Deadline Job TUI ───────────────────────╮ +│ 📋 Sessions for Task Frame=3 │ +╰────────────────────────────────────────────────────────────╯ + +▶ ● ENDED session-aaa worker-xxx 2m ago + ● ENDED session-bbb worker-yyy 5m ago + +╭────────────────────────────────────────────────────────────╮ +│ ↑↓ nav Enter details Esc back q quit │ +╰────────────────────────────────────────────────────────────╯ +``` + +| Key | Action | +|-----|--------| +| ↑/↓ | Move cursor | +| Enter | Show session details (status, worker, timestamps) | +| Esc | Back to task list | +| q | Quit | + +### Attachment Browser Screen + +Reuses the existing `JobBrowserTUI` file tree for manifest browsing. Entered via: +- `a` from job list → shows all job-level input + output attachments +- `a` from task list → shows task-level input + output attachments (passes step_id + task_id for scoped output manifests) + +``` +╭─────────────────── Deadline Job TUI ───────────────────────╮ +│ 🎬 My Render Job ✓ SUCCEEDED │ +╰────────────────────────────────────────────────────────────╯ +📍 Job › output › /renders + +▶ 💾 /renders/frames 120 files + 📁 textures 45 files + 📄 scene.blend 2.4 MB + 🖼️ preview.png 156 KB + +╭────────────────────────────────────────────────────────────╮ +│ ←→↑↓ nav Enter open d download i info v view Esc back q quit │ +╰────────────────────────────────────────────────────────────╯ +``` + +| Key | Action | +|-----|--------| +| ↑/↓ | Move cursor | +| ←/→/Enter | Navigate tree | +| d | Download file or folder | +| i | File info | +| v | Open image in system viewer | +| m | List all manifests | +| Esc | Back to job/step/task list | +| q | Quit | + +## Data Flow + +### Job List +``` +1. Detect terminal height, compute page_size +2. search_jobs(farmId, queueIds, pageSize, itemOffset, sortBy=CREATED_AT DESC) +3. Render page, wait for input +4. n/p pages through results, re-fetches +``` + +### Step List +``` +1. list_steps(farmId, queueId, jobId, maxResults=page_size) +2. Paginate with nextToken on n/p +``` + +### Task List +``` +1. list_tasks(farmId, queueId, jobId, stepId, maxResults=page_size) +2. Paginate with nextToken on n/p +3. Display task parameters as summary (e.g. "Frame=1") +``` + +### Session List (for a task) +``` +1. list_session_actions(farmId, queueId, jobId, sessionId=...) + — OR filter sessions by iterating list_sessions and matching task actions +2. For each session: get_session() for details (worker, status, timestamps) +``` + +### Job Attachments (J from job list) +``` +1. get_job() for attachments metadata +2. get_queue() for jobAttachmentSettings → S3Settings +3. get_queue_user_boto3_session() for role session +4. load_input_manifests() from job.attachments.manifests[].inputManifestPath +5. load_output_manifests() via get_output_manifests_by_asset_root() +6. Merge manifests, build tree, launch attachment browser +7. Esc returns to job list +``` + +### Task Attachments (j from task list) +``` +1. Same as job attachments but scoped: + - Input manifests: same as job-level (tasks share job inputs) + - Output manifests: get_output_manifests_by_asset_root() with step_id + task_id + to scope to that task's outputs only +2. Esc returns to task list +``` + +## Icons + +| Type | Icon | +|------|------| +| Category (input/output) | 📦 | +| Manifest Root | 💾 | +| Folder | 📁 | +| File | 📄 | +| Image | 🖼️ | +| Text/Log | 📝 | +| Script | 📜 | + +## Dependencies + +- `rich` — terminal rendering (already in project dependencies) + +## Implementation + +### File Structure + +- `src/deadline/client/cli/_groups/job_group.py` — add `tui` subcommand to existing `cli_job` group +- `src/deadline/client/cli/_groups/browse_group.py` — keep existing `deadline browse` entry point unchanged + +The TUI classes live in a new module: +``` +src/deadline/client/cli/_groups/_job_tui/ +├── __init__.py +├── _common.py # Shared rendering utils (render_header, render_help_bar, format_size, etc.) +├── _job_list.py # JobListTUI — paginated job browser +├── _step_list.py # StepListTUI — paginated step browser +├── _task_list.py # TaskListTUI — paginated task browser +├── _session_list.py # SessionListTUI — session list for a task +└── _attachment_browser.py # AttachmentBrowserTUI — file tree browser (refactored from browse_group) +``` + +### Registration + +Add to `job_group.py`: +```python +@cli_job.command(name="tui") +@click.option("--profile", help="The AWS profile to use.") +@click.option("--farm-id", help="The farm to use.") +@click.option("--queue-id", help="The queue to use.") +@_handle_error +def job_tui(**args): + """ + Interactive TUI for browsing jobs, steps, tasks, sessions, and attachments. + """ + ... +``` + +### Pseudo Code + +```python +# === _common.py === + +console = Console() + +def get_terminal_page_size(chrome_lines: int = 8) -> int: + """Compute how many list items fit on screen.""" + return max(5, console.height - chrome_lines) + +def render_header(title: str, subtitle: str = "") -> None: ... +def render_help_bar(keys: list[tuple[str, str]]) -> None: ... +def format_size(size: int) -> str: ... +def format_time_ago(dt: datetime) -> str: ... +def get_status_style(status: str) -> tuple[str, str]: + """ + Return (color, icon) for all taskRunStatus values: + SUCCEEDED → green, ✓ + RUNNING → yellow, ● + STARTING → yellow, ● + SCHEDULED → yellow, ● + ASSIGNED → yellow, ● + PENDING → blue, ○ + READY → cyan, ◉ + INTERRUPTING → yellow, ⚡ + SUSPENDED → magenta, ⏸ + CANCELED → red, ✗ + FAILED → red, ✗ + NOT_COMPATIBLE → red, ⚠ + unknown → dim, ○ + """ + +def read_key() -> str: + """Read a single keypress, returning normalized key name.""" + + +# === _job_list.py === + +class JobListTUI: + def __init__(self, farm_id: str, queue_id: str, deadline_client, config): + self.page_size = get_terminal_page_size() + ... + + def load_page(self) -> None: + """Fetch one page of jobs via search_jobs(pageSize=self.page_size, itemOffset=...).""" + + def render(self) -> None: + """ + Render job list with: + - taskRunStatus icon+color (primary) + - targetTaskRunStatus arrow badge when set and differs from taskRunStatus (e.g. →CANCELED) + - name, time ago, short job ID + """ + + def run(self) -> Optional[tuple[str, str]]: + """ + Main loop. Returns: + - ("select", job_id) when Enter pressed + - ("attachments", job_id) when J pressed + - None when q pressed + """ + + +# === _step_list.py === + +class StepListTUI: + def __init__(self, farm_id: str, queue_id: str, job_id: str, job_name: str, + job_status: str, deadline_client): + self.page_size = get_terminal_page_size() + ... + + def load_page(self) -> None: + """Fetch one page of steps via list_steps(maxResults=self.page_size, nextToken=...).""" + + def render(self) -> None: + """ + Render step list with: + - taskRunStatus icon+color (primary) + - targetTaskRunStatus arrow badge when set and differs (e.g. →CANCELED) + - lifecycleStatus badge when not CREATE_COMPLETE (secondary) + - name, task count, step ID + """ + + def run(self) -> Optional[tuple[str, str]]: + """ + Returns: + - ("select", step_id) when Enter pressed + - None/("back", "") when Esc pressed + """ + + +# === _task_list.py === + +class TaskListTUI: + def __init__(self, farm_id: str, queue_id: str, job_id: str, job_name: str, + step_id: str, step_name: str, deadline_client): + self.page_size = get_terminal_page_size() + ... + + def load_page(self) -> None: + """Fetch one page of tasks via list_tasks(maxResults=self.page_size, nextToken=...).""" + + def render(self) -> None: + """ + Render task list with: + - runStatus icon+color (primary, uses same get_status_style as jobs) + - targetRunStatus arrow badge when set and differs from runStatus (e.g. →CANCELED) + - parameter summary (e.g. "Frame=1"), task ID + """ + + def run(self) -> Optional[tuple[str, str]]: + """ + Returns: + - ("sessions", task_id) when l pressed + - ("attachments", task_id) when j pressed + - None/("back", "") when Esc pressed + """ + + +# === _session_list.py === + +class SessionListTUI: + def __init__(self, farm_id: str, queue_id: str, job_id: str, + step_id: str, task_id: str, deadline_client): + ... + + def load_sessions(self) -> None: + """ + Find sessions for this task: + 1. list_sessions(farmId, queueId, jobId) + 2. For each session, list_session_actions and filter for matching stepId+taskId + 3. Collect unique sessions + """ + + def render(self) -> None: + """Render session list with status, session ID, worker ID, time.""" + + def run(self) -> None: + """Browse sessions. Enter shows detail panel. Esc returns.""" + + +# === _attachment_browser.py === + +class AttachmentBrowserTUI: + """ + Refactored from existing JobBrowserTUI in browse_group.py. + Adds Esc-to-return behavior and optional step_id/task_id scoping. + """ + def __init__(self, farm_id: str, queue_id: str, job_id: str, + job_name: str, job_status: str, + boto3_session, queue_role_session, s3_settings, + step_id: Optional[str] = None, + task_id: Optional[str] = None): + ... + + def load_manifests(self) -> None: + """ + Load input manifests (always job-level). + Load output manifests: + - If step_id/task_id provided: scope to task outputs + - Otherwise: all job outputs + """ + + def run(self) -> None: + """File tree browser. Esc returns to caller.""" + + +# === Entry point in job_group.py === + +@cli_job.command(name="tui") +def job_tui(**args): + """ + 1. Build config, get farm_id/queue_id + 2. Create deadline client + 3. Loop: + a. Show JobListTUI + b. On ("select", job_id): show StepListTUI + - On ("select", step_id): show TaskListTUI + - On ("sessions", task_id): show SessionListTUI, then return to TaskListTUI + - On ("attachments", task_id): show AttachmentBrowserTUI(step_id, task_id), then return + - On Esc: return to StepListTUI + - On Esc: return to JobListTUI + c. On ("attachments", job_id): show AttachmentBrowserTUI(job-level), then return to JobListTUI + d. On quit: exit + """ +``` + +## Screen Rendering & Clearing + +The TUI uses a two-mode screen clearing strategy to balance flicker-free rendering with clean screen transitions. + +### The Problem + +Terminal UIs face a tradeoff: erasing the entire screen before each frame prevents stale content but causes visible flicker. Only repositioning the cursor and overwriting in-place eliminates flicker but leaves stale lines when transitioning between screens of different lengths (e.g. a 50-job list → a 3-step list leaves 47 ghost lines). + +### Two-Mode Approach + +`clear_screen(full)` in `_common.py` implements both modes: + +- **Soft clear** (`full=False`): Writes `\033[H` (cursor home) only. Content overwrites in-place with zero flicker. Used for same-screen re-renders (scrolling, cursor movement, page changes within the same list). +- **Hard clear** (`full=True`): Writes `\033[H\033[2J` (cursor home + erase entire screen). Used when transitioning between different screens (job list → step list, step list → task list, back navigation) to prevent stale content from the previous screen bleeding through. + +### The `_needs_full_clear` Flag + +Each TUI class (`JobListTUI`, `StepListTUI`, `TaskListTUI`, `SessionListTUI`, `AttachmentBrowserTUI`) manages a `_needs_full_clear: bool` instance flag: + +1. Set to `True` in `__init__()` (initial construction). +2. Set to `True` at the top of `run()` (re-entering the screen after returning from a child screen). +3. Passed to `clear_screen(full=self._needs_full_clear)` at the start of `render()`. +4. Immediately set to `False` after the `clear_screen()` call in `render()`, so subsequent frames within the same screen use soft clear. + +```python +# Pattern used in every TUI class: +def render(self) -> None: + clear_screen(full=self._needs_full_clear) + self._needs_full_clear = False + # ... render content ... + +def run(self) -> ...: + self._needs_full_clear = True # hard clear on first frame + self.load_page() + while True: + self.render() # first call uses hard clear, rest use soft clear + key = read_key() + # ... handle input ... +``` + +### Attachment Browser Folder Navigation + +The `AttachmentBrowserTUI` applies the same two-mode clearing to folder transitions within the file tree. When the user navigates into a subfolder (`→`/`Enter` on a non-file node) or back out (`←` to parent), `_needs_full_clear` is set to `True` before the next render. This prevents stale file listings from a longer folder bleeding through when entering a shorter folder — the same class of bug that screen transitions between TUI screens solve. + +Scrolling within the same folder (↑/↓) continues to use soft clear for flicker-free rendering. + +### Erase-Below in Help Bar + +`render_help_bar()` writes `\033[J` (erase from cursor to end of screen) after rendering the help panel. This cleans up leftover lines from a previous longer frame without requiring a full-screen erase — for example, when the current page has fewer items than the previous page on the same screen. + +### Terminal Width Constraint + +All `Panel` and `Table` widgets are constrained to `width=console.width - 1` to prevent terminal line wrapping, which would cause rendering artifacts and misaligned content. + +### Cursor Hiding & Alternate Screen Buffer + +- `enter_alt_screen()` switches to the terminal's alternate screen buffer and hides the cursor (`\033[?1049h\033[?25l`). This preserves the user's scrollback history and prevents a blinking cursor from distracting during TUI operation. +- `leave_alt_screen()` restores the cursor and returns to the main screen buffer (`\033[?25h\033[?1049l`). + +These are called by the top-level orchestrator in `job_group.py` around the entire TUI session, not by individual screens. + +### Function Size Limits + +All functions must be ≤75 lines. Complex logic should be split: +- `load_page()` handles API call only +- `render()` delegates to `render_header()` + list table + `render_help_bar()` +- `run()` delegates to `render()` + `read_key()` + action dispatch + +### Shared Code with `deadline browse` + +The existing `browse_group.py` keeps its `cli_browse` entry point and `JobBrowserTUI` unchanged. +The new `_attachment_browser.py` refactors the tree/manifest/download logic into a reusable module +that both `browse_group.py` and `job tui` can import. Common utilities (icons, formatting, rendering) +move to `_common.py`. + +## Implementation Plan (Sub-Agent Tasks) + +The implementation is split into 5 sequential tasks. Each task builds on the previous one. +Run them in order — later tasks depend on earlier ones being complete. + +### Task 1: Common utilities and key input (`_common.py`) + +Create `src/deadline/client/cli/_groups/_job_tui/__init__.py` and `_common.py`. + +Extract and refactor shared utilities from `browse_group.py` into `_common.py`: +- `console` — shared Rich Console instance +- `get_terminal_page_size(chrome_lines=8) -> int` — compute page size from terminal height +- `get_status_style(status) -> tuple[str, str]` — color+icon for all 12 taskRunStatus values +- `get_lifecycle_badge(status) -> Optional[tuple[str, str]]` — badge+color for step lifecycleStatus +- `format_size(size) -> str` — bytes to human readable +- `format_time_ago(dt) -> str` — datetime to relative time +- `format_short_id(full_id) -> str` — truncate `xxx-<32hex>` to `xxx-<6hex>` like git short hash +- `copy_to_clipboard(text) -> bool` — copy to clipboard using `pbcopy` (macOS), `xclip` (Linux), `clip` (Windows) +- `render_header(title, subtitle)` — Rich Panel header +- `render_help_bar(keys)` — Rich Panel help bar +- `read_key() -> str` — read single keypress, normalize arrow keys to `"up"`, `"down"`, `"left"`, `"right"`, escape to `"esc"`, etc. + +Update `browse_group.py` to import from `_common.py` instead of defining its own copies. +Add unit tests for all pure functions (format_size, format_time_ago, get_status_style, format_short_id, get_lifecycle_badge). + +Files created/modified: +- `src/deadline/client/cli/_groups/_job_tui/__init__.py` (new) +- `src/deadline/client/cli/_groups/_job_tui/_common.py` (new) +- `src/deadline/client/cli/_groups/browse_group.py` (modified — import from _common) +- `test/unit/deadline_client/cli/groups/job_tui/test_common.py` (new) + +### Task 2: Job list TUI (`_job_list.py`) + +Create `_job_list.py` with `JobListTUI` class: +- `__init__(farm_id, queue_id, deadline_client, config)` — store params, compute page_size +- `load_page()` — call `search_jobs(pageSize=page_size, itemOffset=page*page_size, sortBy=CREATED_AT DESC)`, store jobs + total count +- `render()` — clear screen, render_header, render job table (status icon, target badge, name, time_ago, short ID), pagination info, render_help_bar +- `run() -> Optional[tuple[str, str]]` — main loop: render, read_key, dispatch: + - ↑/↓ move cursor + - →/Enter returns `("select", job_id)` + - `a` returns `("attachments", job_id)` + - `c` copies full job ID to clipboard + - `n`/`p` next/prev page + - `r` refresh + - `q` returns None + +Add unit tests mocking the deadline client to verify page loading, cursor movement, and return values. + +Files created: +- `src/deadline/client/cli/_groups/_job_tui/_job_list.py` (new) +- `test/unit/deadline_client/cli/groups/job_tui/test_job_list.py` (new) + +### Task 3: Step list and task list TUIs (`_step_list.py`, `_task_list.py`) + +Create `_step_list.py` with `StepListTUI` class: +- `__init__(farm_id, queue_id, job_id, job_name, job_status, deadline_client)` +- `load_page()` — call `list_steps(maxResults=page_size, nextToken=...)`, store steps + tokens +- `render()` — header with job name+status, step table (taskRunStatus icon, target badge, lifecycle badge, name, task count, short ID), pagination, help bar +- `run() -> Optional[tuple[str, str]]` — →/Enter returns `("select", step_id)`, ←/Esc returns `("back", "")`, `c` copies step ID, `n`/`p` pages, `r` refresh, `q` quit + +Create `_task_list.py` with `TaskListTUI` class: +- `__init__(farm_id, queue_id, job_id, job_name, step_id, step_name, deadline_client)` +- `load_page()` — call `list_tasks(maxResults=page_size, nextToken=...)`, store tasks + tokens +- `render()` — header with job›step name, task table (runStatus icon, target badge, parameter summary, short ID), pagination, help bar +- `run() -> Optional[tuple[str, str]]` — `l` returns `("sessions", task_id)`, `a` returns `("attachments", task_id)`, ←/Esc returns `("back", "")`, `c` copies task ID, `n`/`p` pages, `r` refresh, `q` quit + +Add unit tests for both classes. + +Files created: +- `src/deadline/client/cli/_groups/_job_tui/_step_list.py` (new) +- `src/deadline/client/cli/_groups/_job_tui/_task_list.py` (new) +- `test/unit/deadline_client/cli/groups/job_tui/test_step_list.py` (new) +- `test/unit/deadline_client/cli/groups/job_tui/test_task_list.py` (new) + +### Task 4: Session list and attachment browser (`_session_list.py`, `_attachment_browser.py`) + +Create `_session_list.py` with `SessionListTUI` class: +- `__init__(farm_id, queue_id, job_id, step_id, task_id, deadline_client)` +- `load_sessions()` — list_sessions for the job, then for each session list_session_actions filtering for matching step_id+task_id, collect unique sessions with details +- `render()` — header with task info, session table (status, session ID, worker ID, time) +- `run()` — ↑/↓ nav, Enter shows session detail panel, Esc returns to task list, `q` quit + +Create `_attachment_browser.py` with `AttachmentBrowserTUI` class: +- Refactor tree/manifest/download logic from `browse_group.py` into this module +- `__init__(...)` — same as existing JobBrowserTUI but adds optional `step_id`/`task_id` params +- `load_manifests()` — load input manifests (job-level), load output manifests (scoped to task if step_id/task_id provided) +- `run()` — same file tree navigation as existing, but Esc returns to caller instead of quitting + +Update `browse_group.py` to use `AttachmentBrowserTUI` from this module (delegate, don't duplicate). + +Add unit tests for session list and attachment browser. + +Files created/modified: +- `src/deadline/client/cli/_groups/_job_tui/_session_list.py` (new) +- `src/deadline/client/cli/_groups/_job_tui/_attachment_browser.py` (new) +- `src/deadline/client/cli/_groups/browse_group.py` (modified — delegate to _attachment_browser) +- `test/unit/deadline_client/cli/groups/job_tui/test_session_list.py` (new) +- `test/unit/deadline_client/cli/groups/job_tui/test_attachment_browser.py` (new) + +### Task 5: Entry point and orchestration (`job_group.py` + wiring) + +Add `deadline job tui` command to `job_group.py`: +- Register `@cli_job.command(name="tui")` with `--profile`, `--farm-id`, `--queue-id` options +- Build config, get farm_id/queue_id, create deadline client +- Implement the main orchestration loop: + ``` + while True: + result = JobListTUI(...).run() + if result is None: break + if result[0] == "select": enter step loop for result[1] + if result[0] == "attachments": launch AttachmentBrowserTUI for result[1] + ``` +- Step loop: StepListTUI → on select enter task loop, on back return to job loop +- Task loop: TaskListTUI → on sessions launch SessionListTUI, on attachments launch AttachmentBrowserTUI(step_id, task_id), on back return to step loop +- Verify TTY check (`sys.stdin.isatty()`) + +Run `hatch run fmt` and `hatch run lint` to ensure code passes formatting and linting. +Run `hatch run test --numprocesses=1 -k "job_tui"` to verify all new tests pass. + +Files modified: +- `src/deadline/client/cli/_groups/job_group.py` (modified — add tui command) diff --git a/mkdocs.yml b/mkdocs.yml index 314f85aeb..da3768e25 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,7 @@ nav: - cli_reference/index.md - "attachment": cli_reference/deadline_attachment.md - "auth": cli_reference/deadline_auth.md + - "browse": cli_reference/deadline_browse.md - "bundle": cli_reference/deadline_bundle.md - "config": cli_reference/deadline_config.md - "farm": cli_reference/deadline_farm.md diff --git a/pyproject.toml b/pyproject.toml index dda0fa527..3ce29f6ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,9 @@ gui = [ # If the version changes, update the version in deadline/client/ui/__init__.py "PySide6-essentials >= 6.6,< 6.11", ] +tui = [ + "rich >= 13.0", +] mcp = [ "mcp >= 1.13.0", ] diff --git a/requirements-testing.txt b/requirements-testing.txt index c19271674..1cb44e485 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -19,5 +19,7 @@ jsondiff == 2.* pyinstrument == 5.* # MCP (Model Context Protocol) library for MCP unit tests mcp >= 1.13.0; python_version >= '3.10' +# Rich library for TUI unit tests +rich >= 13.0 # OpenJD model library for job template parsing in mock backend openjd-model >= 0.8.0; python_version >= '3.9' diff --git a/src/deadline/client/cli/_groups/__init__.py b/src/deadline/client/cli/_groups/__init__.py index 5ec0e6855..6ef1e08f7 100644 --- a/src/deadline/client/cli/_groups/__init__.py +++ b/src/deadline/client/cli/_groups/__init__.py @@ -1,6 +1,7 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. __all__ = [ + "browse_group", "bundle_group", "config_group", "auth_group", @@ -16,6 +17,7 @@ ] from . import ( + browse_group as browse_group, bundle_group as bundle_group, config_group as config_group, auth_group as auth_group, diff --git a/src/deadline/client/cli/_groups/_browse_tui.py b/src/deadline/client/cli/_groups/_browse_tui.py new file mode 100644 index 000000000..6cd0dde33 --- /dev/null +++ b/src/deadline/client/cli/_groups/_browse_tui.py @@ -0,0 +1,625 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""TUI rendering and interactive browser classes for job attachments. + +This module requires the ``rich`` package (install via ``pip install 'deadline[tui]'``). +It is imported lazily by ``browse_group.py`` and ``_job_tui/`` so that the rest of the +CLI works without ``rich`` installed. +""" + +import os +import subprocess +import sys +import tempfile +from contextlib import contextmanager +from enum import Enum +from typing import Dict, Iterator, List, Optional + +import click +from rich.console import Console +from rich.panel import Panel +from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn +from rich.table import Table + +from ._job_tui._common import ( + format_size, + format_time_ago, + get_status_style, + render_help_bar, +) +from deadline.client.api._session import get_default_client_config +from deadline.job_attachments.models import S3_MANIFEST_FOLDER_NAME + +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg"} +console = Console() + + +# === Terminal Input Helpers === + + +@contextmanager +def _raw_terminal() -> Iterator[None]: + """Context manager that puts the terminal into raw mode and restores it on exit. + + On Windows, this is a no-op since msvcrt handles raw input natively. + """ + if sys.platform == "win32": + yield + else: + import termios + import tty + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + yield + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + +def _read_browse_key() -> str: + """Read a single keypress, returning a normalized name. + + Returns one of: 'up', 'down', 'left', 'right', 'esc', 'enter', + or the character pressed (e.g. 'q', 'r', 'd'). + """ + if sys.platform == "win32": + import msvcrt + + ch = msvcrt.getwch() + if ch in ("\x00", "\xe0"): + ch2 = msvcrt.getwch() + return {"H": "up", "P": "down", "M": "right", "K": "left"}.get(ch2, "esc") + if ch == "\x1b": + return "esc" + if ch == "\r": + return "enter" + return ch + + # Unix: stdin is already in raw mode (caller used _raw_terminal) + ch = sys.stdin.read(1) + if ch == "\x1b": + ch2 = sys.stdin.read(2) + return {"[A": "up", "[B": "down", "[C": "right", "[D": "left"}.get(ch2, "esc") + if ch == "\r": + return "enter" + return ch + + +# === Constants & Types === + + +class NodeType(Enum): + ROOT = "root" + CATEGORY = "category" + MANIFEST_ROOT = "manifest_root" + FOLDER = "folder" + FILE = "file" + + +class TreeNode: + def __init__( + self, + name: str, + node_type: NodeType, + path: str = "", + size: int = 0, + hash: str = "", + parent: Optional["TreeNode"] = None, + ): + self.name = name + self.node_type = node_type + self.path = path + self.size = size + self.hash = hash + self.parent = parent + self.children: List["TreeNode"] = [] + + +# === Layer 1: Utilities === + + +def is_image(filename: str) -> bool: + return os.path.splitext(filename.lower())[1] in IMAGE_EXTENSIONS + + +def get_all_files_under(node: TreeNode) -> List[TreeNode]: + files = [] + if node.node_type == NodeType.FILE: + files.append(node) + for child in node.children: + files.extend(get_all_files_under(child)) + return files + + +# === Layer 2: Tree Construction === + + +def build_file_tree(manifest, root_name: str) -> TreeNode: + root = TreeNode(name=root_name, node_type=NodeType.MANIFEST_ROOT) + for mp in manifest.paths: + parts = mp.path.split("/") + current = root + for i, part in enumerate(parts): + is_file = i == len(parts) - 1 + existing = next((c for c in current.children if c.name == part), None) + if existing: + current = existing + else: + node_type = NodeType.FILE if is_file else NodeType.FOLDER + new_node = TreeNode( + name=part, + node_type=node_type, + path=mp.path if is_file else "/".join(parts[: i + 1]), + size=mp.size if is_file else 0, + hash=mp.hash if is_file else "", + parent=current, + ) + current.children.append(new_node) + current = new_node + return root + + +# === Layer 3: Manifest Loading === + + +def load_input_manifests(job: dict, s3_prefix: str, s3_bucket: str, session) -> List[TreeNode]: + from deadline.job_attachments.download import get_manifest_from_s3 + + trees = [] + attachments = job.get("attachments", {}) + for manifest_info in attachments.get("manifests", []): + input_path = manifest_info.get("inputManifestPath", "") + root_path = manifest_info["rootPath"] + if input_path: + manifest = get_manifest_from_s3( + manifest_key=f"{s3_prefix}/{input_path}", + s3_bucket=s3_bucket, + session=session, + ) + if manifest: + trees.append(build_file_tree(manifest, root_path)) + return trees + + +def load_output_manifests(s3_settings, farm_id, queue_id, job_id, session) -> List[TreeNode]: + from deadline.job_attachments.download import ( + get_output_manifests_by_asset_root, + merge_asset_manifests, + ) + + trees = [] + output_manifests = get_output_manifests_by_asset_root( + s3_settings=s3_settings, + farm_id=farm_id, + queue_id=queue_id, + job_id=job_id, + session=session, + ) + for root_path, manifests in output_manifests.items(): + merged = merge_asset_manifests(manifests) + if merged: + trees.append(build_file_tree(merged, root_path)) + return trees + + +# === Layer 4: TUI Rendering === + + +def get_node_icon(node: TreeNode) -> str: + if node.node_type == NodeType.FILE: + ext = os.path.splitext(node.name.lower())[1] + if ext in IMAGE_EXTENSIONS: + return "🖼️ " + elif ext in {".txt", ".log", ".md"}: + return "📝" + return "📄" + elif node.node_type == NodeType.FOLDER: + return "📁" + elif node.node_type == NodeType.MANIFEST_ROOT: + return "💾" + elif node.node_type == NodeType.CATEGORY: + return "📦" + return "📂" + + +def render_header(title: str, subtitle: str = "") -> None: + header = Table.grid(padding=1) + header.add_column(style="bold cyan", justify="left") + header.add_column(justify="right", style="dim") + header.add_row(f"🎬 {title}", subtitle) + console.print( + Panel(header, title="[bold]Deadline Cloud TUI (Beta)[/bold]", border_style="blue") + ) + + +def render_breadcrumb(current_node: TreeNode) -> None: + parts: List[str] = [] + node: Optional[TreeNode] = current_node + while node: + parts.insert(0, node.name) + node = node.parent + console.print(f"[dim]📍 {' › '.join(parts)}[/dim]\n") + + +def render_file_list(items: List[TreeNode], cursor: int) -> None: + if not items: + console.print("[dim italic] (empty)[/dim italic]") + return + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("", width=3) + table.add_column("Name") + table.add_column("Info", justify="right", style="dim") + for i, item in enumerate(items): + icon = get_node_icon(item) + is_selected = i == cursor + info = ( + format_size(item.size) + if item.node_type == NodeType.FILE + else f"{len(get_all_files_under(item))} files" + ) + if is_selected: + table.add_row( + "[bold cyan]▶[/bold cyan]", + f"[bold reverse] {icon} {item.name} [/bold reverse]", + f"[bold cyan]{info}[/bold cyan]", + ) + else: + table.add_row(" ", f"{icon} {item.name}", info) + console.print(table) + + +# === Layer 5: File Operations === + + +def download_single_file(queue_role_session, s3_settings, node: TreeNode, dest_dir: str) -> str: + s3 = queue_role_session.client("s3") + s3_key = f"{s3_settings.rootPrefix}/Data/{node.hash}.xxh128" + local_path = os.path.join(dest_dir, node.name) + s3.download_file(s3_settings.s3BucketName, s3_key, local_path) + return local_path + + +def download_folder(queue_role_session, s3_settings, node: TreeNode, dest_dir: str) -> int: + files = get_all_files_under(node) + s3 = queue_role_session.client("s3") + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + console=console, + ) as progress: + task = progress.add_task("Downloading...", total=len(files)) + for f in files: + local_path = os.path.join(dest_dir, f.path) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + s3_key = f"{s3_settings.rootPrefix}/Data/{f.hash}.xxh128" + s3.download_file(s3_settings.s3BucketName, s3_key, local_path) + progress.update(task, advance=1, description=f"[cyan]{f.name}[/cyan]") + return len(files) + + +def show_file_info(node: TreeNode) -> None: + console.clear() + table = Table(title="📋 File Information", show_header=False, border_style="cyan") + table.add_column("Property", style="bold") + table.add_column("Value") + table.add_row("Name", node.name) + table.add_row("Path", node.path) + table.add_row("Size", format_size(node.size)) + table.add_row("Hash", node.hash) + table.add_row("Type", "Image" if is_image(node.name) else "File") + console.print(table) + console.print("\n[dim]Press any key to continue...[/dim]") + click.getchar() + + +def preview_file_content(queue_role_session, s3_settings, node: TreeNode) -> None: + """Download and preview file content in terminal.""" + console.clear() + console.print(f"[bold cyan]📄 {node.name}[/bold cyan]") + console.print(f"[dim]Size: {format_size(node.size)} | Hash: {node.hash}[/dim]\n") + + # Download to temp + path = download_single_file(queue_role_session, s3_settings, node, tempfile.gettempdir()) + + # Preview based on file type + ext = os.path.splitext(node.name.lower())[1] + try: + if ext in {".txt", ".log", ".md", ".json", ".yaml", ".yml", ".py", ".sh", ".csv"}: + with open(path, "r", errors="replace") as f: + content = f.read(4000) # First 4KB + if len(content) == 4000: + content += "\n... [truncated]" + from rich.syntax import Syntax + + if ext in {".py", ".sh", ".json", ".yaml", ".yml"}: + syntax = Syntax(content, ext[1:], theme="monokai", line_numbers=True) + console.print(Panel(syntax, title="Preview", border_style="dim")) + else: + console.print(Panel(content, title="Preview", border_style="dim")) + elif is_image(node.name): + console.print("[yellow]Image file - press 'v' to open in viewer[/yellow]") + else: + # Binary file - show hex preview + with open(path, "rb") as f: + data = f.read(256) + hex_lines = [] + for i in range(0, len(data), 16): + hex_part = " ".join(f"{b:02x}" for b in data[i : i + 16]) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in data[i : i + 16]) + hex_lines.append(f"{i:04x} {hex_part:<48} {ascii_part}") + console.print(Panel("\n".join(hex_lines), title="Hex Preview", border_style="dim")) + except Exception as e: + console.print(f"[red]Could not preview: {e}[/red]") + + console.print(f"\n[green]Downloaded to: {path}[/green]") + console.print("[dim]Press any key to continue...[/dim]") + click.getchar() + + +def open_image_viewer(queue_role_session, s3_settings, node: TreeNode) -> None: + path = download_single_file(queue_role_session, s3_settings, node, tempfile.gettempdir()) + if sys.platform == "darwin": + subprocess.run(["open", path]) + elif sys.platform == "linux": + subprocess.run(["xdg-open", path]) + else: + subprocess.run(["start", path], shell=True) + + +def show_manifest_list(root: TreeNode) -> None: + console.clear() + table = Table(title="📜 Manifest Files", border_style="cyan") + table.add_column("Type", style="bold") + table.add_column("Root Path") + table.add_column("Files", justify="right") + for category in root.children: + for manifest_root in category.children: + file_count = len(get_all_files_under(manifest_root)) + table.add_row(category.name, manifest_root.name, str(file_count)) + console.print(table) + console.print("\n[dim]Press any key to continue...[/dim]") + click.getchar() + + +# === Layer 6: Job Selector === + + +class JobSelectorTUI: + PAGE_SIZE = 20 + + def __init__(self, farm_id: str, queue_id: str, deadline_client) -> None: + self.farm_id = farm_id + self.queue_id = queue_id + self.deadline = deadline_client + self.jobs: List[Dict] = [] + self.cursor = 0 + self.page = 0 + self.total_jobs = 0 + + def load_jobs(self) -> None: + response = self.deadline.search_jobs( + farmId=self.farm_id, + queueIds=[self.queue_id], + itemOffset=self.page * self.PAGE_SIZE, + pageSize=self.PAGE_SIZE, + sortExpressions=[{"fieldSort": {"name": "CREATED_AT", "sortOrder": "DESCENDING"}}], + ) + self.jobs = response.get("jobs", []) + self.total_jobs = response.get("totalResults", 0) + + def render(self) -> None: + console.clear() + render_header("Select a Job", f"Queue: {self.queue_id[:20]}...") + console.print() + if not self.jobs: + console.print("[dim italic] No jobs found[/dim italic]") + else: + table = Table(show_header=False, box=None, padding=(0, 1)) + table.add_column("", width=3) + table.add_column("Status", width=12) + table.add_column("Name", min_width=30) + table.add_column("Time", width=10, justify="right") + table.add_column("ID", width=12, justify="right") + for i, job in enumerate(self.jobs): + status = job.get("taskRunStatus", "UNKNOWN") + color, icon = get_status_style(status) + name = job.get("name", job.get("displayName", "Unnamed")) + created = job.get("createdAt") + time_str = format_time_ago(created) if created else "" + job_id = job.get("jobId", "") + short_id = job_id[-8:] if job_id else "" + is_selected = i == self.cursor + if is_selected: + table.add_row( + "[bold cyan]▶[/bold cyan]", + f"[bold {color}]{icon} {status}[/bold {color}]", + f"[bold reverse] {name} [/bold reverse]", + f"[bold cyan]{time_str}[/bold cyan]", + f"[bold cyan]...{short_id}[/bold cyan]", + ) + else: + table.add_row( + " ", + f"[{color}]{icon} {status}[/{color}]", + name, + f"[dim]{time_str}[/dim]", + f"[dim]...{short_id}[/dim]", + ) + console.print(table) + # Pagination info + total_pages = (self.total_jobs + self.PAGE_SIZE - 1) // self.PAGE_SIZE + start = self.page * self.PAGE_SIZE + 1 + end = min(start + len(self.jobs) - 1, self.total_jobs) + console.print( + f"\n[dim]Page {self.page + 1}/{total_pages}" + f" ({start}-{end} of {self.total_jobs} jobs)[/dim]" + ) + console.print() + render_help_bar( + [("↑↓", "nav"), ("←→", "page"), ("Enter", "select"), ("r", "refresh"), ("q", "quit")] + ) + + def run(self) -> Optional[str]: + console.print("[dim]Loading jobs...[/dim]") + self.load_jobs() + while True: + self.render() + with _raw_terminal(): + key = _read_browse_key() + if key == "up": + self.cursor = max(0, self.cursor - 1) + elif key == "down": + self.cursor = min(len(self.jobs) - 1, self.cursor + 1) if self.jobs else 0 + elif key == "left": + if self.page > 0: + self.page -= 1 + self.cursor = 0 + self.load_jobs() + elif key == "right": + total_pages = (self.total_jobs + self.PAGE_SIZE - 1) // self.PAGE_SIZE + if self.page < total_pages - 1: + self.page += 1 + self.cursor = 0 + self.load_jobs() + elif key == "enter" and self.jobs: + return self.jobs[self.cursor].get("jobId") + elif key == "r": + console.print("[dim]Refreshing...[/dim]") + self.load_jobs() + self.cursor = 0 + elif key == "q": + console.clear() + return None + + +# === Layer 7: File Browser === + + +class JobBrowserTUI: + def __init__( + self, + farm_id: str, + queue_id: str, + job_id: str, + job_name: str, + job_status: str, + boto3_session, + queue_role_session, + s3_settings, + ) -> None: + self.farm_id = farm_id + self.queue_id = queue_id + self.job_id = job_id + self.job_name = job_name + self.job_status = job_status + self.boto3_session = boto3_session + self.queue_role_session = queue_role_session + self.s3_settings = s3_settings + self.root = TreeNode(name="Job", node_type=NodeType.ROOT) + self.current_node = self.root + self.cursor = 0 + self.message = "" + + def load_manifests(self) -> None: + deadline = self.boto3_session.client("deadline", config=get_default_client_config()) + job = deadline.get_job(farmId=self.farm_id, queueId=self.queue_id, jobId=self.job_id) + input_node = TreeNode(name="input", node_type=NodeType.CATEGORY, parent=self.root) + output_node = TreeNode(name="output", node_type=NodeType.CATEGORY, parent=self.root) + self.root.children = [input_node, output_node] + s3_prefix = f"{self.s3_settings.rootPrefix}/{S3_MANIFEST_FOLDER_NAME}" + for tree in load_input_manifests( + job, s3_prefix, self.s3_settings.s3BucketName, self.queue_role_session + ): + tree.parent = input_node + input_node.children.append(tree) + for tree in load_output_manifests( + self.s3_settings, self.farm_id, self.queue_id, self.job_id, self.queue_role_session + ): + tree.parent = output_node + output_node.children.append(tree) + + def render(self) -> None: + console.clear() + color, icon = get_status_style(self.job_status) + render_header(self.job_name, f"[{color}]{icon} {self.job_status}[/{color}]") + render_breadcrumb(self.current_node) + render_file_list(self.current_node.children, self.cursor) + if self.message: + console.print(f"\n[yellow]{self.message}[/yellow]") + self.message = "" + console.print() + render_help_bar( + [ + ("←→↑↓", "nav"), + ("Enter", "open"), + ("d", "download"), + ("i", "info"), + ("v", "view"), + ("m", "manifests"), + ("q", "quit"), + ] + ) + + def handle_download(self, node: TreeNode) -> None: + dest = console.input("[bold]Download to:[/bold] ") or os.getcwd() + os.makedirs(dest, exist_ok=True) + if node.node_type == NodeType.FILE: + path = download_single_file(self.queue_role_session, self.s3_settings, node, dest) + self.message = f"✅ Downloaded to {path}" + else: + count = download_folder(self.queue_role_session, self.s3_settings, node, dest) + self.message = f"✅ Downloaded {count} files to {dest}" + + def run(self) -> None: + console.print("[dim]Loading manifests...[/dim]") + self.load_manifests() + while True: + self.render() + items = self.current_node.children + with _raw_terminal(): + key = _read_browse_key() + if key == "up": + self.cursor = max(0, self.cursor - 1) + elif key == "down": + self.cursor = min(len(items) - 1, self.cursor + 1) if items else 0 + elif key == "left" and self.current_node.parent: + self.current_node = self.current_node.parent + self.cursor = 0 + elif key == "right" and items: + node = items[self.cursor] + if node.node_type == NodeType.FILE: + show_file_info(node) + else: + self.current_node = node + self.cursor = 0 + elif key == "enter" and items: + node = items[self.cursor] + if node.node_type == NodeType.FILE: + show_file_info(node) + else: + self.current_node = node + self.cursor = 0 + elif key == "b" and self.current_node.parent: + self.current_node = self.current_node.parent + self.cursor = 0 + elif key == "d" and items: + node = items[self.cursor] + if node.node_type == NodeType.FILE: + preview_file_content(self.queue_role_session, self.s3_settings, node) + else: + self.handle_download(node) + elif key == "i" and items and items[self.cursor].node_type == NodeType.FILE: + show_file_info(items[self.cursor]) + elif key == "v" and items: + node = items[self.cursor] + if node.node_type == NodeType.FILE and is_image(node.name): + open_image_viewer(self.queue_role_session, self.s3_settings, node) + self.message = f"🖼️ Opened {node.name}" + elif key == "m": + show_manifest_list(self.root) + elif key == "q": + console.clear() + break diff --git a/src/deadline/client/cli/_groups/_job_tui/__init__.py b/src/deadline/client/cli/_groups/_job_tui/__init__.py new file mode 100644 index 000000000..8d929cc86 --- /dev/null +++ b/src/deadline/client/cli/_groups/_job_tui/__init__.py @@ -0,0 +1 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/src/deadline/client/cli/_groups/_job_tui/_attachment_browser.py b/src/deadline/client/cli/_groups/_job_tui/_attachment_browser.py new file mode 100644 index 000000000..7d3604449 --- /dev/null +++ b/src/deadline/client/cli/_groups/_job_tui/_attachment_browser.py @@ -0,0 +1,196 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Attachment browser TUI screen — wraps existing browse_group manifest logic.""" + +from __future__ import annotations + +import os +from typing import Any, Optional + +from rich.spinner import Spinner +from rich.live import Live + +from ._common import ( + clear_screen, + console, + get_status_style, + open_feedback_url, + read_key, + render_header, + render_help_bar, +) + +# Import tree/manifest types and functions from _browse_tui +from .._browse_tui import ( + NodeType, + TreeNode, + download_folder, + download_single_file, + is_image, + load_input_manifests, + load_output_manifests, + open_image_viewer, + preview_file_content, + render_breadcrumb, + render_file_list, + show_file_info, + show_manifest_list, +) + +from deadline.client.api._session import get_default_client_config +from deadline.job_attachments.models import ( + S3_MANIFEST_FOLDER_NAME, + JobAttachmentS3Settings, +) + + +class AttachmentBrowserTUI: + """File tree browser for job/task attachments. Esc returns to caller.""" + + def __init__( + self, + farm_id: str, + queue_id: str, + job_id: str, + job_name: str, + job_status: str, + boto3_session: Any, + queue_role_session: Any, + s3_settings: JobAttachmentS3Settings, + step_id: Optional[str] = None, + task_id: Optional[str] = None, + ) -> None: + self.farm_id = farm_id + self.queue_id = queue_id + self.job_id = job_id + self.job_name = job_name + self.job_status = job_status + self.boto3_session = boto3_session + self.queue_role_session = queue_role_session + self.s3_settings = s3_settings + self.step_id = step_id + self.task_id = task_id + self.root = TreeNode(name="Job", node_type=NodeType.ROOT) + self.current_node = self.root + self.cursor: int = 0 + self.message: str = "" + self._needs_full_clear: bool = True + + def load_manifests(self) -> None: + """Load input and output manifests into tree structure.""" + deadline = self.boto3_session.client("deadline", config=get_default_client_config()) + job = deadline.get_job(farmId=self.farm_id, queueId=self.queue_id, jobId=self.job_id) + input_node = TreeNode(name="input", node_type=NodeType.CATEGORY, parent=self.root) + output_node = TreeNode(name="output", node_type=NodeType.CATEGORY, parent=self.root) + self.root.children = [input_node, output_node] + + s3_prefix = f"{self.s3_settings.rootPrefix}/{S3_MANIFEST_FOLDER_NAME}" + + # Input manifests are always job-level + for tree in load_input_manifests( + job, s3_prefix, self.s3_settings.s3BucketName, self.queue_role_session + ): + tree.parent = input_node + input_node.children.append(tree) + + # Output manifests: scoped to step/task if provided + output_kwargs: dict = { + "s3_settings": self.s3_settings, + "farm_id": self.farm_id, + "queue_id": self.queue_id, + "job_id": self.job_id, + "session": self.queue_role_session, + } + for tree in load_output_manifests(**output_kwargs): + tree.parent = output_node + output_node.children.append(tree) + + def render(self) -> None: + """Render the file tree browser.""" + clear_screen(full=self._needs_full_clear) + self._needs_full_clear = False + color, icon = get_status_style(self.job_status) + render_header(self.job_name, f"[{color}]{icon} {self.job_status}[/{color}]") + render_breadcrumb(self.current_node) + render_file_list(self.current_node.children, self.cursor) + + if self.message: + console.print(f"\n[yellow]{self.message}[/yellow]") + self.message = "" + + console.print() + render_help_bar( + [ + ("←→↑↓", "nav"), + ("Enter", "open"), + ("d", "download"), + ("i", "info"), + ("v", "view"), + ("m", "manifests"), + ("f", "feedback"), + ("Esc", "back"), + ("q", "quit"), + ] + ) + + def _handle_download(self, node: TreeNode) -> None: + """Prompt for destination and download.""" + dest = console.input("[bold]Download to:[/bold] ") or os.getcwd() + os.makedirs(dest, exist_ok=True) + if node.node_type == NodeType.FILE: + path = download_single_file(self.queue_role_session, self.s3_settings, node, dest) + self.message = f"✅ Downloaded to {path}" + else: + count = download_folder(self.queue_role_session, self.s3_settings, node, dest) + self.message = f"✅ Downloaded {count} files to {dest}" + + def run(self) -> None: + """File tree browser. Esc returns to caller.""" + with Live(Spinner("dots", text="Loading manifests..."), console=console, transient=True): + self.load_manifests() + + while True: + self.render() + items = self.current_node.children + key = read_key() + + if key == "up": + self.cursor = max(0, self.cursor - 1) + elif key == "down": + self.cursor = min(len(items) - 1, self.cursor + 1) if items else 0 + elif key == "left": + if self.current_node.parent: + self.current_node = self.current_node.parent + self.cursor = 0 + self._needs_full_clear = True + else: + return # At root, go back + elif key == "esc": + return + elif key in ("right", "enter") and items: + node = items[self.cursor] + if node.node_type == NodeType.FILE: + show_file_info(node) + else: + self.current_node = node + self.cursor = 0 + self._needs_full_clear = True + elif key == "d" and items: + node = items[self.cursor] + if node.node_type == NodeType.FILE: + preview_file_content(self.queue_role_session, self.s3_settings, node) + else: + self._handle_download(node) + elif key == "i" and items and items[self.cursor].node_type == NodeType.FILE: + show_file_info(items[self.cursor]) + elif key == "v" and items: + node = items[self.cursor] + if node.node_type == NodeType.FILE and is_image(node.name): + open_image_viewer(self.queue_role_session, self.s3_settings, node) + self.message = f"🖼️ Opened {node.name}" + elif key == "m": + show_manifest_list(self.root) + elif key == "f": + self.message = open_feedback_url() + elif key == "q": + clear_screen() + return diff --git a/src/deadline/client/cli/_groups/_job_tui/_common.py b/src/deadline/client/cli/_groups/_job_tui/_common.py new file mode 100644 index 000000000..4ca8fe334 --- /dev/null +++ b/src/deadline/client/cli/_groups/_job_tui/_common.py @@ -0,0 +1,242 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Shared utilities for the job TUI.""" + +from __future__ import annotations + +import subprocess +import sys +from datetime import datetime, timezone +from typing import Optional + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +console = Console() + + +def get_terminal_page_size(chrome_lines: int = 18) -> int: + """Compute how many list items fit on screen. + + chrome_lines accounts for all non-list-item lines rendered per frame: + header panel (4), section label + blank (2), pagination + blank (2), + message line (1), help bar panel (4), extra safety margin (5) = 18. + """ + return max(5, console.height - chrome_lines) + + +def get_status_style(status: str) -> tuple[str, str]: + """Return (color, icon) for all taskRunStatus/runStatus values.""" + if status == "SUCCEEDED": + return "green", "✓" + elif status in ("RUNNING", "STARTING", "SCHEDULED", "ASSIGNED"): + return "yellow", "●" + elif status == "INTERRUPTING": + return "yellow", "⚡" + elif status == "PENDING": + return "blue", "○" + elif status == "READY": + return "cyan", "◉" + elif status == "SUSPENDED": + return "magenta", "⏸" + elif status == "NOT_COMPATIBLE": + return "red", "⚠" + elif status in ("FAILED", "CANCELED"): + return "red", "✗" + return "dim", "○" + + +def get_lifecycle_badge(status: str) -> Optional[tuple[str, str]]: + """Return (badge_text, color) for step lifecycleStatus, or None if CREATE_COMPLETE.""" + if status == "CREATE_COMPLETE": + return None + elif status == "UPDATE_IN_PROGRESS": + return "[UPDATING]", "yellow" + elif status == "UPDATE_FAILED": + return "[UPD_FAIL]", "red" + elif status == "UPDATE_SUCCEEDED": + return "[UPDATED]", "green" + return None + + +def format_size(size: int) -> str: + """Convert bytes to human readable (KB, MB, GB).""" + size_float = float(size) + for unit in ["B", "KB", "MB", "GB"]: + if size_float < 1024: + return f"{size_float:.1f} {unit}" + size_float /= 1024 + return f"{size_float:.1f} TB" + + +def format_time_ago(dt: Optional[datetime]) -> str: + """Convert datetime to relative time (e.g., '2m ago').""" + if not dt: + return "" + now = datetime.now(timezone.utc) + diff = now - dt + seconds = diff.total_seconds() + if seconds < 60: + return "just now" + elif seconds < 3600: + mins = int(seconds / 60) + return f"{mins}m ago" + elif seconds < 86400: + hrs = int(seconds / 3600) + return f"{hrs}h ago" + else: + days = int(seconds / 86400) + return f"{days}d ago" + + +def format_short_id(full_id: str) -> str: + """Truncate 'prefix-<32hex>' to 'prefix-<6hex>' like git short hash.""" + if "-" in full_id: + prefix, hex_part = full_id.rsplit("-", 1) + return f"{prefix}-{hex_part[:6]}" + return full_id + + +def copy_to_clipboard(text: str) -> bool: + """Copy text to system clipboard. Returns True on success.""" + try: + if sys.platform == "darwin": + subprocess.run(["pbcopy"], input=text.encode(), check=True) + elif sys.platform == "linux": + subprocess.run(["xclip", "-selection", "clipboard"], input=text.encode(), check=True) + elif sys.platform == "win32": + subprocess.run(["clip"], input=text.encode(), check=True) + else: + return False + return True + except (subprocess.SubprocessError, FileNotFoundError): + return False + + +FEEDBACK_URL = "https://github.com/aws-deadline/deadline-cloud/issues" + + +def open_feedback_url() -> str: + """Try to open the feedback URL in a browser. Returns a status message.""" + import webbrowser + + try: + if webbrowser.open(FEEDBACK_URL): + return f"🌐 Opened {FEEDBACK_URL}" + except Exception: + pass + return f"💬 Please provide feedback at {FEEDBACK_URL}" + + +def enter_alt_screen() -> None: + """Switch to alternate screen buffer and hide cursor.""" + sys.stdout.write("\033[?1049h\033[?25l") + sys.stdout.flush() + + +def leave_alt_screen() -> None: + """Show cursor and return to the main screen buffer.""" + sys.stdout.write("\033[?25h\033[?1049l") + sys.stdout.flush() + + +def clear_screen(full: bool = True) -> None: + """Move cursor to home position, optionally erasing the screen. + + Args: + full: When True (default), erase the entire screen after homing + the cursor. This prevents stale content from lingering. + When False, only homes the cursor so content is + overwritten in-place — use this when scrolling within + the same list to avoid flicker. + """ + if full: + sys.stdout.write("\033[H\033[2J") + else: + sys.stdout.write("\033[H") + sys.stdout.flush() + + +def render_header(title: str, subtitle: str = "") -> None: + """Render title panel with subtitle.""" + header = Table.grid(padding=1) + header.add_column(style="bold cyan", justify="left") + header.add_column(justify="right", style="dim") + header.add_row(f"🎬 {title}", subtitle) + console.print( + Panel( + header, + title="[bold]Deadline Cloud TUI (Beta)[/bold]", + border_style="blue", + width=console.width - 1, + ) + ) + + +def render_help_bar(keys: list[tuple[str, str]]) -> None: + """Render keyboard shortcut help panel, then erase any stale lines below. + + The erase-below (\\033[J) after the panel cleans up leftover content + from a previous longer frame (e.g. navigating from a long job list + to a shorter step list) without needing a full-screen erase in + clear_screen() which would cause visible flicker. + """ + help_text = " ".join([f"[bold]{k}[/bold] [dim]{v}[/dim]" for k, v in keys]) + console.print(Panel(help_text, style="dim", border_style="dim", width=console.width - 1)) + sys.stdout.write("\033[J") + sys.stdout.flush() + + +def read_key() -> str: + """Read a single keypress, returning normalized key name. + + Returns one of: 'up', 'down', 'left', 'right', 'esc', 'enter', + or the character pressed (e.g. 'q', 'j', 'c', 'n', 'p', 'r', 'l'). + """ + if sys.platform == "win32": + import msvcrt + + ch = msvcrt.getwch() + if ch in ("\x00", "\xe0"): + ch2 = msvcrt.getwch() + if ch2 == "H": + return "up" + elif ch2 == "P": + return "down" + elif ch2 == "M": + return "right" + elif ch2 == "K": + return "left" + return "esc" + elif ch == "\x1b": + return "esc" + elif ch == "\r": + return "enter" + return ch + else: + import termios + import tty + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = sys.stdin.read(1) + if ch == "\x1b": + ch2 = sys.stdin.read(1) + if ch2 == "[": + ch3 = sys.stdin.read(1) + if ch3 == "A": + return "up" + elif ch3 == "B": + return "down" + elif ch3 == "C": + return "right" + elif ch3 == "D": + return "left" + return "esc" + elif ch == "\r": + return "enter" + return ch + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) diff --git a/src/deadline/client/cli/_groups/_job_tui/_job_list.py b/src/deadline/client/cli/_groups/_job_tui/_job_list.py new file mode 100644 index 000000000..caceb6060 --- /dev/null +++ b/src/deadline/client/cli/_groups/_job_tui/_job_list.py @@ -0,0 +1,195 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Job list TUI screen.""" + +from __future__ import annotations + +from typing import Any, Optional + +from rich.table import Table + +from ._common import ( + clear_screen, + console, + copy_to_clipboard, + format_short_id, + format_time_ago, + get_status_style, + get_terminal_page_size, + open_feedback_url, + read_key, + render_header, + render_help_bar, +) + + +class JobListTUI: + """Paginated job list browser.""" + + def __init__(self, farm_id: str, queue_id: str, deadline_client: Any) -> None: + self.farm_id = farm_id + self.queue_id = queue_id + self.deadline = deadline_client + self.page_size = get_terminal_page_size() + self.jobs: list[dict] = [] + self.cursor: int = 0 + self.page: int = 0 + self.total_jobs: int = 0 + self.message: str = "" + self._needs_full_clear: bool = True + + def load_page(self) -> None: + """Fetch one page of jobs via search_jobs.""" + response = self.deadline.search_jobs( + farmId=self.farm_id, + queueIds=[self.queue_id], + itemOffset=self.page * self.page_size, + pageSize=self.page_size, + sortExpressions=[{"fieldSort": {"name": "CREATED_AT", "sortOrder": "DESCENDING"}}], + ) + self.jobs = response.get("jobs", []) + self.total_jobs = response.get("totalResults", 0) + + def render(self) -> None: + """Render job list with status, target badge, name, time, short ID.""" + clear_screen(full=self._needs_full_clear) + self._needs_full_clear = False + render_header("Jobs", f"Queue: {format_short_id(self.queue_id)}") + console.print() + + if not self.jobs: + console.print("[dim italic] No jobs found[/dim italic]") + else: + table = Table(show_header=False, box=None, padding=(0, 1), width=console.width - 1) + table.add_column("", width=3) + table.add_column("Status", width=22, no_wrap=True) + table.add_column("Name", no_wrap=True, overflow="ellipsis") + table.add_column("Time", width=10, justify="right", no_wrap=True) + table.add_column("ID", width=14, justify="right", no_wrap=True) + + for i, job in enumerate(self.jobs): + self._add_job_row(table, i, job) + console.print(table) + + # Pagination info + total_pages = max(1, (self.total_jobs + self.page_size - 1) // self.page_size) + start = self.page * self.page_size + 1 + end = min(start + len(self.jobs) - 1, self.total_jobs) + console.print( + f"\n[dim]Page {self.page + 1}/{total_pages} ({start}-{end} of {self.total_jobs})[/dim]" + ) + + if self.message: + console.print(f"\n[yellow]{self.message}[/yellow]") + self.message = "" + + console.print() + render_help_bar( + [ + ("↑↓", "nav"), + ("→/Enter", "steps"), + ("a", "attachments"), + ("c", "copy id"), + ("n/p", "page"), + ("r", "refresh"), + ("f", "feedback"), + ("q", "quit"), + ] + ) + + def _add_job_row(self, table: Table, index: int, job: dict) -> None: + """Add a single job row to the table.""" + status = job.get("taskRunStatus", "UNKNOWN") + target = job.get("targetTaskRunStatus", "") + color, icon = get_status_style(status) + name = job.get("name", job.get("displayName", "Unnamed")) + created = job.get("createdAt") + time_str = format_time_ago(created) if created else "" + job_id = job.get("jobId", "") + short_id = format_short_id(job_id) + + # Build status text with optional target badge + status_text = f"{icon} {status}" + if target and target != status: + target_color, _ = get_status_style(target) + status_text += f" [{target_color}]→{target}[/{target_color}]" + + if index == self.cursor: + table.add_row( + "[bold cyan]▶[/bold cyan]", + f"[bold {color}]{status_text}[/bold {color}]", + f"[bold reverse] {name} [/bold reverse]", + f"[bold cyan]{time_str}[/bold cyan]", + f"[bold cyan]{short_id}[/bold cyan]", + ) + else: + table.add_row( + " ", + f"[{color}]{status_text}[/{color}]", + name, + f"[dim]{time_str}[/dim]", + f"[dim]{short_id}[/dim]", + ) + + def run(self) -> Optional[tuple[str, str]]: + """Main loop. Returns ('select', job_id), ('attachments', job_id), or None.""" + self._needs_full_clear = True + self.load_page() + + while True: + self.render() + key = read_key() + + if key == "up": + if self.cursor > 0: + self.cursor -= 1 + elif self.page > 0: + # Wrap to previous page, cursor at bottom + self.page -= 1 + self._needs_full_clear = True + self.load_page() + self.cursor = max(0, len(self.jobs) - 1) + elif key == "down": + if self.jobs and self.cursor < len(self.jobs) - 1: + self.cursor += 1 + elif self.jobs: + # Wrap to next page, cursor at top + total_pages = max(1, (self.total_jobs + self.page_size - 1) // self.page_size) + if self.page < total_pages - 1: + self.page += 1 + self._needs_full_clear = True + self.cursor = 0 + self.load_page() + elif key in ("right", "enter") and self.jobs: + job_id = self.jobs[self.cursor].get("jobId", "") + return ("select", job_id) + elif key == "a" and self.jobs: + job_id = self.jobs[self.cursor].get("jobId", "") + return ("attachments", job_id) + elif key == "c" and self.jobs: + job_id = self.jobs[self.cursor].get("jobId", "") + if copy_to_clipboard(job_id): + self.message = f"📋 Copied {job_id}" + else: + self.message = f"📋 {job_id}" + elif key == "n": + total_pages = max(1, (self.total_jobs + self.page_size - 1) // self.page_size) + if self.page < total_pages - 1: + self.page += 1 + self._needs_full_clear = True + self.cursor = 0 + self.load_page() + elif key == "p": + if self.page > 0: + self.page -= 1 + self._needs_full_clear = True + self.cursor = 0 + self.load_page() + elif key == "r": + self._needs_full_clear = True + self.load_page() + self.cursor = 0 + elif key == "f": + self.message = open_feedback_url() + elif key == "q": + clear_screen() + return None diff --git a/src/deadline/client/cli/_groups/_job_tui/_session_list.py b/src/deadline/client/cli/_groups/_job_tui/_session_list.py new file mode 100644 index 000000000..624d29d46 --- /dev/null +++ b/src/deadline/client/cli/_groups/_job_tui/_session_list.py @@ -0,0 +1,181 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Session list TUI screen.""" + +from __future__ import annotations + +from typing import Any + +from rich.table import Table + +from ._common import ( + clear_screen, + console, + copy_to_clipboard, + format_short_id, + format_time_ago, + get_status_style, + open_feedback_url, + read_key, + render_header, + render_help_bar, +) + + +class SessionListTUI: + """Session list for a specific task.""" + + def __init__( + self, + farm_id: str, + queue_id: str, + job_id: str, + step_id: str, + task_id: str, + task_label: str, + deadline_client: Any, + ) -> None: + self.farm_id = farm_id + self.queue_id = queue_id + self.job_id = job_id + self.step_id = step_id + self.task_id = task_id + self.task_label = task_label + self.deadline = deadline_client + self.sessions: list[dict] = [] + self.cursor: int = 0 + self.message: str = "" + self._needs_full_clear: bool = True + + def load_sessions(self) -> None: + """Load sessions for this job, then filter to those that ran this task's step+task.""" + # List all sessions for the job + all_sessions: list[dict] = [] + kwargs: dict = { + "farmId": self.farm_id, + "queueId": self.queue_id, + "jobId": self.job_id, + } + while True: + response = self.deadline.list_sessions(**kwargs) + all_sessions.extend(response.get("sessions", [])) + next_token = response.get("nextToken") + if not next_token: + break + kwargs["nextToken"] = next_token + + # For each session, check if it has actions for our step+task + matching_sessions: list[dict] = [] + for session in all_sessions: + session_id = session.get("sessionId", "") + try: + actions_kwargs: dict = { + "farmId": self.farm_id, + "queueId": self.queue_id, + "jobId": self.job_id, + "sessionId": session_id, + } + actions_response = self.deadline.list_session_actions(**actions_kwargs) + for action in actions_response.get("sessionActions", []): + definition = action.get("definition", {}) + task_run = definition.get("taskRun", {}) + if ( + task_run.get("stepId") == self.step_id + and task_run.get("taskId") == self.task_id + ): + matching_sessions.append(session) + break + except Exception: + # If we can't list actions for a session, skip it + continue + + self.sessions = matching_sessions + + def render(self) -> None: + """Render session list.""" + clear_screen(full=self._needs_full_clear) + self._needs_full_clear = False + render_header(f"Sessions for {self.task_label}", "") + console.print() + + if not self.sessions: + console.print("[dim italic] No sessions found for this task[/dim italic]") + else: + table = Table(show_header=False, box=None, padding=(0, 1), width=console.width - 1) + table.add_column("", width=3) + table.add_column("Status", width=12, no_wrap=True) + table.add_column("Session", width=20, no_wrap=True) + table.add_column("Worker", width=20, no_wrap=True) + table.add_column("Time", width=10, justify="right", no_wrap=True) + + for i, session in enumerate(self.sessions): + self._add_session_row(table, i, session) + console.print(table) + + if self.message: + console.print(f"\n[yellow]{self.message}[/yellow]") + self.message = "" + + console.print() + render_help_bar( + [ + ("↑↓", "nav"), + ("c", "copy id"), + ("f", "feedback"), + ("Esc", "back"), + ("q", "quit"), + ] + ) + + def _add_session_row(self, table: Table, index: int, session: dict) -> None: + """Add a single session row.""" + status = session.get("lifecycleStatus", "UNKNOWN") + color, icon = get_status_style(status) + session_id = session.get("sessionId", "") + short_id = format_short_id(session_id) + worker_id = session.get("workerId", "") + short_worker = format_short_id(worker_id) if worker_id else "" + started = session.get("startedAt") + time_str = format_time_ago(started) if started else "" + + if index == self.cursor: + table.add_row( + "[bold cyan]▶[/bold cyan]", + f"[bold {color}]{icon} {status}[/bold {color}]", + f"[bold cyan]{short_id}[/bold cyan]", + f"[bold cyan]{short_worker}[/bold cyan]", + f"[bold cyan]{time_str}[/bold cyan]", + ) + else: + table.add_row( + " ", + f"[{color}]{icon} {status}[/{color}]", + f"[dim]{short_id}[/dim]", + f"[dim]{short_worker}[/dim]", + f"[dim]{time_str}[/dim]", + ) + + def run(self) -> None: + """Browse sessions. Esc returns to caller.""" + self.load_sessions() + + while True: + self.render() + key = read_key() + + if key == "up": + self.cursor = max(0, self.cursor - 1) + elif key == "down": + self.cursor = min(len(self.sessions) - 1, self.cursor + 1) if self.sessions else 0 + elif key == "c" and self.sessions: + session_id = self.sessions[self.cursor].get("sessionId", "") + if copy_to_clipboard(session_id): + self.message = f"📋 Copied {session_id}" + else: + self.message = f"📋 {session_id}" + elif key in ("esc", "left"): + return + elif key == "f": + self.message = open_feedback_url() + elif key == "q": + clear_screen() + return diff --git a/src/deadline/client/cli/_groups/_job_tui/_step_list.py b/src/deadline/client/cli/_groups/_job_tui/_step_list.py new file mode 100644 index 000000000..9327023cf --- /dev/null +++ b/src/deadline/client/cli/_groups/_job_tui/_step_list.py @@ -0,0 +1,213 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Step list TUI screen.""" + +from __future__ import annotations + +from typing import Any, Optional + +from rich.table import Table + +from ._common import ( + clear_screen, + console, + copy_to_clipboard, + format_short_id, + get_lifecycle_badge, + get_status_style, + get_terminal_page_size, + open_feedback_url, + read_key, + render_header, + render_help_bar, +) + + +class StepListTUI: + """Paginated step list browser for a job.""" + + def __init__( + self, + farm_id: str, + queue_id: str, + job_id: str, + job_name: str, + job_status: str, + deadline_client: Any, + ) -> None: + self.farm_id = farm_id + self.queue_id = queue_id + self.job_id = job_id + self.job_name = job_name + self.job_status = job_status + self.deadline = deadline_client + self.page_size = get_terminal_page_size() + self.steps: list[dict] = [] + self.cursor: int = 0 + self.next_token: Optional[str] = None + self.prev_tokens: list[Optional[str]] = [] + self.page: int = 0 + self.message: str = "" + self._needs_full_clear: bool = True + + def load_page(self) -> None: + """Fetch one page of steps via list_steps.""" + kwargs: dict = { + "farmId": self.farm_id, + "queueId": self.queue_id, + "jobId": self.job_id, + "maxResults": self.page_size, + } + token = self.prev_tokens[self.page] if self.page < len(self.prev_tokens) else None + if token: + kwargs["nextToken"] = token + response = self.deadline.list_steps(**kwargs) + self.steps = response.get("steps", []) + self.next_token = response.get("nextToken") + + def render(self) -> None: + """Render step list with status, target badge, lifecycle badge, name, task count, ID.""" + clear_screen(full=self._needs_full_clear) + self._needs_full_clear = False + color, icon = get_status_style(self.job_status) + render_header(self.job_name, f"[{color}]{icon} {self.job_status}[/{color}]") + console.print("[dim]📍 Steps[/dim]\n") + + if not self.steps: + console.print("[dim italic] No steps found[/dim italic]") + else: + table = Table(show_header=False, box=None, padding=(0, 1), width=console.width - 1) + table.add_column("", width=3) + table.add_column("Status", width=26, no_wrap=True) + table.add_column("Name", no_wrap=True, overflow="ellipsis") + table.add_column("Tasks", width=12, justify="right", no_wrap=True) + table.add_column("ID", width=14, justify="right", no_wrap=True) + + for i, step in enumerate(self.steps): + self._add_step_row(table, i, step) + console.print(table) + + if self.message: + console.print(f"\n[yellow]{self.message}[/yellow]") + self.message = "" + + console.print() + render_help_bar( + [ + ("↑↓", "nav"), + ("→/Enter", "tasks"), + ("←/Esc", "back"), + ("c", "copy id"), + ("n/p", "page"), + ("r", "refresh"), + ("f", "feedback"), + ("q", "quit"), + ] + ) + + def _add_step_row(self, table: Table, index: int, step: dict) -> None: + """Add a single step row to the table.""" + status = step.get("taskRunStatus", "UNKNOWN") + target = step.get("targetTaskRunStatus", "") + lifecycle = step.get("lifecycleStatus", "CREATE_COMPLETE") + color, icon = get_status_style(status) + name = step.get("name", "Unnamed") + step_id = step.get("stepId", "") + short_id = format_short_id(step_id) + + # Task count from taskRunStatusCounts + counts = step.get("taskRunStatusCounts", {}) + total_tasks = sum(counts.values()) + task_label = f"{total_tasks} task{'s' if total_tasks != 1 else ''}" + + # Build status text + status_text = f"{icon} {status}" + if target and target != status: + target_color, _ = get_status_style(target) + status_text += f" [{target_color}]→{target}[/{target_color}]" + + # Lifecycle badge + badge = get_lifecycle_badge(lifecycle) + if badge: + badge_text, badge_color = badge + name = f"{name} [{badge_color}]{badge_text}[/{badge_color}]" + + if index == self.cursor: + table.add_row( + "[bold cyan]▶[/bold cyan]", + f"[bold {color}]{status_text}[/bold {color}]", + f"[bold reverse] {name} [/bold reverse]", + f"[bold cyan]{task_label}[/bold cyan]", + f"[bold cyan]{short_id}[/bold cyan]", + ) + else: + table.add_row( + " ", + f"[{color}]{status_text}[/{color}]", + name, + f"[dim]{task_label}[/dim]", + f"[dim]{short_id}[/dim]", + ) + + def run(self) -> Optional[tuple[str, ...]]: + """Main loop. Returns ('select', step_id, step_name), ('back', ''), or None.""" + self._needs_full_clear = True + self.prev_tokens = [None] + self.load_page() + + while True: + self.render() + key = read_key() + + if key == "up": + if self.cursor > 0: + self.cursor -= 1 + elif self.page > 0: + self.page -= 1 + self._needs_full_clear = True + self.load_page() + self.cursor = max(0, len(self.steps) - 1) + elif key == "down": + if self.steps and self.cursor < len(self.steps) - 1: + self.cursor += 1 + elif self.steps and self.next_token: + self.page += 1 + self._needs_full_clear = True + if self.page >= len(self.prev_tokens): + self.prev_tokens.append(self.next_token) + self.cursor = 0 + self.load_page() + elif key in ("right", "enter") and self.steps: + step_id = self.steps[self.cursor].get("stepId", "") + step_name = self.steps[self.cursor].get("name", "Unnamed") + return ("select", step_id, step_name) + elif key in ("left", "esc"): + return ("back", "") + elif key == "c" and self.steps: + step_id = self.steps[self.cursor].get("stepId", "") + if copy_to_clipboard(step_id): + self.message = f"📋 Copied {step_id}" + else: + self.message = f"📋 {step_id}" + elif key == "n" and self.next_token: + self.page += 1 + self._needs_full_clear = True + if self.page >= len(self.prev_tokens): + self.prev_tokens.append(self.next_token) + self.cursor = 0 + self.load_page() + elif key == "p" and self.page > 0: + self.page -= 1 + self._needs_full_clear = True + self.cursor = 0 + self.load_page() + elif key == "r": + self._needs_full_clear = True + self.prev_tokens = [None] + self.page = 0 + self.cursor = 0 + self.load_page() + elif key == "f": + self.message = open_feedback_url() + elif key == "q": + clear_screen() + return None diff --git a/src/deadline/client/cli/_groups/_job_tui/_task_list.py b/src/deadline/client/cli/_groups/_job_tui/_task_list.py new file mode 100644 index 000000000..f6739dc5e --- /dev/null +++ b/src/deadline/client/cli/_groups/_job_tui/_task_list.py @@ -0,0 +1,217 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Task list TUI screen.""" + +from __future__ import annotations + +from typing import Any, Optional + +from rich.table import Table + +from ._common import ( + clear_screen, + console, + copy_to_clipboard, + format_short_id, + get_status_style, + get_terminal_page_size, + open_feedback_url, + read_key, + render_header, + render_help_bar, +) + + +class TaskListTUI: + """Paginated task list browser for a step.""" + + def __init__( + self, + farm_id: str, + queue_id: str, + job_id: str, + job_name: str, + step_id: str, + step_name: str, + deadline_client: Any, + ) -> None: + self.farm_id = farm_id + self.queue_id = queue_id + self.job_id = job_id + self.job_name = job_name + self.step_id = step_id + self.step_name = step_name + self.deadline = deadline_client + self.page_size = get_terminal_page_size() + self.tasks: list[dict] = [] + self.cursor: int = 0 + self.next_token: Optional[str] = None + self.prev_tokens: list[Optional[str]] = [] + self.page: int = 0 + self.message: str = "" + self._needs_full_clear: bool = True + + def load_page(self) -> None: + """Fetch one page of tasks via list_tasks.""" + kwargs: dict = { + "farmId": self.farm_id, + "queueId": self.queue_id, + "jobId": self.job_id, + "stepId": self.step_id, + "maxResults": self.page_size, + } + token = self.prev_tokens[self.page] if self.page < len(self.prev_tokens) else None + if token: + kwargs["nextToken"] = token + response = self.deadline.list_tasks(**kwargs) + self.tasks = response.get("tasks", []) + self.next_token = response.get("nextToken") + + @staticmethod + def _format_task_params(task: dict) -> str: + """Format task parameters as a summary string like 'Frame=1'.""" + params = task.get("parameters", {}) + if not params: + return task.get("taskId", "")[-8:] + parts: list[str] = [] + for key, value_dict in params.items(): + # value_dict is like {"int": "1"} or {"string": "foo"} + val = next(iter(value_dict.values()), "") + parts.append(f"{key}={val}") + return ", ".join(parts) + + def render(self) -> None: + """Render task list with status, target badge, params, ID.""" + clear_screen(full=self._needs_full_clear) + self._needs_full_clear = False + render_header( + f"{self.job_name} › {self.step_name}", + "", + ) + console.print("[dim]📍 Tasks[/dim]\n") + + if not self.tasks: + console.print("[dim italic] No tasks found[/dim italic]") + else: + table = Table(show_header=False, box=None, padding=(0, 1), width=console.width - 1) + table.add_column("", width=3) + table.add_column("Status", width=22, no_wrap=True) + table.add_column("Parameters", no_wrap=True, overflow="ellipsis") + table.add_column("ID", width=14, justify="right", no_wrap=True) + + for i, task in enumerate(self.tasks): + self._add_task_row(table, i, task) + console.print(table) + + if self.message: + console.print(f"\n[yellow]{self.message}[/yellow]") + self.message = "" + + console.print() + render_help_bar( + [ + ("↑↓", "nav"), + ("←/Esc", "back"), + ("l", "sessions"), + ("a", "attachments"), + ("c", "copy id"), + ("n/p", "page"), + ("r", "refresh"), + ("f", "feedback"), + ("q", "quit"), + ] + ) + + def _add_task_row(self, table: Table, index: int, task: dict) -> None: + """Add a single task row to the table.""" + status = task.get("runStatus", "UNKNOWN") + target = task.get("targetRunStatus", "") + color, icon = get_status_style(status) + param_summary = self._format_task_params(task) + task_id = task.get("taskId", "") + short_id = format_short_id(task_id) + + status_text = f"{icon} {status}" + if target and target != status: + target_color, _ = get_status_style(target) + status_text += f" [{target_color}]→{target}[/{target_color}]" + + if index == self.cursor: + table.add_row( + "[bold cyan]▶[/bold cyan]", + f"[bold {color}]{status_text}[/bold {color}]", + f"[bold reverse] {param_summary} [/bold reverse]", + f"[bold cyan]{short_id}[/bold cyan]", + ) + else: + table.add_row( + " ", + f"[{color}]{status_text}[/{color}]", + param_summary, + f"[dim]{short_id}[/dim]", + ) + + def run(self) -> Optional[tuple[str, str]]: + """Main loop. Returns ('sessions', task_id), ('attachments', task_id), ('back', ''), or None.""" + self._needs_full_clear = True + self.prev_tokens = [None] + self.load_page() + + while True: + self.render() + key = read_key() + + if key == "up": + if self.cursor > 0: + self.cursor -= 1 + elif self.page > 0: + self.page -= 1 + self._needs_full_clear = True + self.load_page() + self.cursor = max(0, len(self.tasks) - 1) + elif key == "down": + if self.tasks and self.cursor < len(self.tasks) - 1: + self.cursor += 1 + elif self.tasks and self.next_token: + self.page += 1 + self._needs_full_clear = True + if self.page >= len(self.prev_tokens): + self.prev_tokens.append(self.next_token) + self.cursor = 0 + self.load_page() + elif key in ("left", "esc"): + return ("back", "") + elif key == "l" and self.tasks: + task_id = self.tasks[self.cursor].get("taskId", "") + return ("sessions", task_id) + elif key == "a" and self.tasks: + task_id = self.tasks[self.cursor].get("taskId", "") + return ("attachments", task_id) + elif key == "c" and self.tasks: + task_id = self.tasks[self.cursor].get("taskId", "") + if copy_to_clipboard(task_id): + self.message = f"📋 Copied {task_id}" + else: + self.message = f"📋 {task_id}" + elif key == "n" and self.next_token: + self.page += 1 + self._needs_full_clear = True + if self.page >= len(self.prev_tokens): + self.prev_tokens.append(self.next_token) + self.cursor = 0 + self.load_page() + elif key == "p" and self.page > 0: + self.page -= 1 + self._needs_full_clear = True + self.cursor = 0 + self.load_page() + elif key == "r": + self._needs_full_clear = True + self.prev_tokens = [None] + self.page = 0 + self.cursor = 0 + self.load_page() + elif key == "f": + self.message = open_feedback_url() + elif key == "q": + clear_screen() + return None diff --git a/src/deadline/client/cli/_groups/browse_group.py b/src/deadline/client/cli/_groups/browse_group.py new file mode 100644 index 000000000..52b70549a --- /dev/null +++ b/src/deadline/client/cli/_groups/browse_group.py @@ -0,0 +1,101 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""CLI entry point for the interactive job attachments browser. + +The TUI rendering code lives in ``_browse_tui.py`` and requires the ``rich`` +package (install via ``pip install 'deadline[tui]'``). This module only +registers the Click command and does the conditional import at invocation time +so the rest of the CLI works without ``rich`` installed. +""" + +import sys +from configparser import ConfigParser +from typing import Optional + +import click + +from deadline.client import api +from deadline.client.config import config_file +from deadline.job_attachments.models import JobAttachmentS3Settings + +from .._common import _apply_cli_options_to_config, _handle_error +from .._main import deadline as main + + +def _check_tui_installed() -> None: + """Raise a clear ClickException if the ``rich`` package is missing.""" + try: + import rich # noqa: F401 + except ImportError: + raise click.ClickException( + "The TUI requires the 'rich' package. Install it with: pip install 'deadline[tui]'" + ) + + +@main.command(name="browse") +@click.option("--profile", help="The AWS profile to use.") +@click.option("--farm-id", help="The AWS Deadline Cloud Farm to use.") +@click.option("--queue-id", help="The AWS Deadline Cloud Queue to use.") +@click.option( + "--job-id", help="The AWS Deadline Cloud Job to browse. If omitted, shows job selector." +) +@_handle_error +def cli_browse(**args): + """ + Interactively browse input and output files of a Deadline Cloud job. + + If --job-id is not provided, shows a job selection screen. + Navigate with arrow keys, Enter to select, 'd' to download, 'q' to quit. + + Requires the [tui] extra: pip install 'deadline[tui]' + """ + _check_tui_installed() + from ._browse_tui import JobBrowserTUI, JobSelectorTUI + + config: Optional[ConfigParser] = _apply_cli_options_to_config( + required_options={"farm_id", "queue_id"}, **args + ) + farm_id = config_file.get_setting("defaults.farm_id", config=config) + queue_id = config_file.get_setting("defaults.queue_id", config=config) + job_id = args.get("job_id") or config_file.get_setting("defaults.job_id", config=config) + + if not sys.stdin.isatty(): + raise click.ClickException("This command requires an interactive terminal") + + boto3_session = api.get_boto3_session(config=config) + deadline = api.get_boto3_client("deadline", config=config) + + # If no job_id, show job selector + if not job_id: + selector = JobSelectorTUI(farm_id, queue_id, deadline) + job_id = selector.run() + if not job_id: + return + + job = deadline.get_job(farmId=farm_id, queueId=queue_id, jobId=job_id) + job_name = job["name"] + job_status = job.get("taskRunStatus", "UNKNOWN") + + queue = deadline.get_queue(farmId=farm_id, queueId=queue_id) + if "jobAttachmentSettings" not in queue: + raise click.ClickException("Queue does not have job attachments configured") + + s3_settings = JobAttachmentS3Settings(**queue["jobAttachmentSettings"]) + queue_role_session = api.get_queue_user_boto3_session( + deadline=deadline, + config=config, + farm_id=farm_id, + queue_id=queue_id, + queue_display_name=queue["displayName"], + ) + + browser = JobBrowserTUI( + farm_id=farm_id, + queue_id=queue_id, + job_id=job_id, + job_name=job_name, + job_status=job_status, + boto3_session=boto3_session, + queue_role_session=queue_role_session, + s3_settings=s3_settings, + ) + browser.run() diff --git a/src/deadline/client/cli/_groups/job_group.py b/src/deadline/client/cli/_groups/job_group.py index 1ca2e1ff4..6cd23042e 100644 --- a/src/deadline/client/cli/_groups/job_group.py +++ b/src/deadline/client/cli/_groups/job_group.py @@ -1838,3 +1838,201 @@ def duration_of(resource): if trace_file: with open(trace_file, "w", encoding="utf8") as f: json.dump(tracing_data, f, indent=1) + + +def _tui_setup_attachment_browser( + config: Optional[ConfigParser], + farm_id: str, + queue_id: str, + job_id: str, + deadline: Any, + step_id: Optional[str] = None, + task_id: Optional[str] = None, +) -> None: + """Set up and launch the attachment browser for a job or task.""" + from ._job_tui._attachment_browser import AttachmentBrowserTUI + + job = deadline.get_job(farmId=farm_id, queueId=queue_id, jobId=job_id) + job_name = job["name"] + job_status = job.get("taskRunStatus", "UNKNOWN") + + queue = deadline.get_queue(farmId=farm_id, queueId=queue_id) + if "jobAttachmentSettings" not in queue: + click.echo("Queue does not have job attachments configured") + return + + boto3_session = api.get_boto3_session(config=config) + s3_settings = JobAttachmentS3Settings(**queue["jobAttachmentSettings"]) + queue_role_session = api.get_queue_user_boto3_session( + deadline=deadline, + config=config, + farm_id=farm_id, + queue_id=queue_id, + queue_display_name=queue["displayName"], + ) + + browser = AttachmentBrowserTUI( + farm_id=farm_id, + queue_id=queue_id, + job_id=job_id, + job_name=job_name, + job_status=job_status, + boto3_session=boto3_session, + queue_role_session=queue_role_session, + s3_settings=s3_settings, + step_id=step_id, + task_id=task_id, + ) + browser.run() + + +def _tui_task_loop( + farm_id: str, + queue_id: str, + job_id: str, + job_name: str, + step_id: str, + step_name: str, + deadline: Any, + config: Optional[ConfigParser], +) -> Optional[str]: + """Run the task list loop. Returns 'quit' or None (back to steps).""" + from ._job_tui._session_list import SessionListTUI + from ._job_tui._task_list import TaskListTUI + + tui = TaskListTUI( + farm_id=farm_id, + queue_id=queue_id, + job_id=job_id, + job_name=job_name, + step_id=step_id, + step_name=step_name, + deadline_client=deadline, + ) + while True: + result = tui.run() + if result is None: + return "quit" + action, task_id = result + if action == "back": + return None + elif action == "sessions": + task = deadline.get_task( + farmId=farm_id, queueId=queue_id, jobId=job_id, stepId=step_id, taskId=task_id + ) + params = task.get("parameters", {}) + label = ( + ", ".join(f"{k}={next(iter(v.values()), '')}" for k, v in params.items()) + or task_id[-8:] + ) + session_tui = SessionListTUI( + farm_id=farm_id, + queue_id=queue_id, + job_id=job_id, + step_id=step_id, + task_id=task_id, + task_label=label, + deadline_client=deadline, + ) + session_tui.run() + elif action == "attachments": + _tui_setup_attachment_browser( + config, farm_id, queue_id, job_id, deadline, step_id, task_id + ) + + +def _tui_step_loop( + farm_id: str, + queue_id: str, + job_id: str, + deadline: Any, + config: Optional[ConfigParser], +) -> Optional[str]: + """Run the step list loop. Returns 'quit' or None (back to jobs).""" + from ._job_tui._step_list import StepListTUI + + job = deadline.get_job(farmId=farm_id, queueId=queue_id, jobId=job_id) + job_name = job["name"] + job_status = job.get("taskRunStatus", "UNKNOWN") + + tui = StepListTUI( + farm_id=farm_id, + queue_id=queue_id, + job_id=job_id, + job_name=job_name, + job_status=job_status, + deadline_client=deadline, + ) + while True: + result = tui.run() + if result is None: + return "quit" + action = result[0] + if action == "back": + return None + elif action == "select": + step_id = result[1] + step_name = result[2] + task_result = _tui_task_loop( + farm_id, queue_id, job_id, job_name, step_id, step_name, deadline, config + ) + if task_result == "quit": + return "quit" + + +def _tui_main_loop( + farm_id: str, + queue_id: str, + deadline: Any, + config: Optional[ConfigParser], +) -> None: + """Run the main job list loop with navigation into steps/tasks.""" + from ._job_tui._job_list import JobListTUI + + tui = JobListTUI(farm_id=farm_id, queue_id=queue_id, deadline_client=deadline) + while True: + result = tui.run() + if result is None: + return + action, job_id = result + if action == "select": + step_result = _tui_step_loop(farm_id, queue_id, job_id, deadline, config) + if step_result == "quit": + return + elif action == "attachments": + _tui_setup_attachment_browser(config, farm_id, queue_id, job_id, deadline) + + +@cli_job.command(name="tui") +@click.option("--profile", help="The AWS profile to use.") +@click.option("--farm-id", help="The farm to use.") +@click.option("--queue-id", help="The queue to use.") +@_handle_error +def job_tui(**args): + """ + Interactive TUI for browsing jobs, steps, tasks, sessions, and attachments. + + Navigate with arrow keys: ↑/↓ to browse lists, →/← to drill in/out of the + job → step → task hierarchy. Press a for job attachments, j for task attachments, + l for sessions, c to copy IDs, n/p for pagination, q to quit. + + Requires the [tui] extra: pip install 'deadline[tui]' + """ + from .browse_group import _check_tui_installed + + _check_tui_installed() + from ._job_tui._common import enter_alt_screen, leave_alt_screen + + if not sys.stdin.isatty(): + raise click.ClickException("This command requires an interactive terminal") + + config = _apply_cli_options_to_config(required_options={"farm_id", "queue_id"}, **args) + farm_id = config_file.get_setting("defaults.farm_id", config=config) + queue_id = config_file.get_setting("defaults.queue_id", config=config) + deadline = api.get_boto3_client("deadline", config=config) + + enter_alt_screen() + try: + _tui_main_loop(farm_id, queue_id, deadline, config) + finally: + leave_alt_screen() diff --git a/test/unit/deadline_client/cli/test_cli_browse_group.py b/test/unit/deadline_client/cli/test_cli_browse_group.py new file mode 100644 index 000000000..3d38f9d99 --- /dev/null +++ b/test/unit/deadline_client/cli/test_cli_browse_group.py @@ -0,0 +1,26 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Tests for browse_group.py CLI entry point.""" + +from __future__ import annotations + +from unittest.mock import patch + +import click +import pytest + +pytest.importorskip("rich", reason="TUI tests require the 'rich' package (deadline[tui])") + +from deadline.client.cli._groups.browse_group import _check_tui_installed + + +class TestCheckTuiInstalled: + def test_rich_available(self): + """Should not raise when rich is installed.""" + _check_tui_installed() # rich is installed in test env + + @patch.dict("sys.modules", {"rich": None}) + def test_rich_missing(self): + """Should raise ClickException when rich is not importable.""" + with patch("builtins.__import__", side_effect=ImportError): + with pytest.raises(click.ClickException, match="rich"): + _check_tui_installed() diff --git a/test/unit/deadline_client/cli/test_cli_browse_tui.py b/test/unit/deadline_client/cli/test_cli_browse_tui.py new file mode 100644 index 000000000..b03839cc3 --- /dev/null +++ b/test/unit/deadline_client/cli/test_cli_browse_tui.py @@ -0,0 +1,399 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Tests for _browse_tui utility functions and classes.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +pytest.importorskip("rich", reason="TUI tests require the 'rich' package (deadline[tui])") + +from deadline.client.cli._groups._browse_tui import ( + NodeType, + TreeNode, + build_file_tree, + get_all_files_under, + get_node_icon, + is_image, + load_input_manifests, + load_output_manifests, + render_breadcrumb, + render_file_list, + render_header, +) + + +class TestIsImage: + def test_png(self): + assert is_image("photo.png") is True + + def test_jpg(self): + assert is_image("photo.JPG") is True + + def test_txt(self): + assert is_image("readme.txt") is False + + def test_no_extension(self): + assert is_image("file") is False + + +class TestGetAllFilesUnder: + def test_single_file(self): + f = TreeNode("a.txt", NodeType.FILE) + assert get_all_files_under(f) == [f] + + def test_folder_with_files(self): + folder = TreeNode("dir", NodeType.FOLDER) + f1 = TreeNode("a.txt", NodeType.FILE, parent=folder) + f2 = TreeNode("b.txt", NodeType.FILE, parent=folder) + folder.children = [f1, f2] + assert get_all_files_under(folder) == [f1, f2] + + def test_nested(self): + root = TreeNode("root", NodeType.ROOT) + sub = TreeNode("sub", NodeType.FOLDER, parent=root) + f = TreeNode("a.txt", NodeType.FILE, parent=sub) + sub.children = [f] + root.children = [sub] + assert get_all_files_under(root) == [f] + + def test_empty_folder(self): + folder = TreeNode("dir", NodeType.FOLDER) + assert get_all_files_under(folder) == [] + + +class TestBuildFileTree: + def test_single_file(self): + manifest = MagicMock() + mp = MagicMock() + mp.path = "file.txt" + mp.size = 100 + mp.hash = "abc123" + manifest.paths = [mp] + tree = build_file_tree(manifest, "/root") + assert tree.name == "/root" + assert tree.node_type == NodeType.MANIFEST_ROOT + assert len(tree.children) == 1 + assert tree.children[0].name == "file.txt" + assert tree.children[0].node_type == NodeType.FILE + assert tree.children[0].size == 100 + + def test_nested_path(self): + manifest = MagicMock() + mp = MagicMock() + mp.path = "dir/sub/file.txt" + mp.size = 50 + mp.hash = "def456" + manifest.paths = [mp] + tree = build_file_tree(manifest, "/root") + assert tree.children[0].name == "dir" + assert tree.children[0].node_type == NodeType.FOLDER + assert tree.children[0].children[0].name == "sub" + assert tree.children[0].children[0].children[0].name == "file.txt" + + def test_shared_prefix(self): + manifest = MagicMock() + mp1 = MagicMock() + mp1.path = "dir/a.txt" + mp1.size = 10 + mp1.hash = "h1" + mp2 = MagicMock() + mp2.path = "dir/b.txt" + mp2.size = 20 + mp2.hash = "h2" + manifest.paths = [mp1, mp2] + tree = build_file_tree(manifest, "/root") + assert len(tree.children) == 1 # single "dir" folder + assert len(tree.children[0].children) == 2 # two files + + def test_empty_manifest(self): + manifest = MagicMock() + manifest.paths = [] + tree = build_file_tree(manifest, "/root") + assert tree.children == [] + + +class TestGetNodeIcon: + def test_image_file(self): + node = TreeNode("photo.png", NodeType.FILE) + assert "🖼️" in get_node_icon(node) + + def test_text_file(self): + node = TreeNode("readme.txt", NodeType.FILE) + assert get_node_icon(node) == "📝" + + def test_log_file(self): + node = TreeNode("output.log", NodeType.FILE) + assert get_node_icon(node) == "📝" + + def test_md_file(self): + node = TreeNode("README.md", NodeType.FILE) + assert get_node_icon(node) == "📝" + + def test_generic_file(self): + node = TreeNode("data.bin", NodeType.FILE) + assert get_node_icon(node) == "📄" + + def test_folder(self): + node = TreeNode("dir", NodeType.FOLDER) + assert get_node_icon(node) == "📁" + + def test_manifest_root(self): + node = TreeNode("root", NodeType.MANIFEST_ROOT) + assert get_node_icon(node) == "💾" + + def test_category(self): + node = TreeNode("input", NodeType.CATEGORY) + assert get_node_icon(node) == "📦" + + def test_root(self): + node = TreeNode("job", NodeType.ROOT) + assert get_node_icon(node) == "📂" + + +class TestRenderFunctions: + @patch("deadline.client.cli._groups._browse_tui.console") + def test_render_header(self, mock_console): + render_header("Test Title", "subtitle") + mock_console.print.assert_called_once() + + @patch("deadline.client.cli._groups._browse_tui.console") + def test_render_breadcrumb(self, mock_console): + root = TreeNode("root", NodeType.ROOT) + child = TreeNode("child", NodeType.FOLDER, parent=root) + render_breadcrumb(child) + mock_console.print.assert_called_once() + call_str = str(mock_console.print.call_args) + assert "root" in call_str + assert "child" in call_str + + @patch("deadline.client.cli._groups._browse_tui.console") + def test_render_file_list_empty(self, mock_console): + render_file_list([], 0) + mock_console.print.assert_called_once() + + @patch("deadline.client.cli._groups._browse_tui.console") + def test_render_file_list_with_items(self, mock_console): + f = TreeNode("a.txt", NodeType.FILE, size=100) + folder = TreeNode("dir", NodeType.FOLDER) + render_file_list([f, folder], 0) + mock_console.print.assert_called_once() + + +class TestLoadInputManifests: + @patch("deadline.job_attachments.download.get_manifest_from_s3") + def test_loads_manifests(self, mock_get_manifest): + mock_manifest = MagicMock() + mp = MagicMock() + mp.path = "file.txt" + mp.size = 100 + mp.hash = "abc" + mock_manifest.paths = [mp] + mock_get_manifest.return_value = mock_manifest + + job = { + "attachments": { + "manifests": [{"inputManifestPath": "manifest.json", "rootPath": "/data"}] + } + } + trees = load_input_manifests(job, "prefix", "bucket", MagicMock()) + assert len(trees) == 1 + assert trees[0].name == "/data" + + @patch("deadline.job_attachments.download.get_manifest_from_s3") + def test_skips_empty_input_path(self, mock_get_manifest): + job = {"attachments": {"manifests": [{"inputManifestPath": "", "rootPath": "/data"}]}} + trees = load_input_manifests(job, "prefix", "bucket", MagicMock()) + assert len(trees) == 0 + mock_get_manifest.assert_not_called() + + def test_no_attachments(self): + job: dict = {} + trees = load_input_manifests(job, "prefix", "bucket", MagicMock()) + assert len(trees) == 0 + + @patch("deadline.job_attachments.download.get_manifest_from_s3") + def test_skips_none_manifest(self, mock_get_manifest): + mock_get_manifest.return_value = None + job = { + "attachments": { + "manifests": [{"inputManifestPath": "manifest.json", "rootPath": "/data"}] + } + } + trees = load_input_manifests(job, "prefix", "bucket", MagicMock()) + assert len(trees) == 0 + + +class TestLoadOutputManifests: + @patch("deadline.job_attachments.download.merge_asset_manifests") + @patch("deadline.job_attachments.download.get_output_manifests_by_asset_root") + def test_loads_output_manifests(self, mock_get_output, mock_merge): + mock_manifest = MagicMock() + mp = MagicMock() + mp.path = "output.exr" + mp.size = 5000 + mp.hash = "xyz" + mock_manifest.paths = [mp] + mock_get_output.return_value = {"/output": [mock_manifest]} + mock_merge.return_value = mock_manifest + + trees = load_output_manifests(MagicMock(), "farm-1", "queue-1", "job-1", MagicMock()) + assert len(trees) == 1 + assert trees[0].name == "/output" + + @patch("deadline.job_attachments.download.merge_asset_manifests") + @patch("deadline.job_attachments.download.get_output_manifests_by_asset_root") + def test_skips_none_merged(self, mock_get_output, mock_merge): + mock_get_output.return_value = {"/output": [MagicMock()]} + mock_merge.return_value = None + trees = load_output_manifests(MagicMock(), "farm-1", "queue-1", "job-1", MagicMock()) + assert len(trees) == 0 + + +class TestDownloadSingleFile: + @patch("deadline.client.cli._groups._browse_tui.os") + def test_download(self, mock_os): + from deadline.client.cli._groups._browse_tui import download_single_file + + mock_os.path.join.return_value = "/tmp/file.txt" + session = MagicMock() + s3_settings = MagicMock() + s3_settings.rootPrefix = "prefix" + s3_settings.s3BucketName = "bucket" + node = TreeNode("file.txt", NodeType.FILE, hash="abc123") + result = download_single_file(session, s3_settings, node, "/tmp") + session.client.assert_called_once_with("s3") + assert result == "/tmp/file.txt" + + +class TestShowFileInfo: + @patch("deadline.client.cli._groups._browse_tui.click") + @patch("deadline.client.cli._groups._browse_tui.console") + def test_show_file_info(self, mock_console, mock_click): + from deadline.client.cli._groups._browse_tui import show_file_info + + node = TreeNode("file.txt", NodeType.FILE, path="dir/file.txt", size=1024, hash="abc") + show_file_info(node) + assert mock_console.clear.called + assert mock_click.getchar.called + + +class TestShowManifestList: + @patch("deadline.client.cli._groups._browse_tui.click") + @patch("deadline.client.cli._groups._browse_tui.console") + def test_show_manifest_list(self, mock_console, mock_click): + from deadline.client.cli._groups._browse_tui import show_manifest_list + + root = TreeNode("Job", NodeType.ROOT) + cat = TreeNode("input", NodeType.CATEGORY, parent=root) + manifest = TreeNode("/data", NodeType.MANIFEST_ROOT, parent=cat) + f = TreeNode("file.txt", NodeType.FILE, parent=manifest) + manifest.children = [f] + cat.children = [manifest] + root.children = [cat] + show_manifest_list(root) + assert mock_console.clear.called + + +class TestPreviewFileContent: + @patch("deadline.client.cli._groups._browse_tui.click") + @patch("deadline.client.cli._groups._browse_tui.console") + @patch("deadline.client.cli._groups._browse_tui.download_single_file") + def test_preview_text_file(self, mock_download, mock_console, mock_click, tmp_path): + from deadline.client.cli._groups._browse_tui import preview_file_content + + # Create a temp text file + text_file = tmp_path / "test.txt" + text_file.write_text("hello world") + mock_download.return_value = str(text_file) + + node = TreeNode("test.txt", NodeType.FILE, size=11, hash="abc") + preview_file_content(MagicMock(), MagicMock(), node) + assert mock_console.clear.called + + @patch("deadline.client.cli._groups._browse_tui.click") + @patch("deadline.client.cli._groups._browse_tui.console") + @patch("deadline.client.cli._groups._browse_tui.download_single_file") + def test_preview_binary_file(self, mock_download, mock_console, mock_click, tmp_path): + from deadline.client.cli._groups._browse_tui import preview_file_content + + bin_file = tmp_path / "test.bin" + bin_file.write_bytes(b"\x00\x01\x02\x03" * 10) + mock_download.return_value = str(bin_file) + + node = TreeNode("test.bin", NodeType.FILE, size=40, hash="abc") + preview_file_content(MagicMock(), MagicMock(), node) + assert mock_console.clear.called + + @patch("deadline.client.cli._groups._browse_tui.click") + @patch("deadline.client.cli._groups._browse_tui.console") + @patch("deadline.client.cli._groups._browse_tui.download_single_file") + def test_preview_python_file(self, mock_download, mock_console, mock_click, tmp_path): + from deadline.client.cli._groups._browse_tui import preview_file_content + + py_file = tmp_path / "test.py" + py_file.write_text("print('hello')") + mock_download.return_value = str(py_file) + + node = TreeNode("test.py", NodeType.FILE, size=14, hash="abc") + preview_file_content(MagicMock(), MagicMock(), node) + assert mock_console.clear.called + + @patch("deadline.client.cli._groups._browse_tui.click") + @patch("deadline.client.cli._groups._browse_tui.console") + @patch("deadline.client.cli._groups._browse_tui.download_single_file") + def test_preview_image_file(self, mock_download, mock_console, mock_click, tmp_path): + from deadline.client.cli._groups._browse_tui import preview_file_content + + img_file = tmp_path / "test.png" + img_file.write_bytes(b"\x89PNG") + mock_download.return_value = str(img_file) + + node = TreeNode("test.png", NodeType.FILE, size=4, hash="abc") + preview_file_content(MagicMock(), MagicMock(), node) + assert mock_console.clear.called + + +class TestOpenImageViewer: + @patch("deadline.client.cli._groups._browse_tui.subprocess") + @patch( + "deadline.client.cli._groups._browse_tui.download_single_file", return_value="/tmp/img.png" + ) + def test_open_image_darwin(self, mock_download, mock_subprocess): + from deadline.client.cli._groups._browse_tui import open_image_viewer + + with patch("deadline.client.cli._groups._browse_tui.sys") as mock_sys: + mock_sys.platform = "darwin" + open_image_viewer(MagicMock(), MagicMock(), TreeNode("img.png", NodeType.FILE)) + mock_subprocess.run.assert_called_once_with(["open", "/tmp/img.png"]) + + @patch("deadline.client.cli._groups._browse_tui.subprocess") + @patch( + "deadline.client.cli._groups._browse_tui.download_single_file", return_value="/tmp/img.png" + ) + def test_open_image_linux(self, mock_download, mock_subprocess): + from deadline.client.cli._groups._browse_tui import open_image_viewer + + with patch("deadline.client.cli._groups._browse_tui.sys") as mock_sys: + mock_sys.platform = "linux" + open_image_viewer(MagicMock(), MagicMock(), TreeNode("img.png", NodeType.FILE)) + mock_subprocess.run.assert_called_once_with(["xdg-open", "/tmp/img.png"]) + + +class TestDownloadFolder: + @patch("deadline.client.cli._groups._browse_tui.console") + def test_download_folder(self, mock_console): + from deadline.client.cli._groups._browse_tui import download_folder + + session = MagicMock() + s3_settings = MagicMock() + s3_settings.rootPrefix = "prefix" + s3_settings.s3BucketName = "bucket" + + folder = TreeNode("dir", NodeType.FOLDER) + f1 = TreeNode("a.txt", NodeType.FILE, path="dir/a.txt", hash="h1", parent=folder) + folder.children = [f1] + + count = download_folder(session, s3_settings, folder, "/tmp/dest") + assert count == 1 diff --git a/test/unit/deadline_client/cli/test_cli_job_tui_attachment_browser.py b/test/unit/deadline_client/cli/test_cli_job_tui_attachment_browser.py new file mode 100644 index 000000000..904c7fdae --- /dev/null +++ b/test/unit/deadline_client/cli/test_cli_job_tui_attachment_browser.py @@ -0,0 +1,348 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Tests for AttachmentBrowserTUI.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, call, patch + +import pytest + +pytest.importorskip("rich", reason="TUI tests require the 'rich' package (deadline[tui])") + +from deadline.client.cli._groups._job_tui._attachment_browser import AttachmentBrowserTUI +from deadline.client.cli._groups._browse_tui import NodeType, TreeNode + + +@pytest.fixture +def mock_s3_settings(): + settings = MagicMock() + settings.rootPrefix = "root/prefix" + settings.s3BucketName = "test-bucket" + return settings + + +@pytest.fixture +def mock_sessions(): + boto3_session = MagicMock() + queue_role_session = MagicMock() + # Mock the deadline client returned by boto3_session.client() + mock_deadline = MagicMock() + mock_deadline.get_job.return_value = { + "name": "Test Job", + "attachments": {"manifests": []}, + } + boto3_session.client.return_value = mock_deadline + return boto3_session, queue_role_session + + +class TestAttachmentBrowserTUI: + @patch( + "deadline.client.cli._groups._job_tui._attachment_browser.load_output_manifests", + return_value=[], + ) + @patch( + "deadline.client.cli._groups._job_tui._attachment_browser.load_input_manifests", + return_value=[], + ) + @patch("deadline.client.cli._groups._job_tui._attachment_browser.read_key") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.console") + def test_run_esc_returns( + self, + mock_console, + mock_read_key, + mock_load_input, + mock_load_output, + mock_sessions, + mock_s3_settings, + ): + mock_read_key.return_value = "esc" + boto3_session, queue_role_session = mock_sessions + tui = AttachmentBrowserTUI( + "farm-1", + "queue-1", + "job-1", + "Test Job", + "SUCCEEDED", + boto3_session, + queue_role_session, + mock_s3_settings, + ) + tui.run() # Should return without error + + @patch( + "deadline.client.cli._groups._job_tui._attachment_browser.load_output_manifests", + return_value=[], + ) + @patch( + "deadline.client.cli._groups._job_tui._attachment_browser.load_input_manifests", + return_value=[], + ) + @patch("deadline.client.cli._groups._job_tui._attachment_browser.read_key") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.console") + def test_run_quit( + self, + mock_console, + mock_read_key, + mock_load_input, + mock_load_output, + mock_sessions, + mock_s3_settings, + ): + mock_read_key.return_value = "q" + boto3_session, queue_role_session = mock_sessions + tui = AttachmentBrowserTUI( + "farm-1", + "queue-1", + "job-1", + "Test Job", + "SUCCEEDED", + boto3_session, + queue_role_session, + mock_s3_settings, + ) + tui.run() # Should return without error + + @patch( + "deadline.client.cli._groups._job_tui._attachment_browser.load_output_manifests", + return_value=[], + ) + @patch( + "deadline.client.cli._groups._job_tui._attachment_browser.load_input_manifests", + return_value=[], + ) + def test_load_manifests( + self, mock_load_input, mock_load_output, mock_sessions, mock_s3_settings + ): + boto3_session, queue_role_session = mock_sessions + tui = AttachmentBrowserTUI( + "farm-1", + "queue-1", + "job-1", + "Test Job", + "SUCCEEDED", + boto3_session, + queue_role_session, + mock_s3_settings, + ) + tui.load_manifests() + assert len(tui.root.children) == 2 # input + output categories + assert tui.root.children[0].name == "input" + assert tui.root.children[1].name == "output" + + +def _build_test_tree() -> TreeNode: + """Build a small tree: root -> [input(category) -> [folder -> [file]], output(category)].""" + root = TreeNode(name="Job", node_type=NodeType.ROOT) + input_node = TreeNode(name="input", node_type=NodeType.CATEGORY, parent=root) + output_node = TreeNode(name="output", node_type=NodeType.CATEGORY, parent=root) + root.children = [input_node, output_node] + + folder = TreeNode(name="renders", node_type=NodeType.FOLDER, parent=input_node) + input_node.children = [folder] + + file_node = TreeNode(name="frame001.exr", node_type=NodeType.FILE, size=1024, parent=folder) + folder.children = [file_node] + + return root + + +class TestAttachmentBrowserScreenClearing: + """Tests for _needs_full_clear flag behavior during folder navigation.""" + + @patch( + "deadline.client.cli._groups._job_tui._attachment_browser.load_output_manifests", + return_value=[], + ) + @patch( + "deadline.client.cli._groups._job_tui._attachment_browser.load_input_manifests", + return_value=[], + ) + @patch("deadline.client.cli._groups._job_tui._attachment_browser.clear_screen") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.read_key") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.console") + def test_first_render_uses_hard_clear( + self, + mock_console, + mock_read_key, + mock_clear, + mock_load_input, + mock_load_output, + mock_sessions, + mock_s3_settings, + ): + """First render after run() should call clear_screen(full=True).""" + mock_read_key.return_value = "esc" + boto3_session, queue_role_session = mock_sessions + tui = AttachmentBrowserTUI( + "farm-1", + "queue-1", + "job-1", + "Test Job", + "SUCCEEDED", + boto3_session, + queue_role_session, + mock_s3_settings, + ) + tui.run() + assert mock_clear.call_args_list[0] == call(full=True) + + @patch( + "deadline.client.cli._groups._job_tui._attachment_browser.load_output_manifests", + return_value=[], + ) + @patch( + "deadline.client.cli._groups._job_tui._attachment_browser.load_input_manifests", + return_value=[], + ) + @patch("deadline.client.cli._groups._job_tui._attachment_browser.clear_screen") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.read_key") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.console") + def test_scrolling_uses_soft_clear( + self, + mock_console, + mock_read_key, + mock_clear, + mock_load_input, + mock_load_output, + mock_sessions, + mock_s3_settings, + ): + """Up/down scrolling within the same folder should use soft clear.""" + mock_read_key.side_effect = ["down", "up", "esc"] + boto3_session, queue_role_session = mock_sessions + tui = AttachmentBrowserTUI( + "farm-1", + "queue-1", + "job-1", + "Test Job", + "SUCCEEDED", + boto3_session, + queue_role_session, + mock_s3_settings, + ) + tui.run() + # First render: hard clear, subsequent renders: soft clear + assert mock_clear.call_args_list[0] == call(full=True) + assert mock_clear.call_args_list[1] == call(full=False) + assert mock_clear.call_args_list[2] == call(full=False) + + @patch("deadline.client.cli._groups._job_tui._attachment_browser.clear_screen") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.read_key") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.console") + def test_entering_folder_triggers_hard_clear( + self, + mock_console, + mock_read_key, + mock_clear, + mock_sessions, + mock_s3_settings, + ): + """Navigating into a subfolder (right/enter) should trigger hard clear.""" + # Navigate: right (enter input category), then esc + mock_read_key.side_effect = ["right", "esc"] + boto3_session, queue_role_session = mock_sessions + tui = AttachmentBrowserTUI( + "farm-1", + "queue-1", + "job-1", + "Test Job", + "SUCCEEDED", + boto3_session, + queue_role_session, + mock_s3_settings, + ) + # Pre-populate tree and skip load_manifests + tui.root = _build_test_tree() + tui.current_node = tui.root + tui.load_manifests = lambda: None # type: ignore[assignment] + tui.run() + # call 0: initial render (full=True) + # call 1: render after entering folder (full=True again) + assert mock_clear.call_args_list[0] == call(full=True) + assert mock_clear.call_args_list[1] == call(full=True) + + @patch("deadline.client.cli._groups._job_tui._attachment_browser.clear_screen") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.read_key") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.console") + def test_leaving_folder_triggers_hard_clear( + self, + mock_console, + mock_read_key, + mock_clear, + mock_sessions, + mock_s3_settings, + ): + """Navigating back to parent (left) should trigger hard clear.""" + # Navigate: right (enter input), left (back to root), esc + mock_read_key.side_effect = ["right", "left", "esc"] + boto3_session, queue_role_session = mock_sessions + tui = AttachmentBrowserTUI( + "farm-1", + "queue-1", + "job-1", + "Test Job", + "SUCCEEDED", + boto3_session, + queue_role_session, + mock_s3_settings, + ) + tui.root = _build_test_tree() + tui.current_node = tui.root + tui.load_manifests = lambda: None # type: ignore[assignment] + tui.run() + # call 0: initial render (full=True) + # call 1: after entering folder (full=True) + # call 2: after leaving folder (full=True) + assert mock_clear.call_args_list[0] == call(full=True) + assert mock_clear.call_args_list[1] == call(full=True) + assert mock_clear.call_args_list[2] == call(full=True) + + def test_needs_full_clear_initial_value(self, mock_sessions, mock_s3_settings): + """_needs_full_clear should be True after construction.""" + boto3_session, queue_role_session = mock_sessions + tui = AttachmentBrowserTUI( + "farm-1", + "queue-1", + "job-1", + "Test Job", + "SUCCEEDED", + boto3_session, + queue_role_session, + mock_s3_settings, + ) + assert tui._needs_full_clear is True + + @patch("deadline.client.cli._groups._job_tui._attachment_browser.clear_screen") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.read_key") + @patch("deadline.client.cli._groups._job_tui._attachment_browser.console") + def test_deep_navigation_round_trip( + self, + mock_console, + mock_read_key, + mock_clear, + mock_sessions, + mock_s3_settings, + ): + """Navigate root→input→renders→(back)→(back)→esc: each folder change is hard clear.""" + # right=enter input, right=enter renders, left=back to input, left=back to root, esc + mock_read_key.side_effect = ["right", "right", "left", "left", "esc"] + boto3_session, queue_role_session = mock_sessions + tui = AttachmentBrowserTUI( + "farm-1", + "queue-1", + "job-1", + "Test Job", + "SUCCEEDED", + boto3_session, + queue_role_session, + mock_s3_settings, + ) + tui.root = _build_test_tree() + tui.current_node = tui.root + tui.load_manifests = lambda: None # type: ignore[assignment] + tui.run() + # All 5 renders should use hard clear (each is a folder transition) + for i in range(5): + assert mock_clear.call_args_list[i] == call(full=True), ( + f"Render {i} should be hard clear" + ) diff --git a/test/unit/deadline_client/cli/test_cli_job_tui_common.py b/test/unit/deadline_client/cli/test_cli_job_tui_common.py new file mode 100644 index 000000000..1b0fca571 --- /dev/null +++ b/test/unit/deadline_client/cli/test_cli_job_tui_common.py @@ -0,0 +1,289 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Tests for job TUI common utilities.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +pytest.importorskip("rich", reason="TUI tests require the 'rich' package (deadline[tui])") + +from unittest.mock import patch + +from deadline.client.cli._groups._job_tui._common import ( + clear_screen, + copy_to_clipboard, + enter_alt_screen, + format_short_id, + format_size, + format_time_ago, + get_lifecycle_badge, + get_status_style, + get_terminal_page_size, + leave_alt_screen, + open_feedback_url, + render_header, + render_help_bar, +) + + +class TestFormatSize: + def test_bytes(self): + assert format_size(500) == "500.0 B" + + def test_kilobytes(self): + assert format_size(2048) == "2.0 KB" + + def test_megabytes(self): + assert format_size(5 * 1024 * 1024) == "5.0 MB" + + def test_gigabytes(self): + assert format_size(3 * 1024 * 1024 * 1024) == "3.0 GB" + + def test_zero(self): + assert format_size(0) == "0.0 B" + + +class TestFormatTimeAgo: + def test_none(self): + assert format_time_ago(None) == "" + + def test_just_now(self): + now = datetime.now(timezone.utc) + assert format_time_ago(now) == "just now" + + def test_minutes(self): + dt = datetime.now(timezone.utc) - timedelta(minutes=5) + assert format_time_ago(dt) == "5m ago" + + def test_hours(self): + dt = datetime.now(timezone.utc) - timedelta(hours=3) + assert format_time_ago(dt) == "3h ago" + + def test_days(self): + dt = datetime.now(timezone.utc) - timedelta(days=2) + assert format_time_ago(dt) == "2d ago" + + +class TestGetStatusStyle: + def test_succeeded(self): + color, icon = get_status_style("SUCCEEDED") + assert color == "green" + assert icon == "✓" + + def test_running(self): + color, icon = get_status_style("RUNNING") + assert color == "yellow" + + def test_failed(self): + color, icon = get_status_style("FAILED") + assert color == "red" + assert icon == "✗" + + def test_canceled(self): + color, icon = get_status_style("CANCELED") + assert color == "red" + + def test_pending(self): + color, icon = get_status_style("PENDING") + assert color == "blue" + + def test_ready(self): + color, icon = get_status_style("READY") + assert color == "cyan" + + def test_suspended(self): + color, icon = get_status_style("SUSPENDED") + assert color == "magenta" + + def test_interrupting(self): + color, icon = get_status_style("INTERRUPTING") + assert color == "yellow" + assert icon == "⚡" + + def test_not_compatible(self): + color, icon = get_status_style("NOT_COMPATIBLE") + assert color == "red" + assert icon == "⚠" + + def test_assigned(self): + color, icon = get_status_style("ASSIGNED") + assert color == "yellow" + + def test_starting(self): + color, icon = get_status_style("STARTING") + assert color == "yellow" + + def test_scheduled(self): + color, icon = get_status_style("SCHEDULED") + assert color == "yellow" + + def test_unknown(self): + color, icon = get_status_style("SOMETHING_ELSE") + assert color == "dim" + + +class TestFormatShortId: + def test_job_id(self): + assert format_short_id("job-abcdef1234567890abcdef1234567890") == "job-abcdef" + + def test_step_id(self): + assert format_short_id("step-abcdef1234567890abcdef1234567890") == "step-abcdef" + + def test_task_id(self): + assert format_short_id("task-abcdef1234567890abcdef1234567890") == "task-abcdef" + + def test_no_dash(self): + assert format_short_id("nohex") == "nohex" + + def test_short_hex(self): + assert format_short_id("job-abc") == "job-abc" + + +class TestGetLifecycleBadge: + def test_create_complete(self): + assert get_lifecycle_badge("CREATE_COMPLETE") is None + + def test_update_in_progress(self): + result = get_lifecycle_badge("UPDATE_IN_PROGRESS") + assert result is not None + badge, color = result + assert badge == "[UPDATING]" + assert color == "yellow" + + def test_update_failed(self): + result = get_lifecycle_badge("UPDATE_FAILED") + assert result is not None + badge, color = result + assert badge == "[UPD_FAIL]" + assert color == "red" + + def test_update_succeeded(self): + result = get_lifecycle_badge("UPDATE_SUCCEEDED") + assert result is not None + badge, color = result + assert badge == "[UPDATED]" + assert color == "green" + + def test_unknown(self): + assert get_lifecycle_badge("UNKNOWN") is None + + +class TestClearScreen: + """Tests for the two-mode clear_screen function.""" + + @patch("deadline.client.cli._groups._job_tui._common.sys") + def test_soft_clear_writes_cursor_home_only(self, mock_sys): + """Soft clear (full=False) should only write cursor-home escape.""" + clear_screen(full=False) + mock_sys.stdout.write.assert_called_once_with("\033[H") + mock_sys.stdout.flush.assert_called_once() + + @patch("deadline.client.cli._groups._job_tui._common.sys") + def test_hard_clear_writes_cursor_home_and_erase(self, mock_sys): + """Hard clear (full=True) should write cursor-home + erase-screen.""" + clear_screen(full=True) + mock_sys.stdout.write.assert_called_once_with("\033[H\033[2J") + mock_sys.stdout.flush.assert_called_once() + + @patch("deadline.client.cli._groups._job_tui._common.sys") + def test_default_is_full_clear(self, mock_sys): + """Default call with no args should be full clear.""" + clear_screen() + mock_sys.stdout.write.assert_called_once_with("\033[H\033[2J") + + +class TestCopyToClipboard: + @patch("deadline.client.cli._groups._job_tui._common.subprocess") + @patch("deadline.client.cli._groups._job_tui._common.sys") + def test_darwin(self, mock_sys, mock_subprocess): + mock_sys.platform = "darwin" + assert copy_to_clipboard("test") is True + mock_subprocess.run.assert_called_once() + + @patch("deadline.client.cli._groups._job_tui._common.subprocess") + @patch("deadline.client.cli._groups._job_tui._common.sys") + def test_linux(self, mock_sys, mock_subprocess): + mock_sys.platform = "linux" + assert copy_to_clipboard("test") is True + + @patch("deadline.client.cli._groups._job_tui._common.subprocess") + @patch("deadline.client.cli._groups._job_tui._common.sys") + def test_win32(self, mock_sys, mock_subprocess): + mock_sys.platform = "win32" + assert copy_to_clipboard("test") is True + + @patch("deadline.client.cli._groups._job_tui._common.subprocess") + @patch("deadline.client.cli._groups._job_tui._common.sys") + def test_unsupported_platform(self, mock_sys, mock_subprocess): + mock_sys.platform = "freebsd" + assert copy_to_clipboard("test") is False + + @patch("deadline.client.cli._groups._job_tui._common.subprocess") + @patch("deadline.client.cli._groups._job_tui._common.sys") + def test_subprocess_error(self, mock_sys, mock_subprocess): + import subprocess as sp + + mock_sys.platform = "darwin" + mock_subprocess.run.side_effect = sp.SubprocessError + mock_subprocess.SubprocessError = sp.SubprocessError + assert copy_to_clipboard("test") is False + + +class TestOpenFeedbackUrl: + @patch("webbrowser.open", return_value=True) + def test_success(self, mock_open): + result = open_feedback_url() + assert "Opened" in result + + @patch("webbrowser.open", return_value=False) + def test_failure(self, mock_open): + result = open_feedback_url() + assert "feedback" in result.lower() + + @patch("webbrowser.open", side_effect=Exception("no browser")) + def test_exception(self, mock_open): + result = open_feedback_url() + assert "feedback" in result.lower() + + +class TestEnterLeaveAltScreen: + @patch("deadline.client.cli._groups._job_tui._common.sys") + def test_enter_alt_screen(self, mock_sys): + enter_alt_screen() + mock_sys.stdout.write.assert_called_once() + mock_sys.stdout.flush.assert_called_once() + + @patch("deadline.client.cli._groups._job_tui._common.sys") + def test_leave_alt_screen(self, mock_sys): + leave_alt_screen() + mock_sys.stdout.write.assert_called_once() + mock_sys.stdout.flush.assert_called_once() + + +class TestRenderHeader: + @patch("deadline.client.cli._groups._job_tui._common.console") + def test_render_header(self, mock_console): + render_header("Title", "Sub") + mock_console.print.assert_called_once() + + +class TestRenderHelpBar: + @patch("deadline.client.cli._groups._job_tui._common.sys") + @patch("deadline.client.cli._groups._job_tui._common.console") + def test_render_help_bar(self, mock_console, mock_sys): + render_help_bar([("q", "quit"), ("r", "refresh")]) + mock_console.print.assert_called_once() + + +class TestGetTerminalPageSize: + @patch("deadline.client.cli._groups._job_tui._common.console") + def test_returns_at_least_5(self, mock_console): + mock_console.height = 10 + assert get_terminal_page_size(chrome_lines=18) == 5 + + @patch("deadline.client.cli._groups._job_tui._common.console") + def test_large_terminal(self, mock_console): + mock_console.height = 50 + assert get_terminal_page_size(chrome_lines=18) == 32 diff --git a/test/unit/deadline_client/cli/test_cli_job_tui_integration.py b/test/unit/deadline_client/cli/test_cli_job_tui_integration.py new file mode 100644 index 000000000..4f7583751 --- /dev/null +++ b/test/unit/deadline_client/cli/test_cli_job_tui_integration.py @@ -0,0 +1,147 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Tests for job_group TUI integration functions.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +pytest.importorskip("rich", reason="TUI tests require the 'rich' package (deadline[tui])") + +from deadline.client.cli._groups.job_group import ( + _tui_main_loop, + _tui_setup_attachment_browser, + _tui_step_loop, + _tui_task_loop, +) + + +@pytest.fixture +def mock_deadline(): + client = MagicMock() + client.get_job.return_value = { + "name": "Test Job", + "taskRunStatus": "SUCCEEDED", + } + client.get_queue.return_value = { + "displayName": "Test Queue", + "jobAttachmentSettings": { + "s3BucketName": "bucket", + "rootPrefix": "prefix", + }, + } + client.get_task.return_value = { + "parameters": {"Frame": {"int": "1"}}, + } + return client + + +class TestTuiMainLoop: + @patch("deadline.client.cli._groups._job_tui._job_list.JobListTUI") + def test_quit(self, MockJobList, mock_deadline): + MockJobList.return_value.run.return_value = None + _tui_main_loop("farm-1", "queue-1", mock_deadline, None) + + @patch("deadline.client.cli._groups.job_group._tui_step_loop", return_value=None) + @patch("deadline.client.cli._groups._job_tui._job_list.JobListTUI") + def test_select_job_then_back(self, MockJobList, mock_step_loop, mock_deadline): + MockJobList.return_value.run.side_effect = [("select", "job-1"), None] + _tui_main_loop("farm-1", "queue-1", mock_deadline, None) + mock_step_loop.assert_called_once() + + @patch("deadline.client.cli._groups.job_group._tui_step_loop", return_value="quit") + @patch("deadline.client.cli._groups._job_tui._job_list.JobListTUI") + def test_select_job_then_quit(self, MockJobList, mock_step_loop, mock_deadline): + MockJobList.return_value.run.side_effect = [("select", "job-1")] + _tui_main_loop("farm-1", "queue-1", mock_deadline, None) + + @patch("deadline.client.cli._groups.job_group._tui_setup_attachment_browser") + @patch("deadline.client.cli._groups._job_tui._job_list.JobListTUI") + def test_attachments(self, MockJobList, mock_attach, mock_deadline): + MockJobList.return_value.run.side_effect = [("attachments", "job-1"), None] + _tui_main_loop("farm-1", "queue-1", mock_deadline, None) + mock_attach.assert_called_once() + + +class TestTuiStepLoop: + @patch("deadline.client.cli._groups._job_tui._step_list.StepListTUI") + def test_quit(self, MockStepList, mock_deadline): + MockStepList.return_value.run.return_value = None + result = _tui_step_loop("farm-1", "queue-1", "job-1", mock_deadline, None) + assert result == "quit" + + @patch("deadline.client.cli._groups._job_tui._step_list.StepListTUI") + def test_back(self, MockStepList, mock_deadline): + MockStepList.return_value.run.return_value = ("back", "") + result = _tui_step_loop("farm-1", "queue-1", "job-1", mock_deadline, None) + assert result is None + + @patch("deadline.client.cli._groups.job_group._tui_task_loop", return_value=None) + @patch("deadline.client.cli._groups._job_tui._step_list.StepListTUI") + def test_select_step_then_back(self, MockStepList, mock_task_loop, mock_deadline): + MockStepList.return_value.run.side_effect = [ + ("select", "step-1", "Render"), + ("back", ""), + ] + result = _tui_step_loop("farm-1", "queue-1", "job-1", mock_deadline, None) + assert result is None + + @patch("deadline.client.cli._groups.job_group._tui_task_loop", return_value="quit") + @patch("deadline.client.cli._groups._job_tui._step_list.StepListTUI") + def test_select_step_then_quit(self, MockStepList, mock_task_loop, mock_deadline): + MockStepList.return_value.run.side_effect = [("select", "step-1", "Render")] + result = _tui_step_loop("farm-1", "queue-1", "job-1", mock_deadline, None) + assert result == "quit" + + +class TestTuiTaskLoop: + @patch("deadline.client.cli._groups._job_tui._task_list.TaskListTUI") + def test_quit(self, MockTaskList, mock_deadline): + MockTaskList.return_value.run.return_value = None + result = _tui_task_loop( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline, None + ) + assert result == "quit" + + @patch("deadline.client.cli._groups._job_tui._task_list.TaskListTUI") + def test_back(self, MockTaskList, mock_deadline): + MockTaskList.return_value.run.return_value = ("back", "") + result = _tui_task_loop( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline, None + ) + assert result is None + + @patch("deadline.client.cli._groups._job_tui._session_list.SessionListTUI") + @patch("deadline.client.cli._groups._job_tui._task_list.TaskListTUI") + def test_sessions(self, MockTaskList, MockSessionList, mock_deadline): + MockTaskList.return_value.run.side_effect = [("sessions", "task-1"), ("back", "")] + result = _tui_task_loop( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline, None + ) + MockSessionList.return_value.run.assert_called_once() + assert result is None + + @patch("deadline.client.cli._groups.job_group._tui_setup_attachment_browser") + @patch("deadline.client.cli._groups._job_tui._task_list.TaskListTUI") + def test_attachments(self, MockTaskList, mock_attach, mock_deadline): + MockTaskList.return_value.run.side_effect = [("attachments", "task-1"), ("back", "")] + _tui_task_loop( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline, None + ) + mock_attach.assert_called_once() + + +class TestTuiSetupAttachmentBrowser: + @patch("deadline.client.cli._groups._job_tui._attachment_browser.AttachmentBrowserTUI") + @patch("deadline.client.cli._groups.job_group.api") + def test_launches_browser(self, mock_api, MockBrowser, mock_deadline): + _tui_setup_attachment_browser(None, "farm-1", "queue-1", "job-1", mock_deadline) + MockBrowser.return_value.run.assert_called_once() + + @patch("deadline.client.cli._groups.job_group.click") + @patch("deadline.client.cli._groups.job_group.api") + def test_no_attachment_settings(self, mock_api, mock_click, mock_deadline): + mock_deadline.get_queue.return_value = {"displayName": "Q"} + _tui_setup_attachment_browser(None, "farm-1", "queue-1", "job-1", mock_deadline) + mock_click.echo.assert_called_once() diff --git a/test/unit/deadline_client/cli/test_cli_job_tui_job_list.py b/test/unit/deadline_client/cli/test_cli_job_tui_job_list.py new file mode 100644 index 000000000..d2834f6ea --- /dev/null +++ b/test/unit/deadline_client/cli/test_cli_job_tui_job_list.py @@ -0,0 +1,230 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Tests for JobListTUI.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock, call, patch + +import pytest + +pytest.importorskip("rich", reason="TUI tests require the 'rich' package (deadline[tui])") + +from deadline.client.cli._groups._job_tui._job_list import JobListTUI + + +@pytest.fixture +def mock_deadline_client(): + client = MagicMock() + client.search_jobs.return_value = { + "jobs": [ + { + "jobId": "job-abcdef1234567890abcdef1234567890", + "name": "Test Render Job", + "taskRunStatus": "SUCCEEDED", + "createdAt": datetime(2026, 2, 8, 10, 0, 0, tzinfo=timezone.utc), + }, + { + "jobId": "job-11111111111111111111111111111111", + "name": "Running Job", + "taskRunStatus": "RUNNING", + "targetTaskRunStatus": "CANCELED", + "createdAt": datetime(2026, 2, 8, 9, 0, 0, tzinfo=timezone.utc), + }, + ], + "totalResults": 2, + } + return client + + +class TestJobListTUI: + def test_load_page(self, mock_deadline_client): + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + tui.load_page() + assert len(tui.jobs) == 2 + assert tui.total_jobs == 2 + mock_deadline_client.search_jobs.assert_called_once() + + def test_load_page_pagination(self, mock_deadline_client): + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + tui.page = 2 + tui.load_page() + call_kwargs = mock_deadline_client.search_jobs.call_args[1] + assert call_kwargs["itemOffset"] == 2 * tui.page_size + + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_run_select_job(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "enter" + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + result = tui.run() + assert result == ("select", "job-abcdef1234567890abcdef1234567890") + + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_run_quit(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "q" + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + result = tui.run() + assert result is None + + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_run_attachments(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "a" + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + result = tui.run() + assert result == ("attachments", "job-abcdef1234567890abcdef1234567890") + + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_run_right_arrow_selects(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "right" + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + result = tui.run() + assert result == ("select", "job-abcdef1234567890abcdef1234567890") + + @patch("deadline.client.cli._groups._job_tui._job_list.copy_to_clipboard", return_value=True) + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_run_copy_id(self, mock_console, mock_read_key, mock_copy, mock_deadline_client): + # First press 'c' to copy, then 'q' to quit + mock_read_key.side_effect = ["c", "q"] + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + result = tui.run() + mock_copy.assert_called_once_with("job-abcdef1234567890abcdef1234567890") + assert result is None + + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_cursor_movement(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.side_effect = ["down", "enter"] + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + result = tui.run() + assert result == ("select", "job-11111111111111111111111111111111") + + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_cursor_does_not_go_below_zero(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.side_effect = ["up", "up", "enter"] + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + result = tui.run() + # Cursor should stay at 0 + assert result == ("select", "job-abcdef1234567890abcdef1234567890") + + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_empty_jobs(self, mock_console, mock_read_key, mock_deadline_client): + mock_deadline_client.search_jobs.return_value = {"jobs": [], "totalResults": 0} + mock_read_key.return_value = "q" + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + result = tui.run() + assert result is None + + +class TestJobListTUIScreenClearing: + """Tests for the _needs_full_clear flag and clear_screen behavior.""" + + @patch("deadline.client.cli._groups._job_tui._job_list.clear_screen") + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_needs_full_clear_true_on_first_render( + self, mock_console, mock_read_key, mock_clear, mock_deadline_client + ): + """First render after run() should call clear_screen(full=True).""" + mock_read_key.return_value = "q" + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + tui.run() + # First call to clear_screen should be full=True + assert mock_clear.call_args_list[0] == call(full=True) + + @patch("deadline.client.cli._groups._job_tui._job_list.clear_screen") + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_needs_full_clear_false_after_first_render( + self, mock_console, mock_read_key, mock_clear, mock_deadline_client + ): + """Second render (after a keypress) should call clear_screen(full=False).""" + # Press down then quit — triggers two render() calls + mock_read_key.side_effect = ["down", "q"] + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + tui.run() + assert len(mock_clear.call_args_list) >= 2 + # First render: full=True, second render: full=False + assert mock_clear.call_args_list[0] == call(full=True) + assert mock_clear.call_args_list[1] == call(full=False) + + @patch("deadline.client.cli._groups._job_tui._job_list.clear_screen") + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_needs_full_clear_reset_on_rerun( + self, mock_console, mock_read_key, mock_clear, mock_deadline_client + ): + """Calling run() again should reset _needs_full_clear to True.""" + mock_read_key.return_value = "q" + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + + # First run + tui.run() + first_run_first_call = mock_clear.call_args_list[0] + assert first_run_first_call == call(full=True) + + mock_clear.reset_mock() + + # Second run — should hard-clear again on first render + tui.run() + assert mock_clear.call_args_list[0] == call(full=True) + + def test_needs_full_clear_initial_value(self, mock_deadline_client): + """_needs_full_clear should be True after construction.""" + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + assert tui._needs_full_clear is True + + @patch("deadline.client.cli._groups._job_tui._job_list.clear_screen") + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_subsequent_renders_all_soft_clear( + self, mock_console, mock_read_key, mock_clear, mock_deadline_client + ): + """All render() calls after the first should use soft clear (full=False). + + Note: the quit handler also calls clear_screen() with no args (full=False + by default), so we check all calls from render (indices 0..N-1) plus the + final bare clear_screen() on quit. + """ + # Navigate: down, down, up, quit — 4 renders after the initial one, plus quit clear + mock_read_key.side_effect = ["down", "down", "up", "q"] + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + tui.run() + # First call is hard clear (from render) + assert mock_clear.call_args_list[0] == call(full=True) + # Remaining render calls use soft clear (full=False) + # Last call is the bare clear_screen() from quit handler (no args) + for c in mock_clear.call_args_list[1:-1]: + assert c == call(full=False) + + @patch("deadline.client.cli._groups._job_tui._job_list.clear_screen") + @patch("deadline.client.cli._groups._job_tui._job_list.read_key") + @patch("deadline.client.cli._groups._job_tui._job_list.console") + def test_page_change_triggers_full_clear( + self, mock_console, mock_read_key, mock_clear, mock_deadline_client + ): + """Pressing 'n' to go to next page should trigger a full clear.""" + mock_deadline_client.search_jobs.return_value = { + "jobs": [ + { + "jobId": "job-aaaa", + "name": "Job A", + "taskRunStatus": "SUCCEEDED", + }, + ], + "totalResults": 50, + } + # Press n (next page), then q + mock_read_key.side_effect = ["n", "q"] + tui = JobListTUI("farm-123", "queue-456", mock_deadline_client) + tui.run() + # call 0: initial render (full=True) + # call 1: render after page change (full=True again) + assert mock_clear.call_args_list[0] == call(full=True) + assert mock_clear.call_args_list[1] == call(full=True) diff --git a/test/unit/deadline_client/cli/test_cli_job_tui_session_list.py b/test/unit/deadline_client/cli/test_cli_job_tui_session_list.py new file mode 100644 index 000000000..ac04abb03 --- /dev/null +++ b/test/unit/deadline_client/cli/test_cli_job_tui_session_list.py @@ -0,0 +1,145 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Tests for SessionListTUI.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +pytest.importorskip("rich", reason="TUI tests require the 'rich' package (deadline[tui])") + +from deadline.client.cli._groups._job_tui._session_list import SessionListTUI + + +@pytest.fixture +def mock_deadline_client(): + client = MagicMock() + client.list_sessions.return_value = { + "sessions": [ + { + "sessionId": "session-abcdef1234567890abcdef1234567890", + "lifecycleStatus": "ENDED", + "workerId": "worker-11111111111111111111111111111111", + "startedAt": datetime(2026, 2, 8, 10, 0, 0, tzinfo=timezone.utc), + }, + ], + } + client.list_session_actions.return_value = { + "sessionActions": [ + { + "definition": { + "taskRun": { + "stepId": "step-aaa", + "taskId": "task-bbb", + } + } + } + ], + } + return client + + +class TestSessionListTUI: + def test_load_sessions_finds_matching(self, mock_deadline_client): + tui = SessionListTUI( + "farm-1", "queue-1", "job-1", "step-aaa", "task-bbb", "Frame=1", mock_deadline_client + ) + tui.load_sessions() + assert len(tui.sessions) == 1 + + def test_load_sessions_no_match(self, mock_deadline_client): + tui = SessionListTUI( + "farm-1", "queue-1", "job-1", "step-xxx", "task-yyy", "Frame=1", mock_deadline_client + ) + tui.load_sessions() + assert len(tui.sessions) == 0 + + @patch("deadline.client.cli._groups._job_tui._session_list.read_key") + @patch("deadline.client.cli._groups._job_tui._session_list.console") + def test_run_esc_returns(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "esc" + tui = SessionListTUI( + "farm-1", "queue-1", "job-1", "step-aaa", "task-bbb", "Frame=1", mock_deadline_client + ) + tui.run() # Should return without error + + @patch("deadline.client.cli._groups._job_tui._session_list.read_key") + @patch("deadline.client.cli._groups._job_tui._session_list.console") + def test_run_quit(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "q" + tui = SessionListTUI( + "farm-1", "queue-1", "job-1", "step-aaa", "task-bbb", "Frame=1", mock_deadline_client + ) + tui.run() # Should return without error + + @patch( + "deadline.client.cli._groups._job_tui._session_list.copy_to_clipboard", + return_value=True, + ) + @patch("deadline.client.cli._groups._job_tui._session_list.read_key") + @patch("deadline.client.cli._groups._job_tui._session_list.console") + def test_copy_session_id(self, mock_console, mock_read_key, mock_copy, mock_deadline_client): + mock_read_key.side_effect = ["c", "q"] + tui = SessionListTUI( + "farm-1", "queue-1", "job-1", "step-aaa", "task-bbb", "Frame=1", mock_deadline_client + ) + tui.run() + mock_copy.assert_called_once_with("session-abcdef1234567890abcdef1234567890") + + @patch("deadline.client.cli._groups._job_tui._session_list.read_key") + @patch("deadline.client.cli._groups._job_tui._session_list.console") + def test_run_left_returns(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "left" + tui = SessionListTUI( + "farm-1", "queue-1", "job-1", "step-aaa", "task-bbb", "Frame=1", mock_deadline_client + ) + tui.run() + + @patch("deadline.client.cli._groups._job_tui._session_list.read_key") + @patch("deadline.client.cli._groups._job_tui._session_list.console") + def test_cursor_navigation(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.side_effect = ["down", "up", "q"] + tui = SessionListTUI( + "farm-1", "queue-1", "job-1", "step-aaa", "task-bbb", "Frame=1", mock_deadline_client + ) + tui.run() + + @patch( + "deadline.client.cli._groups._job_tui._session_list.open_feedback_url", return_value="msg" + ) + @patch("deadline.client.cli._groups._job_tui._session_list.read_key") + @patch("deadline.client.cli._groups._job_tui._session_list.console") + def test_feedback(self, mock_console, mock_read_key, mock_feedback, mock_deadline_client): + mock_read_key.side_effect = ["f", "q"] + tui = SessionListTUI( + "farm-1", "queue-1", "job-1", "step-aaa", "task-bbb", "Frame=1", mock_deadline_client + ) + tui.run() + mock_feedback.assert_called_once() + + @patch( + "deadline.client.cli._groups._job_tui._session_list.copy_to_clipboard", + return_value=False, + ) + @patch("deadline.client.cli._groups._job_tui._session_list.read_key") + @patch("deadline.client.cli._groups._job_tui._session_list.console") + def test_copy_clipboard_fail( + self, mock_console, mock_read_key, mock_copy, mock_deadline_client + ): + mock_read_key.side_effect = ["c", "q"] + tui = SessionListTUI( + "farm-1", "queue-1", "job-1", "step-aaa", "task-bbb", "Frame=1", mock_deadline_client + ) + tui.run() + mock_copy.assert_called_once() + + def test_load_sessions_exception_skips(self, mock_deadline_client): + """Sessions where list_session_actions raises should be skipped.""" + mock_deadline_client.list_session_actions.side_effect = Exception("access denied") + tui = SessionListTUI( + "farm-1", "queue-1", "job-1", "step-aaa", "task-bbb", "Frame=1", mock_deadline_client + ) + tui.load_sessions() + assert len(tui.sessions) == 0 diff --git a/test/unit/deadline_client/cli/test_cli_job_tui_step_list.py b/test/unit/deadline_client/cli/test_cli_job_tui_step_list.py new file mode 100644 index 000000000..6dc081dd2 --- /dev/null +++ b/test/unit/deadline_client/cli/test_cli_job_tui_step_list.py @@ -0,0 +1,273 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Tests for StepListTUI.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +pytest.importorskip("rich", reason="TUI tests require the 'rich' package (deadline[tui])") + +from deadline.client.cli._groups._job_tui._step_list import StepListTUI + + +@pytest.fixture +def mock_deadline_client(): + client = MagicMock() + client.list_steps.return_value = { + "steps": [ + { + "stepId": "step-abcdef1234567890abcdef1234567890", + "name": "Render Frames", + "taskRunStatus": "SUCCEEDED", + "lifecycleStatus": "CREATE_COMPLETE", + "taskRunStatusCounts": {"SUCCEEDED": 120}, + }, + { + "stepId": "step-11111111111111111111111111111111", + "name": "Cleanup", + "taskRunStatus": "FAILED", + "targetTaskRunStatus": "CANCELED", + "lifecycleStatus": "UPDATE_FAILED", + "taskRunStatusCounts": {"FAILED": 2, "SUCCEEDED": 1}, + }, + ], + } + return client + + +class TestStepListTUI: + def test_load_page(self, mock_deadline_client): + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + tui.prev_tokens = [None] + tui.load_page() + assert len(tui.steps) == 2 + + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_run_select_step(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "enter" + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + result = tui.run() + assert result == ("select", "step-abcdef1234567890abcdef1234567890", "Render Frames") + + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_run_back(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "esc" + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + result = tui.run() + assert result == ("back", "") + + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_run_left_arrow_goes_back(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "left" + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + result = tui.run() + assert result == ("back", "") + + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_run_right_arrow_selects(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "right" + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + result = tui.run() + assert result == ("select", "step-abcdef1234567890abcdef1234567890", "Render Frames") + + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_run_quit(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "q" + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + result = tui.run() + assert result is None + + @patch("deadline.client.cli._groups._job_tui._step_list.copy_to_clipboard", return_value=True) + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_copy_step_id(self, mock_console, mock_read_key, mock_copy, mock_deadline_client): + mock_read_key.side_effect = ["c", "q"] + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + tui.run() + mock_copy.assert_called_once_with("step-abcdef1234567890abcdef1234567890") + + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_cursor_navigation(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.side_effect = ["down", "enter"] + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + result = tui.run() + assert result == ("select", "step-11111111111111111111111111111111", "Cleanup") + + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_next_page(self, mock_console, mock_read_key, mock_deadline_client): + mock_deadline_client.list_steps.return_value = { + "steps": [ + { + "stepId": "step-aaa", + "name": "Step A", + "taskRunStatus": "SUCCEEDED", + "taskRunStatusCounts": {"SUCCEEDED": 1}, + } + ], + "nextToken": "token1", + } + mock_read_key.side_effect = ["n", "q"] + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + tui.run() + assert tui.page == 1 + + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_prev_page(self, mock_console, mock_read_key, mock_deadline_client): + mock_deadline_client.list_steps.return_value = { + "steps": [ + { + "stepId": "step-aaa", + "name": "Step A", + "taskRunStatus": "SUCCEEDED", + "taskRunStatusCounts": {"SUCCEEDED": 1}, + } + ], + "nextToken": "token1", + } + mock_read_key.side_effect = ["n", "p", "q"] + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + tui.run() + assert tui.page == 0 + + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_refresh(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.side_effect = ["r", "q"] + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + tui.run() + assert mock_deadline_client.list_steps.call_count == 2 + + @patch("deadline.client.cli._groups._job_tui._step_list.open_feedback_url", return_value="msg") + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_feedback(self, mock_console, mock_read_key, mock_feedback, mock_deadline_client): + mock_read_key.side_effect = ["f", "q"] + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + tui.run() + mock_feedback.assert_called_once() + + @patch("deadline.client.cli._groups._job_tui._step_list.copy_to_clipboard", return_value=False) + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_copy_clipboard_fail( + self, mock_console, mock_read_key, mock_copy, mock_deadline_client + ): + mock_read_key.side_effect = ["c", "q"] + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + tui.run() + assert "step-abcdef" in tui.message or mock_copy.called + + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_down_wraps_to_next_page(self, mock_console, mock_read_key, mock_deadline_client): + """Pressing down at the last item with a next token should go to next page.""" + mock_deadline_client.list_steps.side_effect = [ + { + "steps": [ + { + "stepId": "step-aaa", + "name": "A", + "taskRunStatus": "SUCCEEDED", + "taskRunStatusCounts": {"SUCCEEDED": 1}, + } + ], + "nextToken": "tok", + }, + { + "steps": [ + { + "stepId": "step-bbb", + "name": "B", + "taskRunStatus": "SUCCEEDED", + "taskRunStatusCounts": {"SUCCEEDED": 1}, + } + ], + }, + ] + mock_read_key.side_effect = ["down", "enter"] + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + result = tui.run() + assert result == ("select", "step-bbb", "B") + + @patch("deadline.client.cli._groups._job_tui._step_list.read_key") + @patch("deadline.client.cli._groups._job_tui._step_list.console") + def test_up_wraps_to_prev_page(self, mock_console, mock_read_key, mock_deadline_client): + """Pressing up at cursor 0 on page 1 should go back to previous page.""" + mock_deadline_client.list_steps.side_effect = [ + { + "steps": [ + { + "stepId": "step-aaa", + "name": "A", + "taskRunStatus": "SUCCEEDED", + "taskRunStatusCounts": {"SUCCEEDED": 1}, + } + ], + "nextToken": "tok", + }, + { + "steps": [ + { + "stepId": "step-bbb", + "name": "B", + "taskRunStatus": "SUCCEEDED", + "taskRunStatusCounts": {"SUCCEEDED": 1}, + } + ], + }, + { + "steps": [ + { + "stepId": "step-aaa", + "name": "A", + "taskRunStatus": "SUCCEEDED", + "taskRunStatusCounts": {"SUCCEEDED": 1}, + } + ], + "nextToken": "tok", + }, + ] + mock_read_key.side_effect = ["n", "up", "enter"] + tui = StepListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "SUCCEEDED", mock_deadline_client + ) + result = tui.run() + assert result == ("select", "step-aaa", "A") diff --git a/test/unit/deadline_client/cli/test_cli_job_tui_task_list.py b/test/unit/deadline_client/cli/test_cli_job_tui_task_list.py new file mode 100644 index 000000000..d715d42ac --- /dev/null +++ b/test/unit/deadline_client/cli/test_cli_job_tui_task_list.py @@ -0,0 +1,233 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +"""Tests for TaskListTUI.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +pytest.importorskip("rich", reason="TUI tests require the 'rich' package (deadline[tui])") + +from deadline.client.cli._groups._job_tui._task_list import TaskListTUI + + +@pytest.fixture +def mock_deadline_client(): + client = MagicMock() + client.list_tasks.return_value = { + "tasks": [ + { + "taskId": "task-abcdef1234567890abcdef1234567890", + "runStatus": "SUCCEEDED", + "parameters": {"Frame": {"int": "1"}}, + }, + { + "taskId": "task-11111111111111111111111111111111", + "runStatus": "RUNNING", + "targetRunStatus": "CANCELED", + "parameters": {"Frame": {"int": "2"}}, + }, + ], + } + return client + + +class TestTaskListTUI: + def test_load_page(self, mock_deadline_client): + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + tui.prev_tokens = [None] + tui.load_page() + assert len(tui.tasks) == 2 + + def test_format_task_params(self, mock_deadline_client): + task = {"parameters": {"Frame": {"int": "1"}, "Chunk": {"string": "A"}}} + result = TaskListTUI._format_task_params(task) + assert "Frame=1" in result + assert "Chunk=A" in result + + def test_format_task_params_empty(self, mock_deadline_client): + task = {"taskId": "task-abcdef1234567890abcdef1234567890"} + result = TaskListTUI._format_task_params(task) + assert result == "34567890" # last 8 chars of task ID + + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_run_sessions(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "l" + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + result = tui.run() + assert result == ("sessions", "task-abcdef1234567890abcdef1234567890") + + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_run_attachments(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "a" + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + result = tui.run() + assert result == ("attachments", "task-abcdef1234567890abcdef1234567890") + + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_run_back(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "esc" + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + result = tui.run() + assert result == ("back", "") + + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_run_left_goes_back(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "left" + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + result = tui.run() + assert result == ("back", "") + + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_run_quit(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.return_value = "q" + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + result = tui.run() + assert result is None + + @patch("deadline.client.cli._groups._job_tui._task_list.copy_to_clipboard", return_value=True) + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_copy_task_id(self, mock_console, mock_read_key, mock_copy, mock_deadline_client): + mock_read_key.side_effect = ["c", "q"] + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + tui.run() + mock_copy.assert_called_once_with("task-abcdef1234567890abcdef1234567890") + + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_cursor_navigation(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.side_effect = ["down", "l"] + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + result = tui.run() + assert result == ("sessions", "task-11111111111111111111111111111111") + + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_next_page(self, mock_console, mock_read_key, mock_deadline_client): + mock_deadline_client.list_tasks.return_value = { + "tasks": [ + {"taskId": "task-aaa", "runStatus": "SUCCEEDED", "parameters": {}}, + ], + "nextToken": "token1", + } + mock_read_key.side_effect = ["n", "q"] + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + tui.run() + assert tui.page == 1 + + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_prev_page(self, mock_console, mock_read_key, mock_deadline_client): + mock_deadline_client.list_tasks.return_value = { + "tasks": [ + {"taskId": "task-aaa", "runStatus": "SUCCEEDED", "parameters": {}}, + ], + "nextToken": "token1", + } + mock_read_key.side_effect = ["n", "p", "q"] + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + tui.run() + assert tui.page == 0 + + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_refresh(self, mock_console, mock_read_key, mock_deadline_client): + mock_read_key.side_effect = ["r", "q"] + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + tui.run() + assert mock_deadline_client.list_tasks.call_count == 2 + + @patch("deadline.client.cli._groups._job_tui._task_list.open_feedback_url", return_value="msg") + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_feedback(self, mock_console, mock_read_key, mock_feedback, mock_deadline_client): + mock_read_key.side_effect = ["f", "q"] + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + tui.run() + mock_feedback.assert_called_once() + + @patch("deadline.client.cli._groups._job_tui._task_list.copy_to_clipboard", return_value=False) + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_copy_clipboard_fail( + self, mock_console, mock_read_key, mock_copy, mock_deadline_client + ): + mock_read_key.side_effect = ["c", "q"] + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + tui.run() + mock_copy.assert_called_once() + + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_down_wraps_to_next_page(self, mock_console, mock_read_key, mock_deadline_client): + mock_deadline_client.list_tasks.side_effect = [ + { + "tasks": [{"taskId": "task-aaa", "runStatus": "SUCCEEDED", "parameters": {}}], + "nextToken": "tok", + }, + { + "tasks": [{"taskId": "task-bbb", "runStatus": "SUCCEEDED", "parameters": {}}], + }, + ] + mock_read_key.side_effect = ["down", "l"] + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + result = tui.run() + assert result == ("sessions", "task-bbb") + + @patch("deadline.client.cli._groups._job_tui._task_list.read_key") + @patch("deadline.client.cli._groups._job_tui._task_list.console") + def test_up_wraps_to_prev_page(self, mock_console, mock_read_key, mock_deadline_client): + mock_deadline_client.list_tasks.side_effect = [ + { + "tasks": [{"taskId": "task-aaa", "runStatus": "SUCCEEDED", "parameters": {}}], + "nextToken": "tok", + }, + { + "tasks": [{"taskId": "task-bbb", "runStatus": "SUCCEEDED", "parameters": {}}], + }, + { + "tasks": [{"taskId": "task-aaa", "runStatus": "SUCCEEDED", "parameters": {}}], + "nextToken": "tok", + }, + ] + mock_read_key.side_effect = ["n", "up", "l"] + tui = TaskListTUI( + "farm-1", "queue-1", "job-1", "Test Job", "step-1", "Render", mock_deadline_client + ) + result = tui.run() + assert result == ("sessions", "task-aaa")