1+ #!/usr/bin/python
12import argparse
23import datetime
34import functools
5+ import logging
46import os
7+ import shlex
58import subprocess
69import sys
10+ from pathlib import Path
711
8- import submitit
12+ import submitit # type: ignore[import-not-found]
913
14+ from turboblast .logo import logo
1015
11- def process_line (slurmexe , options_one_line ):
12- """
13- Function executed by each task in the job array.
16+ # Configure the logger globally
17+ # This format matches standard logging practices: [Date Time] [LEVEL] Message
18+ logging .basicConfig (
19+ level = logging .INFO ,
20+ format = "%(asctime)s [%(levelname)s] %(message)s" ,
21+ datefmt = "%Y-%m-%d %H:%M:%S" ,
22+ handlers = [logging .StreamHandler (sys .stdout )],
23+ )
24+
25+ logger = logging .getLogger (__name__ )
26+
27+
28+ def process_line (slurmexe : str , options_one_line : str ) -> None :
29+ """Executes a single task in the Slurm job array by calling a bash script.
30+
31+ This function is serialized and executed on the Slurm compute node by submitit.
32+ It constructs the command, sets up the environment, and streams both standard
33+ output and standard error directly to the submitit log files.
34+
35+ Args:
36+ slurmexe (str): The path to the bash executable/script to run.
37+ options_one_line (str): A string containing the command-line arguments
38+ to pass to the bash script (e.g., "--input file.txt --output dir/").
39+
40+ Raises:
41+ subprocess.CalledProcessError: If the bash command exits with a non-zero
42+ status code, ensuring submitit and Slurm mark the task as FAILED.
1443 """
15- print ( f"[Task options: { options_one_line } ] Starting computation " , flush = True )
44+ logger . info ( "Starting computation for options: %s " , options_one_line )
1645
1746 # Construct the command
18- # We use shlex.split if options are complex, but for simple strings list structure is better
19- # However, since shell=False is safer, we construct the command list
20- cmd = ["bash" , slurmexe ] + options_one_line .split ()
47+ # shlex.split is safer than string.split() if your options contain quoted strings
48+ # RUF005: Use unpacking instead of list concatenation
49+ cmd = ["bash" , slurmexe , * shlex .split (options_one_line )]
2150
22- print ( f "Executing: { ' ' .join (cmd )} " , flush = True )
51+ logger . info ( "Executing command: %s" , " " .join (cmd ))
2352
24- # We use subprocess.call or run WITHOUT capture_output.
25- # This allows logs to stream directly to the submitit log files in real-time.
2653 # 1. Prepare the environment correctly
27- # Copy the current environment (including PATH, LD_LIBRARY_PATH, etc.)
2854 full_env = os .environ .copy ()
29- # Add your specific variable
3055 full_env ["PYTHONUNBUFFERED" ] = "1"
56+
3157 try :
32- # check=True will raise a CalledProcessError if return code != 0
33- # subprocess.run(cmd, shell=False, check=True, env=env)
3458 subprocess .run (
3559 cmd ,
3660 shell = False ,
3761 check = True ,
3862 stdout = sys .stdout ,
39- stderr = sys .stderr ,
63+ # Redirect stderr to stdout to merge logs chronologically.
64+ # This ensures INFO and WARNING/ERROR messages don't get out of sync.
65+ stderr = subprocess .STDOUT ,
4066 env = full_env ,
4167 )
42- sys . stdout . flush () # Force a flush after the process finishes
43- print (
44- f "Task with options { options_one_line } completed successfully. " , flush = True
45- )
68+ # Flush to ensure everything is written to the submitit .out file immediately
69+ sys . stdout . flush ()
70+ logger . info ( "Task completed successfully for options: %s " , options_one_line )
71+
4672 except subprocess .CalledProcessError as e :
47- print (f"Error in task with options: { options_one_line } " , file = sys .stderr )
48- print (f"Command returned exit status { e .returncode } " , file = sys .stderr )
49- raise e # Re-raise exception so submitit marks the job as FAILED
73+ # TRY400: Use logging.exception instead of logging.error inside except block
74+ logger .exception (
75+ "Task failed for options: %s (exit status %s)" ,
76+ options_one_line ,
77+ e .returncode ,
78+ )
79+ # TRY201: Use bare raise
80+ raise
81+
82+
83+ def parser_args () -> argparse .Namespace :
84+ """Parses command-line arguments for the submitit client.
5085
86+ Returns:
87+ argparse.Namespace: An object containing all the parsed command-line
88+ arguments and their values.
89+ """
5190
52- def parser_args ():
53- parser = argparse .ArgumentParser (description = "Submitit array job example" )
91+ # formatter_class=argparse.RawDescriptionHelpFormatter is REQUIRED
92+ # to preserve the newlines and spaces of the ASCII art logo!
93+ parser = argparse .ArgumentParser (
94+ description = logo , formatter_class = argparse .RawDescriptionHelpFormatter
95+ )
5496 parser .add_argument (
5597 "--num-tasks" ,
5698 type = int ,
@@ -81,7 +123,7 @@ def parser_args():
81123 )
82124 parser .add_argument (
83125 "--output-dir" ,
84- help = "Directory to store submitit logs (suffixed with out/YYYYMMDDTHHMMSS/) " ,
126+ help = "Directory to store submitit logs" ,
85127 default = "submitit_logs_array" ,
86128 )
87129 parser .add_argument (
@@ -90,91 +132,108 @@ def parser_args():
90132 return parser .parse_args ()
91133
92134
93- def main (args ):
135+ def main (args : argparse .Namespace ) -> None :
136+ """Configures and submits the job arrays to the Slurm cluster.
137+
138+ This function reads the input file, configures the submitit AutoExecutor
139+ with the required Slurm parameters (memory, partition, time, etc.), and
140+ submits the jobs in chunks to respect Slurm array limits.
141+
142+ Args:
143+ args (argparse.Namespace): The parsed command-line arguments containing
144+ paths and Slurm configuration variables.
145+ """
94146 # Create log directory
95- output_dir_with_date = os .path .join (
96- args .output_dir , datetime .datetime .today ().strftime ("%Y%m%dT%H%M%S" )
97- )
98- os .makedirs (output_dir_with_date , exist_ok = True )
147+ # DTZ002: Use datetime.now with a timezone instead of today()
148+ output_dir_with_date = Path (args .output_dir ) / datetime .datetime .now (
149+ datetime .timezone .utc
150+ ).strftime ("%Y%m%dT%H%M%S" )
151+
152+ # Use instance method for directory creation
153+ output_dir_with_date .mkdir (parents = True , exist_ok = True )
154+
155+ logger .info ("Submitit logs will be stored in: %s" , output_dir_with_date )
156+ logger .info ("Bash script to execute: %s" , args .bash_slurm_exec )
99157
100158 executor = submitit .AutoExecutor (folder = output_dir_with_date , cluster = "slurm" )
101- print ( "args.bash_slurm_exec" , args . bash_slurm_exec )
159+
102160 # Slurm Configuration
103161 executor .update_parameters (
104162 timeout_min = args .timeout_min ,
105163 mem_gb = args .mem_gb ,
106164 cpus_per_task = args .cpus_per_task ,
107165 slurm_partition = args .slurm_partition ,
108166 slurm_array_parallelism = args .slurm_array_parallelism ,
109- # Naming the job makes it easier to find in squeue
110- slurm_job_name = os .path .basename (args .bash_slurm_exec ).replace (".sh" , "" ),
167+ slurm_job_name = Path (args .bash_slurm_exec ).name .replace (".sh" , "" ),
111168 slurm_additional_parameters = {"export" : "ALL,PYTHONUNBUFFERED=1" },
112169 )
113170
114171 # Read inputs
115- with open ( args . listing_input ) as f :
116- # Filter out empty lines just in case
172+ # Use Path object method to correctly open the file (fixes mypy overload error)
173+ with Path ( args . listing_input ). open ( "r" ) as f :
117174 array_inputs = [line .strip () for line in f if line .strip ()]
118175
119176 if not array_inputs :
120- print ( "Error: Input listing file is empty." )
177+ logger . error ( " Input listing file is empty. Aborting submission ." )
121178 return
122179
123- print (f"Submitting { len (array_inputs )} tasks..." )
124-
125- # if an image must be pulled from registry, honestly it is much easier with a .sif on fs.
126- # reason why the block below is commented out.
127- # Pull the image on the submission node first
128- # img_url = "oras://registry.hpc.ifremer.fr/lops-siam-sentinel1-workbench/unifiedwvalticolocs:2026.2.17"
129- # img_sif = "unifiedwvalticolocs_2026.2.17.sif"
130-
131- # if not os.path.exists(img_sif):
132- # print(f"Pulling image {img_url}...")
133- # subprocess.run(["apptainer", "pull", img_sif, img_url], check=True)
180+ # Define the chunk size (e.g., 1000)
181+ chunk_size = 1000
182+ all_jobs = []
134183
135- # KEY CHANGE: Use partial to bind the bash script path to the function
136- # The map_array will only vary the second argument (options_one_line)
184+ logger .info ("Total tasks to submit: %d" , len (array_inputs ))
137185
138- # Define the chunk size (set this slightly below your MaxArraySize, e.g., 500 or 1000)
139- chunk_size = 1000 # Adjust based on your cluster's MaxArraySize and the total number of tasks, here I am not sure this is the limit but it failed with 1379 lines
140- all_jobs = []
186+ # Calculate total number of chunks for logging
187+ total_chunks = (len (array_inputs ) + chunk_size - 1 ) // chunk_size
188+ logger .info (
189+ "Submitting tasks in chunks of %d (Total chunks: %d)..." ,
190+ chunk_size ,
191+ total_chunks ,
192+ )
141193
142- print ( f"Total tasks to submit: { len ( array_inputs ) } " )
194+ process_func = functools . partial ( process_line , args . bash_slurm_exec )
143195
144196 # Loop through the inputs in chunks
145- chuncked_inputs = range (0 , len (array_inputs ), chunk_size )
146- print (f"Submitting tasks in chunks of { chunk_size } ..." )
147- print (f"Total chunks to submit: { len (list (chuncked_inputs ))} " )
148- for i in chuncked_inputs :
197+ for chunk_idx , i in enumerate (range (0 , len (array_inputs ), chunk_size ), start = 1 ):
149198 chunk = array_inputs [i : i + chunk_size ]
150- print (f"Submitting chunk starting at index { i } (size: { len (chunk )} )..." )
199+ logger .info (
200+ "Submitting chunk %d/%d (size: %d, starting at index %d)..." ,
201+ chunk_idx ,
202+ total_chunks ,
203+ len (chunk ),
204+ i ,
205+ )
151206
152- # This will create a NEW Slurm Job ID for every 1000 tasks
153- process_func = functools .partial (process_line , args .bash_slurm_exec )
207+ # This will create a NEW Slurm Job Array for every 1000 tasks
154208 jobs = executor .map_array (process_func , chunk )
155209
156- print (f"Submitted. Job ID for this chunk: { jobs [0 ].job_id } " )
210+ logger .info (
211+ "Chunk %d submitted successfully. Job Array ID: %s" ,
212+ chunk_idx ,
213+ jobs [0 ].job_id ,
214+ )
157215 all_jobs .extend (jobs )
158216
159- print (
160- f"Successfully submitted all { len (all_jobs )} tasks across multiple Job Arrays."
217+ logger .info (
218+ "Successfully submitted all %d tasks across %d Job Arrays." ,
219+ len (all_jobs ),
220+ total_chunks ,
161221 )
162222
163- # process_func = functools.partial(process_line, args.bash_slurm_exec)
164-
165- # # Submit the array
166- # jobs = executor.map_array(process_func, array_inputs)
223+ if all_jobs :
224+ logger .info ("First Job Array Main Job ID: %s" , all_jobs [0 ].job_id )
225+ logger .info (
226+ "To monitor specific task logs, use: tail -f %s/<JOB_ID>_<TASK_ID>_0.out" ,
227+ output_dir_with_date ,
228+ )
167229
168- # Get the ID of the array job
169- # jobs[0] gives access to the whole array group usually, or iterate to see IDs
170- print (f"Job Array submitted. Main Job ID: { jobs [0 ].job_id } " )
171- print (f"Logs are being written to: { output_dir_with_date } " )
172- print (
173- f"You can monitor specific logs using: tail -f { output_dir_with_date } /<JOB_ID>_<TASK_ID>_0.out"
174- )
175230
231+ def entrypoint () -> None :
232+ """Script entrypoint.
176233
177- def entrypoint ():
234+ Calls the argument parser and passes the resulting arguments to the
235+ main execution function.
236+ """
178237 args = parser_args ()
179238 main (args )
180239
0 commit comments