From 13ef363e8d578aa6e41631c4236512fcbc78b6a7 Mon Sep 17 00:00:00 2001 From: Len Washington III <77632128+lwashington3@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:25:00 -0500 Subject: [PATCH 1/4] Added the ability to add command line arguments when submitting a job. --- src/sfapi_client/_async/compute.py | 5 ++++- src/sfapi_client/_sync/compute.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/sfapi_client/_async/compute.py b/src/sfapi_client/_async/compute.py index 2bcf495..3af1542 100644 --- a/src/sfapi_client/_async/compute.py +++ b/src/sfapi_client/_async/compute.py @@ -126,10 +126,11 @@ async def _wait_for_task(self, task_id) -> AsyncCommandTaskResult: return task.result @check_auth - async def submit_job(self, script: Union[str, AsyncRemotePath]) -> AsyncJobSqueue: + async def submit_job(self, script: Union[str, AsyncRemotePath], args: Optional[List[str]] = None) -> AsyncJobSqueue: """Submit a job to the compute resource :param script: Path to file on the compute system, or script to run beginning with `#!`. + :param args: An optional list of command line arguments to pass to the script. :return: Object containing information about the job, its job id, and status on the system. """ @@ -150,6 +151,8 @@ async def submit_job(self, script: Union[str, AsyncRemotePath]) -> AsyncJobSqueu raise SfApiError(f"Script path not present or is not a file, {script}") data = {"job": script, "isPath": is_path} + if args is not None: + data["args"] = args r = await self.client.post(f"compute/jobs/{self.name}", data) r.raise_for_status() diff --git a/src/sfapi_client/_sync/compute.py b/src/sfapi_client/_sync/compute.py index 148454b..971fd49 100644 --- a/src/sfapi_client/_sync/compute.py +++ b/src/sfapi_client/_sync/compute.py @@ -127,10 +127,11 @@ def _wait_for_task(self, task_id) -> CommandTaskResult: return task.result @check_auth - def submit_job(self, script: Union[str, RemotePath]) -> JobSqueue: + def submit_job(self, script: Union[str, RemotePath], args: Optional[List[str]] = None) -> JobSqueue: """Submit a job to the compute resource :param script: Path to file on the compute system, or script to run beginning with `#!`. + :param args: An optional list of command line arguments to pass to the script. :return: Object containing information about the job, its job id, and status on the system. """ @@ -151,6 +152,8 @@ def submit_job(self, script: Union[str, RemotePath]) -> JobSqueue: raise SfApiError(f"Script path not present or is not a file, {script}") data = {"job": script, "isPath": is_path} + if args is not None: + data["args"] = args r = self.client.post(f"compute/jobs/{self.name}", data) r.raise_for_status() From c80fef0168fd30cc35b9e5c0b2824e059e81e096 Mon Sep 17 00:00:00 2001 From: Chris Harris Date: Tue, 14 Jul 2026 17:10:17 +0000 Subject: [PATCH 2/4] Add test case for submit with args --- tests/conftest.py | 27 +++++++++++++++++++++++++++ tests/test_jobs.py | 24 ++++++++++++++++++++++++ tests/test_jobs_async.py | 27 +++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 9e8acd4..6278e11 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import json import random import string +from io import BytesIO from typing import Optional, Union, Dict from pathlib import Path from cryptography.hazmat.primitives.asymmetric import rsa @@ -23,6 +24,7 @@ class Settings(BaseSettings): SFAPI_DEV_CLIENT_ID: Optional[str] = None SFAPI_DEV_CLIENT_SECRET: Optional[Union[str, Dict]] = None TEST_JOB_PATH: Optional[str] = None + TEST_JOB_ACCOUNT: Optional[str] = None TEST_MACHINE: Machine = Machine.perlmutter TEST_RESOURCE: Resource = Resource.spin TEST_USERNAME: Optional[str] = None @@ -75,6 +77,11 @@ def test_job_path(): return settings.TEST_JOB_PATH +@pytest.fixture +def test_job_account(): + return settings.TEST_JOB_ACCOUNT + + @pytest.fixture def test_machine(): return settings.TEST_MACHINE @@ -214,3 +221,23 @@ def empty_key_file(tmp_path_factory): if temp_path.exists(): (temp_path / "nokey.pem").unlink(missing_ok=True) temp_path.rmdir() + + +@pytest.fixture +def test_arg_job_script(test_job_account, test_tmp_dir): + script = BytesIO( + f"""#!/usr/bin/env bash +#SBATCH --nodes=1 +#SBATCH --constraint=cpu +#SBATCH --account={test_job_account} +#SBATCH --qos=debug +#SBATCH --time=00:01:00 +#SBATCH --output={test_tmp_dir}/slurm-%j.out + +set -euo pipefail + +echo "$@" +""".encode() + ) + script.filename = "echo-args.sh" + return script diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 497f971..fe8c48c 100755 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -17,6 +17,30 @@ def test_submit(authenticated_client, test_job_path, test_machine): assert state == JobState.COMPLETED +def test_submit_with_args( + authenticated_client, test_machine, test_tmp_dir, test_arg_job_script +): + with authenticated_client as client: + machine = client.compute(test_machine) + + [remote_dir] = machine.ls(test_tmp_dir, directory=True) + remote_script = remote_dir.upload(test_arg_job_script) + try: + args = ["1", "2", "3"] + + job = machine.submit_job(remote_script, args) + + state = job.complete() + + assert state == JobState.COMPLETED + + [output_path] = machine.ls(f"{test_tmp_dir}/slurm-{job.jobid}.out") + output = output_path.download().read() + assert "1 2 3" in output + finally: + machine.run(["rm", "-f", str(remote_script)]) + + def test_cancel(authenticated_client, test_job_path, test_machine): with authenticated_client as client: machine = client.compute(test_machine) diff --git a/tests/test_jobs_async.py b/tests/test_jobs_async.py index 3c1b5ab..4f49aea 100755 --- a/tests/test_jobs_async.py +++ b/tests/test_jobs_async.py @@ -16,6 +16,33 @@ async def test_submit(async_authenticated_client, test_job_path, test_machine): assert state == JobState.COMPLETED +@pytest.mark.asyncio +async def test_submit_with_args( + async_authenticated_client, test_machine, test_tmp_dir, test_arg_job_script +): + async with async_authenticated_client as client: + machine = await client.compute(test_machine) + + [remote_dir] = await machine.ls(test_tmp_dir, directory=True) + remote_script = await remote_dir.upload(test_arg_job_script) + try: + args = ["1", "2", "3"] + + job = await machine.submit_job(remote_script, args) + + state = await job.complete() + + assert state == JobState.COMPLETED + + [output_path] = await machine.ls( + f"{test_tmp_dir}/slurm-{job.jobid}.out" + ) + output = (await output_path.download()).read() + assert "1 2 3" in output + finally: + await machine.run(["rm", "-f", str(remote_script)]) + + @pytest.mark.asyncio async def test_cancel(async_authenticated_client, test_job_path, test_machine): async with async_authenticated_client as client: From 1efac341176b6ca9d6889d76be45d78a34bc2630 Mon Sep 17 00:00:00 2001 From: Len Washington III <77632128+lwashington3@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:31:31 -0500 Subject: [PATCH 3/4] Added check to make sure that the script is a path before passing through command line arguments. --- src/sfapi_client/_async/compute.py | 2 ++ src/sfapi_client/_sync/compute.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/sfapi_client/_async/compute.py b/src/sfapi_client/_async/compute.py index 3af1542..124d31a 100644 --- a/src/sfapi_client/_async/compute.py +++ b/src/sfapi_client/_async/compute.py @@ -152,6 +152,8 @@ async def submit_job(self, script: Union[str, AsyncRemotePath], args: Optional[L data = {"job": script, "isPath": is_path} if args is not None: + if not is_path: + raise ValueError("Command line arguments cannot be passed when the script is not a file.") data["args"] = args r = await self.client.post(f"compute/jobs/{self.name}", data) diff --git a/src/sfapi_client/_sync/compute.py b/src/sfapi_client/_sync/compute.py index 971fd49..2943fd8 100644 --- a/src/sfapi_client/_sync/compute.py +++ b/src/sfapi_client/_sync/compute.py @@ -153,6 +153,8 @@ def submit_job(self, script: Union[str, RemotePath], args: Optional[List[str]] = data = {"job": script, "isPath": is_path} if args is not None: + if not is_path: + raise ValueError("Command line arguments cannot be passed when the script is not a file.") data["args"] = args r = self.client.post(f"compute/jobs/{self.name}", data) From 93973b882d96edbefebf3690e12bd3daec501a01 Mon Sep 17 00:00:00 2001 From: Chris Harris Date: Tue, 14 Jul 2026 19:28:00 +0000 Subject: [PATCH 4/4] Fix formatting --- src/sfapi_client/_async/compute.py | 8 ++++++-- tests/conftest.py | 6 ++---- tests/test_jobs_async.py | 4 +--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/sfapi_client/_async/compute.py b/src/sfapi_client/_async/compute.py index 124d31a..875b58a 100644 --- a/src/sfapi_client/_async/compute.py +++ b/src/sfapi_client/_async/compute.py @@ -126,7 +126,9 @@ async def _wait_for_task(self, task_id) -> AsyncCommandTaskResult: return task.result @check_auth - async def submit_job(self, script: Union[str, AsyncRemotePath], args: Optional[List[str]] = None) -> AsyncJobSqueue: + async def submit_job( + self, script: Union[str, AsyncRemotePath], args: Optional[List[str]] = None + ) -> AsyncJobSqueue: """Submit a job to the compute resource :param script: Path to file on the compute system, or script to run beginning with `#!`. @@ -153,7 +155,9 @@ async def submit_job(self, script: Union[str, AsyncRemotePath], args: Optional[L data = {"job": script, "isPath": is_path} if args is not None: if not is_path: - raise ValueError("Command line arguments cannot be passed when the script is not a file.") + raise ValueError( + "Command line arguments cannot be passed when the script is not a file." + ) data["args"] = args r = await self.client.post(f"compute/jobs/{self.name}", data) diff --git a/tests/conftest.py b/tests/conftest.py index 6278e11..d0e1ab8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -225,8 +225,7 @@ def empty_key_file(tmp_path_factory): @pytest.fixture def test_arg_job_script(test_job_account, test_tmp_dir): - script = BytesIO( - f"""#!/usr/bin/env bash + script = BytesIO(f"""#!/usr/bin/env bash #SBATCH --nodes=1 #SBATCH --constraint=cpu #SBATCH --account={test_job_account} @@ -237,7 +236,6 @@ def test_arg_job_script(test_job_account, test_tmp_dir): set -euo pipefail echo "$@" -""".encode() - ) +""".encode()) script.filename = "echo-args.sh" return script diff --git a/tests/test_jobs_async.py b/tests/test_jobs_async.py index 4f49aea..3d8ceee 100755 --- a/tests/test_jobs_async.py +++ b/tests/test_jobs_async.py @@ -34,9 +34,7 @@ async def test_submit_with_args( assert state == JobState.COMPLETED - [output_path] = await machine.ls( - f"{test_tmp_dir}/slurm-{job.jobid}.out" - ) + [output_path] = await machine.ls(f"{test_tmp_dir}/slurm-{job.jobid}.out") output = (await output_path.download()).read() assert "1 2 3" in output finally: