diff --git a/crates/hyperqueue/src/bin/hq.rs b/crates/hyperqueue/src/bin/hq.rs index 7b1373fde..7a8dad07b 100644 --- a/crates/hyperqueue/src/bin/hq.rs +++ b/crates/hyperqueue/src/bin/hq.rs @@ -8,8 +8,8 @@ use hyperqueue::client::commands::data::command_task_data; use hyperqueue::client::commands::doc::command_doc; use hyperqueue::client::commands::job::{ JobCancelOpts, JobCatOpts, JobCloseOpts, JobForgetOpts, JobInfoOpts, JobListOpts, - JobTaskIdsOpts, cancel_job, close_job, forget_job, output_job_cat, output_job_detail, - output_job_list, output_job_summary, + JobTaskIdsOpts, JobWorkdirOpts, cancel_job, close_job, forget_job, output_job_cat, + output_job_detail, output_job_list, output_job_summary, output_job_workdir, }; use hyperqueue::client::commands::journal::command_journal; use hyperqueue::client::commands::outputlog::command_reader; @@ -31,8 +31,9 @@ use hyperqueue::client::output::outputs::{Output, Outputs}; use hyperqueue::client::output::quiet::Quiet; use hyperqueue::client::status::Status; use hyperqueue::client::task::{ - TaskCommand, TaskExplainOpts, TaskInfoOpts, TaskListOpts, output_job_task_explain, - output_job_task_ids, output_job_task_info, output_job_task_list, + TaskCommand, TaskExplainOpts, TaskInfoOpts, TaskListOpts, TaskWorkdirOpts, + output_job_task_explain, output_job_task_ids, output_job_task_info, output_job_task_list, + output_job_task_workdir, }; use hyperqueue::common::cli::{ ColorPolicy, CommonOpts, DeploySshOpts, GenerateCompletionOpts, HwDetectOpts, JobCommand, @@ -140,6 +141,14 @@ async fn command_job_close(gsettings: &GlobalSettings, opts: JobCloseOpts) -> an close_job(gsettings, &mut connection, opts.selector).await } +async fn command_job_workdir( + gsettings: &GlobalSettings, + opts: JobWorkdirOpts, +) -> anyhow::Result<()> { + let mut connection = get_client_session(gsettings.server_directory()).await?; + output_job_workdir(gsettings, &mut connection, opts.selector).await +} + async fn command_job_delete(gsettings: &GlobalSettings, opts: JobForgetOpts) -> anyhow::Result<()> { let mut connection = get_client_session(gsettings.server_directory()).await?; forget_job(gsettings, &mut connection, opts).await @@ -212,6 +221,14 @@ async fn command_task_explain( output_job_task_explain(gsettings, &mut session, opts).await } +async fn command_task_workdir( + gsettings: &GlobalSettings, + opts: TaskWorkdirOpts, +) -> anyhow::Result<()> { + let mut session = get_client_session(gsettings.server_directory()).await?; + output_job_task_workdir(gsettings, &mut session, opts).await +} + async fn command_worker_start( gsettings: &GlobalSettings, opts: WorkerStartOpts, @@ -496,6 +513,7 @@ async fn main() -> hyperqueue::Result<()> { JobCommand::TaskIds(opts) => command_job_task_ids(&gsettings, opts).await, JobCommand::Open(opts) => command_job_open(&gsettings, opts).await, JobCommand::Close(opts) => command_job_close(&gsettings, opts).await, + JobCommand::Workdir(opts) => command_job_workdir(&gsettings, opts).await, }, SubCommand::Submit(opts) => { command_job_submit(&gsettings, OptsWithMatches::new(opts, matches)).await @@ -504,6 +522,7 @@ async fn main() -> hyperqueue::Result<()> { TaskCommand::List(opts) => command_task_list(&gsettings, opts).await, TaskCommand::Info(opts) => command_task_info(&gsettings, opts).await, TaskCommand::Explain(opts) => command_task_explain(&gsettings, opts).await, + TaskCommand::Workdir(opts) => command_task_workdir(&gsettings, opts).await, }, SubCommand::Data(opts) => command_task_data(&gsettings, opts).await, #[cfg(feature = "dashboard")] diff --git a/crates/hyperqueue/src/client/commands/job.rs b/crates/hyperqueue/src/client/commands/job.rs index 37c10c92a..c67fdd355 100644 --- a/crates/hyperqueue/src/client/commands/job.rs +++ b/crates/hyperqueue/src/client/commands/job.rs @@ -113,6 +113,13 @@ pub struct JobCatOpts { pub stream: OutputStream, } +#[derive(Parser)] +pub struct JobWorkdirOpts { + /// Single ID, ID range or `last` to display the most recently submitted job + #[arg(value_parser = parse_last_all_range)] + pub selector: IdSelector, +} + pub async fn output_job_list( gsettings: &GlobalSettings, session: &mut ClientSession, @@ -340,3 +347,37 @@ pub async fn forget_job( Ok(()) } + +pub async fn output_job_workdir( + gsettings: &GlobalSettings, + session: &mut ClientSession, + selector: IdSelector, +) -> anyhow::Result<()> { + let message = FromClientMessage::JobDetail(JobDetailRequest { + job_id_selector: selector, + task_selector: Some(TaskSelector { + id_selector: TaskIdSelector::All, + status_selector: TaskStatusSelector::All, + }), + }); + let response = + rpc_call!(session.connection(), message, ToClientMessage::JobDetailResponse(r) => r) + .await?; + + let jobs: Vec = response + .details + .into_iter() + .filter_map(|(id, job)| match job { + Some(job) => Some(job), + None => { + log::error!("Job {id} not found"); + None + } + }) + .collect(); + + gsettings + .printer() + .print_job_workdir(jobs, &response.server_uid); + Ok(()) +} diff --git a/crates/hyperqueue/src/client/output/cli.rs b/crates/hyperqueue/src/client/output/cli.rs index 920046d60..86807baf2 100644 --- a/crates/hyperqueue/src/client/output/cli.rs +++ b/crates/hyperqueue/src/client/output/cli.rs @@ -472,6 +472,19 @@ impl Output for CliOutput { } } + fn print_task_workdir(&self, jobs: Vec<(JobId, JobDetail)>, server_uid: &str) { + for (job_id, job) in jobs { + let task_paths = resolve_task_paths(&job, server_uid); + + println!("Job {}:", job_id); + for (task_id, resolved_paths) in task_paths.iter() { + if let Some(paths) = resolved_paths { + println!(" Task {}: {}", task_id.as_num(), paths.cwd.display()); + } + } + } + } + fn print_job_list(&self, jobs: Vec, total_jobs: usize) { let job_count = jobs.len(); let mut has_opened = false; @@ -632,6 +645,40 @@ impl Output for CliOutput { } } + fn print_job_workdir(&self, jobs: Vec, server_uid: &str) { + for job in jobs { + let task_paths = resolve_task_paths(&job, server_uid); + + // Collect unique working directories + let mut workdirs: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + + // Add submission directory(s) + for submit_desc in &job.submit_descs { + workdirs.insert( + submit_desc + .description() + .submit_dir + .to_string_lossy() + .to_string(), + ); + } + + // Add task working directories + for (_, resolved_paths) in task_paths.iter() { + if let Some(paths) = resolved_paths { + workdirs.insert(paths.cwd.to_string_lossy().to_string()); + } + } + + // Print job header and working directories + println!("Job {}:", job.info.id); + for workdir in workdirs { + println!(" {}", workdir); + } + } + } + fn print_job_wait( &self, duration: Duration, diff --git a/crates/hyperqueue/src/client/output/json.rs b/crates/hyperqueue/src/client/output/json.rs index f0250f6e3..8ee3e1d13 100644 --- a/crates/hyperqueue/src/client/output/json.rs +++ b/crates/hyperqueue/src/client/output/json.rs @@ -154,6 +154,43 @@ impl Output for JsonOutput { self.print(Value::Array(job_details)); } + fn print_job_workdir(&self, jobs: Vec, server_uid: &str) { + let job_workdirs: Vec<_> = jobs + .into_iter() + .map(|job| { + let task_paths = resolve_task_paths(&job, server_uid); + + // Collect unique working directories + let mut workdirs: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + + // Add submission directory(s) + for submit_desc in &job.submit_descs { + workdirs.insert( + submit_desc + .description() + .submit_dir + .to_string_lossy() + .to_string(), + ); + } + + // Add task working directories + for (_, resolved_paths) in task_paths.iter() { + if let Some(paths) = resolved_paths { + workdirs.insert(paths.cwd.to_string_lossy().to_string()); + } + } + + json!({ + "job_id": job.info.id, + "workdirs": workdirs.into_iter().collect::>() + }) + }) + .collect(); + self.print(Value::Array(job_workdirs)); + } + fn print_job_wait( &self, duration: Duration, @@ -221,6 +258,29 @@ impl Output for JsonOutput { self.print(json!(map)); } + fn print_task_workdir(&self, jobs: Vec<(JobId, JobDetail)>, server_uid: &str) { + let task_workdirs: Vec<_> = jobs + .into_iter() + .map(|(job_id, job)| { + let task_paths = resolve_task_paths(&job, server_uid); + let tasks: HashMap = task_paths + .iter() + .filter_map(|(task_id, resolved_paths)| { + resolved_paths.as_ref().map(|paths| { + (task_id.as_num(), paths.cwd.to_string_lossy().to_string()) + }) + }) + .collect(); + + json!({ + "job_id": job_id, + "tasks": tasks + }) + }) + .collect(); + self.print(Value::Array(task_workdirs)); + } + fn print_summary(&self, filename: &Path, summary: Summary) { let json = json!({ "filename": filename, diff --git a/crates/hyperqueue/src/client/output/outputs.rs b/crates/hyperqueue/src/client/output/outputs.rs index 159c7dab2..1f3135a33 100644 --- a/crates/hyperqueue/src/client/output/outputs.rs +++ b/crates/hyperqueue/src/client/output/outputs.rs @@ -48,6 +48,7 @@ pub trait Output { fn print_job_list(&self, jobs: Vec, total_jobs: usize); fn print_job_summary(&self, jobs: Vec); fn print_job_detail(&self, jobs: Vec, worker_map: WorkerMap, server_uid: &str); + fn print_job_workdir(&self, jobs: Vec, server_uid: &str); fn print_job_wait( &self, duration: Duration, @@ -80,6 +81,7 @@ pub trait Output { verbosity: Verbosity, ); fn print_task_ids(&self, jobs_task_id: Vec<(JobId, IntArray)>); + fn print_task_workdir(&self, jobs: Vec<(JobId, JobDetail)>, server_uid: &str); // Stream fn print_summary(&self, path: &Path, summary: Summary); diff --git a/crates/hyperqueue/src/client/output/quiet.rs b/crates/hyperqueue/src/client/output/quiet.rs index a63ccd016..777002149 100644 --- a/crates/hyperqueue/src/client/output/quiet.rs +++ b/crates/hyperqueue/src/client/output/quiet.rs @@ -99,6 +99,8 @@ impl Output for Quiet { } fn print_job_detail(&self, _jobs: Vec, _worker_map: WorkerMap, _server_uid: &str) {} + fn print_job_workdir(&self, _jobs: Vec, _server_uid: &str) {} + fn print_job_wait( &self, _duration: Duration, @@ -139,6 +141,8 @@ impl Output for Quiet { fn print_task_ids(&self, _job_task_ids: Vec<(JobId, IntArray)>) {} + fn print_task_workdir(&self, _jobs: Vec<(JobId, JobDetail)>, _server_uid: &str) {} + // Stream fn print_summary(&self, _filename: &Path, _summary: Summary) {} diff --git a/crates/hyperqueue/src/client/task.rs b/crates/hyperqueue/src/client/task.rs index 350d5d910..c001d78f4 100644 --- a/crates/hyperqueue/src/client/task.rs +++ b/crates/hyperqueue/src/client/task.rs @@ -27,6 +27,8 @@ pub enum TaskCommand { Info(TaskInfoOpts), /// Explain if task can run on a selected worker Explain(TaskExplainOpts), + /// Display working directory of selected task(s) + Workdir(TaskWorkdirOpts), } #[derive(clap::Parser)] @@ -65,6 +67,16 @@ pub struct TaskExplainOpts { pub task_id: JobTaskId, } +#[derive(clap::Parser)] +pub struct TaskWorkdirOpts { + /// Select specific job + #[arg(value_parser = parse_last_single_id)] + pub job_selector: SingleIdSelector, + + /// Select specific task(s) + pub task_selector: IntArray, +} + pub async fn output_job_task_list( gsettings: &GlobalSettings, session: &mut ClientSession, @@ -200,3 +212,44 @@ pub async fn output_job_task_explain( .print_explanation(response.task_id, &response.explanation); Ok(()) } + +pub async fn output_job_task_workdir( + gsettings: &GlobalSettings, + session: &mut ClientSession, + opts: TaskWorkdirOpts, +) -> anyhow::Result<()> { + let task_selector = TaskSelector { + id_selector: TaskIdSelector::Specific(opts.task_selector), + status_selector: TaskStatusSelector::All, + }; + + let job_id_selector = match opts.job_selector { + SingleIdSelector::Specific(id) => IdSelector::Specific(IntArray::from_id(id)), + SingleIdSelector::Last => IdSelector::LastN(1), + }; + + let message = FromClientMessage::JobDetail(JobDetailRequest { + job_id_selector, + task_selector: Some(task_selector), + }); + let response = + rpc_call!(session.connection(), message, ToClientMessage::JobDetailResponse(r) => r) + .await?; + + let jobs = response + .details + .into_iter() + .filter_map(|(job_id, opt_job)| match opt_job { + Some(job) => Some((job_id, job)), + None => { + log::warn!("Job {job_id} not found"); + None + } + }) + .collect(); + + gsettings + .printer() + .print_task_workdir(jobs, &response.server_uid); + Ok(()) +} diff --git a/crates/hyperqueue/src/common/cli.rs b/crates/hyperqueue/src/common/cli.rs index c26a9c05b..339995715 100644 --- a/crates/hyperqueue/src/common/cli.rs +++ b/crates/hyperqueue/src/common/cli.rs @@ -11,7 +11,7 @@ use crate::client::commands::data::DataOpts; use crate::client::commands::doc::DocOpts; use crate::client::commands::job::{ JobCancelOpts, JobCatOpts, JobCloseOpts, JobForgetOpts, JobInfoOpts, JobListOpts, - JobTaskIdsOpts, + JobTaskIdsOpts, JobWorkdirOpts, }; use crate::client::commands::journal::JournalOpts; use crate::client::commands::outputlog::OutputLogOpts; @@ -374,6 +374,8 @@ pub enum JobCommand { Open(SubmitJobConfOpts), /// Close an open job Close(JobCloseOpts), + /// Display working directory of selected job(s) + Workdir(JobWorkdirOpts), } #[derive(Parser)] diff --git a/tests/job/test_job_workdir.py b/tests/job/test_job_workdir.py new file mode 100644 index 000000000..282b8e135 --- /dev/null +++ b/tests/job/test_job_workdir.py @@ -0,0 +1,171 @@ +"""Integration tests for job workdir command.""" + +import json +import os +import tempfile + +from ..conftest import HqEnv +from ..utils import wait_for_job_state +from ..utils.job import list_jobs + + +def test_job_workdir_integration_with_job_list(hq_env: HqEnv): + """Test that job workdir works with jobs from job list.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit multiple jobs + hq_env.command(["submit", "--name", "test-job-1", "--", "echo", "hello"]) + hq_env.command(["submit", "--name", "test-job-2", "--", "echo", "world"]) + wait_for_job_state(hq_env, [1, 2], "FINISHED") + + # Verify jobs exist in list + table = list_jobs(hq_env) + assert len(table) == 2 + + # Test workdir for all jobs + output = hq_env.command(["job", "workdir", "all"]) + assert "Job 1:" in output + assert "Job 2:" in output + + +def test_job_workdir_with_complex_working_directories(hq_env: HqEnv): + """Test job workdir with various working directory configurations.""" + hq_env.start_server() + hq_env.start_worker() + + with tempfile.TemporaryDirectory() as tmpdir: + subdir = os.path.join(tmpdir, "subdir") + os.makedirs(subdir) + + # Job with default cwd + hq_env.command(["submit", "--name", "default-cwd", "--", "pwd"]) + + # Job with custom cwd + hq_env.command(["submit", "--name", "custom-cwd", "--cwd", subdir, "--", "pwd"]) + + # Job with relative cwd + rel_path = os.path.relpath(subdir) + hq_env.command(["submit", "--name", "relative-cwd", "--cwd", rel_path, "--", "pwd"]) + + wait_for_job_state(hq_env, [1, 2, 3], "FINISHED") + + # Test workdir output for all jobs + output = hq_env.command(["job", "workdir", "1-3"]) + + # Should contain all three job headers + assert "Job 1:" in output + assert "Job 2:" in output + assert "Job 3:" in output + + # Should contain the custom directory path + assert subdir in output or rel_path in output + + +def test_job_workdir_json_structure(hq_env: HqEnv): + """Test the JSON structure of job workdir output.""" + hq_env.start_server() + hq_env.start_worker() + + with tempfile.TemporaryDirectory() as tmpdir: + # Submit job with custom directory + hq_env.command(["submit", "--cwd", tmpdir, "--array", "1-2", "--", "echo", "test"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Get JSON output + output = hq_env.command(["job", "workdir", "1", "--output-mode", "json"]) + data = json.loads(output) + + # Verify JSON structure + assert isinstance(data, list) + assert len(data) == 1 + + job_data = data[0] + assert "job_id" in job_data + assert job_data["job_id"] == 1 + assert "workdirs" in job_data + assert isinstance(job_data["workdirs"], list) + + # Should contain the custom directory + workdirs = job_data["workdirs"] + assert len(workdirs) >= 1 + assert any(tmpdir in wd for wd in workdirs) + + +def test_job_workdir_error_handling(hq_env: HqEnv): + """Test error handling in job workdir command.""" + hq_env.start_server() + + # Test with no jobs - should succeed but return empty output + hq_env.command(["job", "workdir", "1"]) + # Should handle gracefully (no crash, empty or minimal output) + + # Test with invalid selector - check manually for now since clap error handling varies + try: + hq_env.command(["job", "workdir", "invalid"]) + # If it doesn't fail, that's also acceptable + except Exception: + # Expected to fail with invalid selector + pass + + +def test_job_workdir_with_stdout_stderr_redirection(hq_env: HqEnv): + """Test job workdir when jobs have stdout/stderr redirection.""" + hq_env.start_server() + hq_env.start_worker() + + with tempfile.TemporaryDirectory() as tmpdir: + stdout_file = os.path.join(tmpdir, "output.txt") + stderr_file = os.path.join(tmpdir, "error.txt") + + # Submit job with output redirection + hq_env.command( + ["submit", "--stdout", stdout_file, "--stderr", stderr_file, "--cwd", tmpdir, "--", "echo", "hello"] + ) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Test workdir - should show the working directory, not the output files + output = hq_env.command(["job", "workdir", "1"]) + + assert "Job 1:" in output + assert tmpdir in output + + +def test_job_workdir_consistency_with_job_info(hq_env: HqEnv): + """Test that job workdir is consistent with job info output.""" + hq_env.start_server() + hq_env.start_worker() + + with tempfile.TemporaryDirectory() as tmpdir: + # Submit job with custom working directory + hq_env.command(["submit", "--cwd", tmpdir, "--", "echo", "test"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Get workdir output + workdir_output = hq_env.command(["job", "workdir", "1"]) + + # Get job info output + info_output = hq_env.command(["job", "info", "1"]) + + # Both should reference the same working directory + assert tmpdir in workdir_output + assert tmpdir in info_output + + +def test_job_workdir_performance_with_many_jobs(hq_env: HqEnv): + """Test job workdir performance with multiple jobs.""" + hq_env.start_server() + hq_env.start_worker(cpus=4) + + # Submit multiple jobs quickly + for i in range(10): + hq_env.command(["submit", "--", "echo", f"job-{i}"]) + + wait_for_job_state(hq_env, list(range(1, 11)), "FINISHED") + + # Test workdir for all jobs - should complete reasonably quickly + output = hq_env.command(["job", "workdir", "1-10"]) + + # Should contain all job headers + for i in range(1, 11): + assert f"Job {i}:" in output diff --git a/tests/output/test_json.py b/tests/output/test_json.py index b5fd4ea62..d00b8baec 100644 --- a/tests/output/test_json.py +++ b/tests/output/test_json.py @@ -289,3 +289,76 @@ def test_print_job_summary(hq_env: HqEnv): } ) schema.validate(output) + + +def test_print_job_workdir_json(hq_env: HqEnv): + """Test job workdir JSON output format.""" + hq_env.start_server() + hq_env.start_worker() + + hq_env.command(["submit", "--", "echo", "test"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + output = parse_json_output(hq_env, ["--output-mode=json", "job", "workdir", "1"]) + + # Validate JSON schema + schema = Schema([{"job_id": int, "workdirs": [str]}]) + schema.validate(output) + + # Verify content + assert len(output) == 1 + assert output[0]["job_id"] == 1 + assert len(output[0]["workdirs"]) >= 1 + + +def test_print_job_workdir_multiple_jobs_json(hq_env: HqEnv): + """Test job workdir JSON output with multiple jobs.""" + hq_env.start_server() + hq_env.start_worker() + + hq_env.command(["submit", "--", "echo", "test1"]) + hq_env.command(["submit", "--", "echo", "test2"]) + wait_for_job_state(hq_env, [1, 2], "FINISHED") + + output = parse_json_output(hq_env, ["--output-mode=json", "job", "workdir", "1-2"]) + + # Validate JSON schema + schema = Schema([{"job_id": int, "workdirs": [str]}]) + schema.validate(output) + + # Verify content + assert len(output) == 2 + job_ids = [job["job_id"] for job in output] + assert 1 in job_ids + assert 2 in job_ids + + +def test_print_task_workdir_json(hq_env: HqEnv): + """Test task workdir JSON output format.""" + hq_env.start_server() + hq_env.start_worker() + + hq_env.command(["submit", "--array=1-3", "--", "echo", "test"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + output = parse_json_output(hq_env, ["--output-mode=json", "task", "workdir", "1", "1-2"]) + + # Validate JSON schema + schema = Schema( + [ + { + "job_id": int, + "tasks": {str: str}, # task_id -> workdir + } + ] + ) + schema.validate(output) + + # Verify content + assert len(output) == 1 + assert output[0]["job_id"] == 1 + tasks = output[0]["tasks"] + assert "1" in tasks + assert "2" in tasks + assert isinstance(tasks["1"], str) + assert isinstance(tasks["2"], str) diff --git a/tests/test_task.py b/tests/test_task.py index c76094b16..c37762702 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -77,3 +77,67 @@ def test_long_running_task(hq_env: HqEnv): hq_env.start_worker() hq_env.command(["submit", "sleep", "20"]) wait_for_job_state(hq_env, 1, "FINISHED", timeout_s=30) + + +def test_task_workdir_basic(hq_env: HqEnv): + """Test basic task workdir functionality.""" + hq_env.start_server() + hq_env.start_worker() + + hq_env.command(["submit", "--array=1-3", "--", "echo", "test"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Test single task workdir + output = hq_env.command(["task", "workdir", "1", "2"]) + assert "Job 1:" in output + assert "Task 2:" in output + + # Test multiple tasks workdir + output = hq_env.command(["task", "workdir", "1", "1-3"]) + assert "Job 1:" in output + assert "Task 1:" in output + assert "Task 2:" in output + assert "Task 3:" in output + + +def test_task_workdir_json_output(hq_env: HqEnv): + """Test task workdir JSON output.""" + hq_env.start_server() + hq_env.start_worker() + + hq_env.command(["submit", "--array=1-2", "--", "echo", "test"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + output = hq_env.command(["task", "workdir", "1", "1-2", "--output-mode", "json"]) + import json + + data = json.loads(output) + + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["job_id"] == 1 + assert "tasks" in data[0] + assert "1" in data[0]["tasks"] + assert "2" in data[0]["tasks"] + + +def test_task_workdir_integration_with_task_info(hq_env: HqEnv): + """Test that task workdir is consistent with task info.""" + hq_env.start_server() + hq_env.start_worker() + + hq_env.command(["submit", "--array=1-2", "--", "echo", "test"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Get workdir for task + workdir_output = hq_env.command(["task", "workdir", "1", "1"]) + + # Get task info + info_output = hq_env.command(["task", "info", "1", "1"]) + + # Both should reference working directories + import os + + current_dir = os.getcwd() + assert current_dir in workdir_output + assert current_dir in info_output diff --git a/tests/test_workdir.py b/tests/test_workdir.py new file mode 100644 index 000000000..f5f281921 --- /dev/null +++ b/tests/test_workdir.py @@ -0,0 +1,316 @@ +"""Tests for the workdir commands (job workdir and task workdir).""" + +import json +import os +import tempfile + +from .conftest import HqEnv +from .utils import wait_for_job_state + + +def test_job_workdir_single_job(hq_env: HqEnv): + """Test job workdir command with a single job.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit a simple job + hq_env.command(["submit", "--", "echo", "hello"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Test CLI output + output = hq_env.command(["job", "workdir", "1"]) + lines = output.strip().split("\n") + + assert len(lines) >= 2 # Should have job header and at least one workdir + assert lines[0] == "Job 1:" + assert os.getcwd() in lines[1] # Current directory should be shown + + +def test_job_workdir_multiple_jobs(hq_env: HqEnv): + """Test job workdir command with multiple jobs.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit multiple jobs + hq_env.command(["submit", "--", "echo", "job1"]) + hq_env.command(["submit", "--", "echo", "job2"]) + wait_for_job_state(hq_env, [1, 2], "FINISHED") + + # Test with job range + output = hq_env.command(["job", "workdir", "1-2"]) + lines = output.strip().split("\n") + + # Should have headers and workdirs for both jobs + assert "Job 1:" in lines + assert "Job 2:" in lines + + +def test_job_workdir_last_selector(hq_env: HqEnv): + """Test job workdir command with 'last' selector.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit multiple jobs + hq_env.command(["submit", "--", "echo", "job1"]) + hq_env.command(["submit", "--", "echo", "job2"]) + wait_for_job_state(hq_env, [1, 2], "FINISHED") + + # Test with 'last' selector - should show only job 2 + output = hq_env.command(["job", "workdir", "last"]) + lines = output.strip().split("\n") + + assert "Job 2:" in lines + assert "Job 1:" not in lines + + +def test_job_workdir_with_custom_cwd(hq_env: HqEnv): + """Test job workdir command with custom working directory.""" + hq_env.start_server() + hq_env.start_worker() + + # Create a temporary directory + with tempfile.TemporaryDirectory() as tmpdir: + # Submit job with custom working directory + hq_env.command(["submit", "--cwd", tmpdir, "--", "echo", "hello"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Check workdir output + output = hq_env.command(["job", "workdir", "1"]) + lines = output.strip().split("\n") + + assert len(lines) >= 2 + assert lines[0] == "Job 1:" + # Should show the custom directory + assert tmpdir in output + + +def test_job_workdir_array_job(hq_env: HqEnv): + """Test job workdir command with array job.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit array job + hq_env.command(["submit", "--array", "1-3", "--", "echo", "$HQ_TASK_ID"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Test workdir output + output = hq_env.command(["job", "workdir", "1"]) + lines = output.strip().split("\n") + + assert len(lines) >= 2 + assert lines[0] == "Job 1:" + assert os.getcwd() in lines[1] + + +def test_job_workdir_json_output(hq_env: HqEnv): + """Test job workdir command with JSON output.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit a job + hq_env.command(["submit", "--", "echo", "hello"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Test JSON output + output = hq_env.command(["job", "workdir", "1", "--output-mode", "json"]) + data = json.loads(output) + + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["job_id"] == 1 + assert "workdirs" in data[0] + assert isinstance(data[0]["workdirs"], list) + assert len(data[0]["workdirs"]) >= 1 + + +def test_job_workdir_quiet_output(hq_env: HqEnv): + """Test job workdir command with quiet output.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit a job + hq_env.command(["submit", "--", "echo", "hello"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Test quiet output - should be empty + output = hq_env.command(["job", "workdir", "1", "--output-mode", "quiet"]) + assert output.strip() == "" + + +def test_job_workdir_nonexistent_job(hq_env: HqEnv): + """Test job workdir command with nonexistent job.""" + hq_env.start_server() + + # Try to get workdir for nonexistent job - should succeed but with empty output + hq_env.command(["job", "workdir", "999"]) + # Should succeed but show no jobs (empty output or only error logs) + + +def test_task_workdir_single_task(hq_env: HqEnv): + """Test task workdir command with a single task.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit array job + hq_env.command(["submit", "--array", "1-3", "--", "echo", "$HQ_TASK_ID"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Test task workdir for single task + output = hq_env.command(["task", "workdir", "1", "2"]) + lines = output.strip().split("\n") + + assert len(lines) >= 2 + assert lines[0] == "Job 1:" + assert "Task 2:" in lines[1] + assert os.getcwd() in lines[1] + + +def test_task_workdir_multiple_tasks(hq_env: HqEnv): + """Test task workdir command with multiple tasks.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit array job + hq_env.command(["submit", "--array", "1-4", "--", "echo", "$HQ_TASK_ID"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Test task workdir for multiple tasks + output = hq_env.command(["task", "workdir", "1", "2-3"]) + lines = output.strip().split("\n") + + assert "Job 1:" in lines + assert any("Task 2:" in line for line in lines) + assert any("Task 3:" in line for line in lines) + + +def test_task_workdir_with_custom_cwd(hq_env: HqEnv): + """Test task workdir command with custom working directory.""" + hq_env.start_server() + hq_env.start_worker() + + # Create a temporary directory + with tempfile.TemporaryDirectory() as tmpdir: + # Submit array job with custom working directory + hq_env.command(["submit", "--array", "1-2", "--cwd", tmpdir, "--", "echo", "$HQ_TASK_ID"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Check task workdir output + output = hq_env.command(["task", "workdir", "1", "1"]) + + assert "Job 1:" in output + assert "Task 1:" in output + assert tmpdir in output + + +def test_task_workdir_json_output(hq_env: HqEnv): + """Test task workdir command with JSON output.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit array job + hq_env.command(["submit", "--array", "1-2", "--", "echo", "$HQ_TASK_ID"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Test JSON output + output = hq_env.command(["task", "workdir", "1", "1-2", "--output-mode", "json"]) + data = json.loads(output) + + assert isinstance(data, list) + assert len(data) == 1 # One job + assert data[0]["job_id"] == 1 + assert "tasks" in data[0] + assert isinstance(data[0]["tasks"], dict) + assert "1" in data[0]["tasks"] + assert "2" in data[0]["tasks"] + + +def test_task_workdir_quiet_output(hq_env: HqEnv): + """Test task workdir command with quiet output.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit array job + hq_env.command(["submit", "--array", "1-2", "--", "echo", "$HQ_TASK_ID"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Test quiet output - should be empty + output = hq_env.command(["task", "workdir", "1", "1", "--output-mode", "quiet"]) + assert output.strip() == "" + + +def test_task_workdir_last_job_selector(hq_env: HqEnv): + """Test task workdir command with 'last' job selector.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit multiple jobs + hq_env.command(["submit", "--array", "1-2", "--", "echo", "job1"]) + hq_env.command(["submit", "--array", "1-2", "--", "echo", "job2"]) + wait_for_job_state(hq_env, [1, 2], "FINISHED") + + # Test with 'last' selector - should show tasks from job 2 + output = hq_env.command(["task", "workdir", "last", "1"]) + + assert "Job 2:" in output + assert "Task 1:" in output + assert "Job 1:" not in output + + +def test_task_workdir_nonexistent_task(hq_env: HqEnv): + """Test task workdir command with nonexistent task.""" + hq_env.start_server() + hq_env.start_worker() + + # Submit job with limited tasks + hq_env.command(["submit", "--array", "1-2", "--", "echo", "$HQ_TASK_ID"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Try to get workdir for nonexistent task + output = hq_env.command(["task", "workdir", "1", "999"]) + + # Should show job header but no tasks (since task doesn't exist) + assert "Job 1:" in output + # Should not crash, just show no tasks + + +def test_task_workdir_with_task_dir_placeholder(hq_env: HqEnv): + """Test task workdir command with task directory placeholders.""" + hq_env.start_server() + hq_env.start_worker() + + # Create a temporary directory and use it as working directory instead of placeholder + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + # Submit job with custom working directory (avoid complex placeholders) + hq_env.command(["submit", "--array", "1-2", "--cwd", tmpdir, "--", "echo", "$HQ_TASK_ID"]) + wait_for_job_state(hq_env, 1, "FINISHED") + + # Check task workdir output - should show the working directory + output = hq_env.command(["task", "workdir", "1", "1"]) + + assert "Job 1:" in output + assert "Task 1:" in output + # Should contain the temp directory path + assert tmpdir in output + + +def test_job_workdir_help(hq_env: HqEnv): + """Test that job workdir help works.""" + hq_env.start_server() + + # Test help output + output = hq_env.command(["job", "workdir", "--help"]) + assert "Display working directory of selected job(s)" in output + assert "SELECTOR" in output + + +def test_task_workdir_help(hq_env: HqEnv): + """Test that task workdir help works.""" + hq_env.start_server() + + # Test help output + output = hq_env.command(["task", "workdir", "--help"]) + assert "Display working directory of selected task(s)" in output + assert "JOB_SELECTOR" in output + assert "TASK_SELECTOR" in output