File Coverage

blib/lib/NBI/Opts.pm
Criterion Covered Total %
statement 211 265 79.6
branch 129 208 62.0
condition 8 25 32.0
subroutine 34 37 91.8
pod 24 24 100.0
total 406 559 72.6


line stmt bran cond sub pod time code
1             package NBI::Opts;
2             #ABSTRACT: A class for representing a the SLURM options for NBI::Slurm
3             #
4             # NBI::Opts - Stores and validates SLURM resource options for a job.
5             #
6             # DESCRIPTION:
7             # Holds every resource knob that ends up as a #SBATCH directive in the
8             # job script. Key responsibilities:
9             # - new() : accepts -queue, -threads, -memory, -time, -tmpdir,
10             # -email_address, -email_type, -opts (extra directives),
11             # -files (enables array-job mode), -placeholder
12             # - header() : generates the full #!/bin/bash + #SBATCH header block
13             # consumed by NBI::Job->script()
14             # - timestring() : converts internal hours to SLURM "D-HH:MM:SS" format
15             # - is_array() : returns true when a -files list is present
16             # - add_option() : appends a raw #SBATCH option string
17             # - view() : returns a human-readable summary string
18             # Internal helpers:
19             # - _mem_parse_mb() : normalises memory values (KB/MB/GB/TB) to MB
20             # - _time_to_hour() : normalises time strings (e.g. "2h30m", "1d") to hours
21             # - _parse_start_time(): validates/normalises HH:MM[:SS] → "HH:MM:SS"
22             # - _parse_start_date(): validates/normalises DD/MM[/YYYY] → "YYYY-MM-DD",
23             # inferring year when omitted
24             # - _compute_begin() : combines start_time + start_date with date-inference
25             # logic and returns the SLURM "--begin" value string
26             #
27             # RELATIONSHIPS:
28             # - Composed into NBI::Job via the -opts constructor argument or set_opts().
29             # - NBI::Job calls $self->opts->header() and reads tmpdir, placeholder,
30             # files, and is_array() to build the sbatch script.
31             # - $NBI::Opts::VERSION is set from $NBI::Slurm::VERSION (loaded by caller).
32             #
33              
34 28     28   236994 use 5.012;
  28         82  
35 28     28   103 use warnings;
  28         38  
  28         1280  
36 28     28   127 use Carp qw(confess);
  28         35  
  28         1320  
37 28     28   5936 use Data::Dumper;
  28         6045  
  28         1291  
38             $Data::Dumper::Sortkeys = 1;
39 28     28   108 use File::Basename;
  28         70  
  28         1411  
40 28     28   11637 use POSIX qw(mktime strftime);
  28         158060  
  28         147  
41              
42             $NBI::Opts::VERSION = $NBI::Slurm::VERSION;
43              
44             my $SYSTEM_TEMPDIR = $ENV{'TMPDIR'} || $ENV{'TEMP'} || "/tmp";
45             require Exporter;
46             our @ISA = qw(Exporter);
47              
48             sub _yell {
49 28     28   46311 use Term::ANSIColor;
  28         116438  
  28         95974  
50 0     0   0 my $msg = shift @_;
51 0   0     0 my $col = shift @_ || "bold green";
52 0         0 say STDERR color($col), "[NBI::Opts]", color("reset"), " $msg";
53             }
54             sub new {
55 124     124 1 13820 my $class = shift @_;
56 124         370 my ($queue, $memory, $threads, $opts_array, $tmpdir, $hours, $email_address, $email_when, $files, $placeholder, $start_time, $start_date, $params_array, $params_rows, $array_offset, $array_tasks) = (undef) x 16;
57            
58             # Descriptive instantiation with parameters -param => value
59 124 100       334 if (substr($_[0], 0, 1) eq '-') {
60            
61 121         531 my %data = @_;
62              
63             # Try parsing
64 121         316 for my $i (keys %data) {
65            
66             # QUEUE
67 941 100       3007 if ($i =~ /^-queue/) {
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    50          
    50          
68 120 50       230 next unless (defined $data{$i});
69 120         179 $queue = $data{$i};
70            
71              
72             # THREADS
73             } elsif ($i =~ /^-threads/) {
74 118 50       205 next unless (defined $data{$i});
75             # Check it's an integer
76 118 50       360 if ($data{$i} =~ /^\d+$/) {
77 118         219 $threads = $data{$i};
78             } else {
79 0         0 confess "ERROR NBI::Seq: -threads expects an integer\n";
80             }
81            
82            
83             # MEMORY
84             } elsif ($i =~ /^-memory/) {
85 118 50       217 next unless (defined $data{$i});
86 118         216 $memory = _mem_parse_mb($data{$i});
87            
88              
89             # TMPDIR
90             } elsif ($i =~ /^-tmpdir/) {
91 118 50       294 next unless (defined $data{$i});
92 118         189 $tmpdir = $data{$i};
93            
94             # MAIL ADDRESS
95             } elsif ($i =~ /^-(mail|email_address)/) {
96 105 100       160 next unless (defined $data{$i});
97 101         139 $email_address = $data{$i};
98            
99             # WHEN MAIL
100             } elsif ($i =~ /^-(when|email_type)/) {
101 105 50       202 next unless (defined $data{$i});
102 105         148 $email_when = $data{$i};
103            
104              
105             # OPTS ARRAY
106             } elsif ($i =~ /^-opts/) {
107 105 50       158 next unless (defined $data{$i});
108             # in this case we expect an array
109 105 50       204 if (ref($data{$i}) ne "ARRAY") {
110 0         0 confess "ERROR NBI::Seq: -opts expects an array\n";
111             }
112 105         136 $opts_array = $data{$i};
113            
114              
115             # TIME
116             } elsif ($i =~ /^-time/) {
117 118         291 $hours = _time_to_hour($data{$i});
118            
119             # PLACEHOLDER
120             } elsif ($i =~ /^-placeholder/) {
121             # check if placeholder contains special regex characters
122 6 50       21 if (not defined $data{$i}) {
123 0         0 confess "ERROR NBI::Seq: Placeholder cannot be empty\n";
124             }
125 6 50       16 if ($data{$i} =~ /[\*\+\?]/) {
126 0         0 confess "ERROR NBI::Seq: Placeholder cannot contain special regex characters\n";
127             }
128 6         11 $placeholder = $data{$i};
129            
130             # ARRAY
131             } elsif ($i =~ /^-files/) {
132             # expects ref to array
133 5 50       14 if (ref($data{$i}) ne "ARRAY") {
134 0         0 confess "ERROR NBI::Seq: -files expects an array\n";
135             } else {
136 5         10 $files = $data{$i};
137             }
138              
139             # PARAMS ARRAY
140             } elsif ($i =~ /^-params_array/) {
141 6 50       13 next unless defined $data{$i};
142 6         14 $params_array = $data{$i};
143              
144             # PARAMS ARRAY ROW COUNT
145             } elsif ($i =~ /^-params_rows/) {
146 6 50       14 next unless defined $data{$i};
147 6 50       22 if ($data{$i} =~ /^\d+$/) {
148 6         14 $params_rows = $data{$i};
149             } else {
150 0         0 confess "ERROR NBI::Seq: -params_rows expects an integer\n";
151             }
152              
153             # ARRAY OFFSET
154             } elsif ($i =~ /^-array_offset/) {
155 5 50       11 next unless defined $data{$i};
156 5 50       17 if ($data{$i} =~ /^\d+$/) {
157 5         34 $array_offset = $data{$i};
158             } else {
159 0         0 confess "ERROR NBI::Seq: -array_offset expects an integer\n";
160             }
161              
162             # ARRAY TASK COUNT
163             } elsif ($i =~ /^-array_tasks/) {
164 5 50       16 next unless defined $data{$i};
165 5 50       19 if ($data{$i} =~ /^\d+$/) {
166 5         11 $array_tasks = $data{$i};
167             } else {
168 0         0 confess "ERROR NBI::Seq: -array_tasks expects an integer\n";
169             }
170            
171             # START TIME
172             } elsif ($i =~ /^-start_time/) {
173 0 0       0 next unless defined $data{$i};
174 0         0 $start_time = _parse_start_time($data{$i});
175              
176             # START DATE
177             } elsif ($i =~ /^-start_date/) {
178 0 0       0 next unless defined $data{$i};
179 0         0 $start_date = _parse_start_date($data{$i});
180              
181             } else {
182 1         187 confess "ERROR NBI::Seq: Unknown parameter $i\n";
183             }
184             }
185             }
186            
187 123         303 my $self = bless {}, $class;
188            
189             # Set attributes
190 123 100       321 $self->queue = defined $queue ? $queue : "nbi-short";
191 123 100       297 $self->threads = defined $threads ? $threads : 1;
192 123 100       271 $self->memory = defined $memory ? $memory : 100;
193 123 100       251 $self->hours = defined $hours ? $hours : 1;
194 123 100       261 $self->tmpdir = defined $tmpdir ? $tmpdir : $SYSTEM_TEMPDIR;
195 123 100       292 $self->email_address = defined $email_address ? $email_address : undef;
196 123 100       282 $self->email_type = defined $email_when ? $email_when : "none";
197 123 100       335 $self->files = defined $files ? $files : [];
198 123 100       262 $self->placeholder = defined $placeholder ? $placeholder : "#FILE#";
199 123 100       233 $self->params_array = defined $params_array ? $params_array : undef;
200 123 100       249 $self->params_rows = defined $params_rows ? $params_rows : 0;
201 123 100       223 $self->{array_offset} = defined $array_offset ? $array_offset : 0;
202 123 100       209 $self->{array_tasks} = defined $array_tasks ? $array_tasks : 0;
203             # Set options
204 123 100       277 $self->opts = defined $$opts_array[0] ? $opts_array : [];
205             # Begin time (optional)
206 123         166 $self->{start_time} = $start_time;
207 123         158 $self->{start_date} = $start_date;
208            
209            
210            
211            
212              
213 123         292 return $self;
214            
215             }
216              
217              
218             sub queue : lvalue {
219             # Update queue
220 132     132 1 237 my ($self, $new_val) = @_;
221 132 50       247 $self->{queue} = $new_val if (defined $new_val);
222 132         432 return $self->{queue};
223             }
224              
225             sub threads : lvalue {
226             # Update threads
227 123     123 1 156 my ($self, $new_val) = @_;
228 123 50       195 $self->{threads} = $new_val if (defined $new_val);
229 123         198 return $self->{threads};
230             }
231              
232             sub memory : lvalue {
233             # Update memory
234 123     123 1 143 my ($self, $new_val) = @_;
235 123 50       164 $self->{memory} = _mem_parse_mb($new_val) if (defined $new_val);
236 123         171 return $self->{memory};
237             }
238              
239             sub email_address : lvalue {
240             # Update memory
241 123     123 1 157 my ($self, $new_val) = @_;
242 123 50       181 $self->{email_address} = $new_val if (defined $new_val);
243 123         260 return $self->{email_address};
244             }
245              
246             sub email_type : lvalue {
247             # Update memory
248 123     123 1 147 my ($self, $new_val) = @_;
249 123 50       180 $self->{email_type} = $new_val if (defined $new_val);
250 123         198 return $self->{email_type};
251             }
252              
253             # property files (list of files)
254             sub files : lvalue {
255             # Update files
256 124     124 1 150 my ($self, $new_val) = @_;
257 124 50       189 $self->{files} = $new_val if (defined $new_val);
258 124         222 return $self->{files};
259             }
260              
261             sub placeholder : lvalue {
262             # Update placeholder
263 124     124 1 149 my ($self, $new_val) = @_;
264 124 50       167 $self->{placeholder} = $new_val if (defined $new_val);
265 124         187 return $self->{placeholder};
266             }
267             sub params_array : lvalue {
268 130     130 1 170 my ($self, $new_val) = @_;
269 130 50       196 $self->{params_array} = $new_val if (defined $new_val);
270 130         213 return $self->{params_array};
271             }
272             sub params_rows : lvalue {
273 123     123 1 188 my ($self, $new_val) = @_;
274 123 50       158 $self->{params_rows} = $new_val if (defined $new_val);
275 123         273 return $self->{params_rows};
276             }
277             sub start_time : lvalue {
278 0     0 1 0 my ($self, $new_val) = @_;
279 0 0       0 $self->{start_time} = _parse_start_time($new_val) if defined $new_val;
280 0         0 return $self->{start_time};
281             }
282              
283             sub start_date : lvalue {
284 0     0 1 0 my ($self, $new_val) = @_;
285 0 0       0 $self->{start_date} = _parse_start_date($new_val) if defined $new_val;
286 0         0 return $self->{start_date};
287             }
288              
289             sub is_array {
290             # Check if the job is an array
291 773     773 1 884 my $self = shift @_;
292 773   100     989 return $self->is_files_array() || $self->is_params_array();
293             }
294             sub is_files_array {
295 863     863 1 842 my $self = shift @_;
296 863         778 return scalar @{$self->{files}} > 0;
  863         2098  
297             }
298             sub is_params_array {
299 855     855 1 960 my $self = shift @_;
300 855   66     2551 return defined $self->{params_array} && $self->{params_rows} > 0;
301             }
302             sub array_size {
303 3     3 1 3 my $self = shift @_;
304 3 100       5 return scalar @{$self->{files}} if $self->is_files_array();
  1         1  
305 2 50       5 return $self->{params_rows} if $self->is_params_array();
306 0         0 return 0;
307             }
308             sub hours : lvalue {
309             # Update memory
310 124     124 1 160 my ($self, $new_val) = @_;
311 124 50       191 $self->{hours} = _time_to_hour($new_val) if (defined $new_val);
312 124         235 return $self->{hours};
313             }
314              
315             sub tmpdir : lvalue {
316             # Update tmpdir
317 362     362 1 425 my ($self, $new_val) = @_;
318 362 50       498 $self->{tmpdir} = $new_val if (defined $new_val);
319 362         626 return $self->{tmpdir};
320             }
321              
322             sub opts : lvalue {
323             # Update opts
324 123     123 1 150 my ($self, $new_val) = @_;
325 123 50       203 if (not defined $self->{opts}) {
326 123         163 $self->{opts} = [];
327 123         230 return $self->{opts};
328             }
329             # check newval is an array
330 0 0       0 confess "ERROR NBI::Opts: opts must be an array, got $new_val\n" if (ref($new_val) ne "ARRAY");
331 0 0       0 $self->{opts} = $new_val if (defined $new_val);
332 0         0 return $self->{opts};
333             }
334             sub add_option {
335             # Add an option
336 2     2 1 483 my ($self, $new_val) = @_;
337 2         7 push @{$self->{opts}}, $new_val;
  2         9  
338 2         7 return $self->{opts};
339             }
340              
341             sub opts_count {
342             # Return the number of options
343 1     1 1 4 my $self = shift @_;
344 1 50       2 return defined $self->{opts} ? scalar @{$self->{opts}} : 0;
  1         4  
345             }
346              
347             sub view {
348             # Return a string representation of the object
349 203     203 1 425 my $self = shift @_;
350 203         204 my $str = " --- NBI::Opts object ---\n";
351 203         355 $str .= " queue:\t" . $self->{queue} . "\n";
352 203         261 $str .= " threads:\t" . $self->{threads} . "\n";
353 203         244 $str .= " memory MB:\t" . $self->{memory} . "\n";
354 203         816 $str .= " time (h):\t" . $self->{hours} . "\n";
355 203         267 $str .= " tmpdir:\t" . $self->{tmpdir} . "\n";
356 203         263 my $begin = $self->_compute_begin();
357 203 50       280 $str .= " begin:\t" . $begin . "\n" if defined $begin;
358 203         303 $str .= " ---------------------------\n";
359 203         185 for my $o (@{$self->{opts}}) {
  203         292  
360 203 50       444 $str .= "#SBATCH $o\n" if defined $o;
361             }
362 203         361 return $str;
363             }
364              
365             sub header {
366             # Return a header for the script based on the options
367 215     215 1 249 my $self = shift @_;
368 215         245 my $str = "#!/bin/bash\n";
369             # Queue
370 215         351 $str .= "#SBATCH -p " . $self->{queue} . "\n";
371             # Nodes: 1
372 215         268 $str .= "#SBATCH -N 1\n";
373             # Time
374 215         349 $str .= "#SBATCH -t " . $self->timestring() . "\n";
375             # Memory
376 215         345 $str .= "#SBATCH --mem=" . $self->{memory} . "\n";
377             # Threads
378 215         259 $str .= "#SBATCH -c " . $self->{threads} . "\n";
379             # Mail
380 215 100       380 if (defined $self->{email_address}) {
381 202         248 $str .= "#SBATCH --mail-user=" . $self->{email_address} . "\n";
382 202         281 $str .= "#SBATCH --mail-type=" . $self->{email_type} . "\n";
383             }
384              
385             # Custom options
386 215         236 for my $o (@{$self->{opts}}) {
  215         352  
387 203 50       289 next if not defined $o;
388 203         275 $str .= "#SBATCH $o\n";
389             }
390              
391             # Job array
392 215 100       377 if ($self->is_array()) {
393 8 100       32 my $tasks = $self->{array_tasks} > 0 ? $self->{array_tasks} : $self->array_size();
394 8         15 my $len = $tasks - 1;
395 8         21 $str .= "#SBATCH --array=0-$len\n";
396             }
397             # Begin time
398 215         352 my $begin = $self->_compute_begin();
399 215 100       324 $str .= "#SBATCH --begin=$begin\n" if defined $begin;
400 215         394 return $str;
401             }
402              
403             sub timestring {
404 215     215 1 269 my $self = shift @_;
405 215         271 my $hours = $self->{hours};
406 215         397 my $days = 0+ int($hours / 24);
407 215         295 $hours = $hours % 24;
408             # Format hours to be 2 digits
409 215         576 $hours = sprintf("%02d", $hours);
410 215         467 return "${days}-${hours}:00:00";
411             }
412              
413             sub _mem_parse_mb {
414 118     118   155 my $mem = shift @_;
415 118 100       376 if ($mem=~/^(\d+)$/) {
    50          
416             # bare number: interpret as MB
417 108         236 return $mem;
418             } elsif ($mem=~/^(\d+)\.?(MB?|GB?|TB?|KB?)$/i) {
419 10 50       39 if (substr(uc($2), 0, 1) eq "G") {
    0          
    0          
    0          
420 10         25 $mem = $1 * 1024;
421             } elsif (substr(uc($2), 0, 1) eq "T") {
422 0         0 $mem = $1 * 1024 * 1024;
423             } elsif (substr(uc($2), 0, 1) eq "M") {
424 0         0 $mem = $1;
425             } elsif (substr(uc($2), 0, 1) eq "K") {
426 0         0 $mem = int($1/1024);
427             } else {
428             # Consider MB
429 0         0 $mem = $1;
430             }
431             } else {
432 0         0 confess "ERROR NBI::Opts: Cannot parse memory value $mem\n";
433             }
434 10         25 return $mem;
435             }
436              
437             sub _time_to_hour {
438             # Get an integer (hours) or a string in the format \d+D \d+H \d+M \d+S
439 125     125   208 my $time = shift @_;
440 125         239 $time = uc($time);
441            
442 125 100       350 if ($time =~ /^(\d+)$/) {
443             # Got an integer
444 1         5 return $1;
445             } else {
446 124         182 my $hours = 0;
447 124         431 while ($time =~ /(\d+)([DHMS])/g) {
448 426         568 my $val = $1;
449 426         524 my $unit = $2;
450 426 100       699 if ($unit eq "D") {
    100          
    100          
    50          
451 102         271 $hours += $val * 24;
452             } elsif ($unit eq "H") {
453 121         308 $hours += $val;
454             } elsif ($unit eq "M") {
455 102         276 $hours += $val / 60;
456             } elsif ($unit eq "S") {
457 101         183 $hours += $val / 3600;
458             } else {
459 0         0 die "ERROR NBI::Opts: Cannot parse time value $time\n";
460             }
461             }
462 124         322 return $hours;
463             }
464             }
465              
466              
467             sub _parse_start_time {
468             # Accept H:MM, HH:MM, H:MM:SS, HH:MM:SS (24h only). Returns "HH:MM:SS".
469 1     1   3 my $time = shift;
470 1 50       13 unless ($time =~ /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/) {
471 0         0 confess "ERROR NBI::Opts: Cannot parse start time '$time'. Use HH:MM or HH:MM:SS (24h format)\n";
472             }
473 1   50     9 my ($h, $m, $s) = ($1, $2, $3 // 0);
474 1 50       5 confess "ERROR NBI::Opts: Invalid hour $h in '$time' (must be 0-23)\n" if $h > 23;
475 1 50       4 confess "ERROR NBI::Opts: Invalid minute $m in '$time' (must be 0-59)\n" if $m > 59;
476 1 50       3 confess "ERROR NBI::Opts: Invalid second $s in '$time' (must be 0-59)\n" if $s > 59;
477 1         6 return sprintf("%02d:%02d:%02d", $h, $m, $s);
478             }
479              
480             sub _parse_start_date {
481             # Accept DD/MM or DD/MM/YYYY. Returns "YYYY-MM-DD".
482             # When year is omitted, infers current year; if that date is already past, uses next year.
483             # Also accepts the already-normalised YYYY-MM-DD form (idempotent).
484 1     1   3 my $date = shift;
485              
486             # Already normalised by a previous call - pass through unchanged.
487 1 50       8 return $date if $date =~ /^\d{4}-\d{2}-\d{2}$/;
488              
489 0 0       0 unless ($date =~ m{^(\d{1,2})/(\d{1,2})(?:/(\d{4}))?$}) {
490 0         0 confess "ERROR NBI::Opts: Cannot parse start date '$date'. Use DD/MM or DD/MM/YYYY\n";
491             }
492 0         0 my ($day, $mon, $year) = ($1, $2, $3);
493 0 0 0     0 confess "ERROR NBI::Opts: Invalid month $mon in '$date' (must be 1-12)\n" if $mon < 1 || $mon > 12;
494 0 0 0     0 confess "ERROR NBI::Opts: Invalid day $day in '$date' (must be 1-31)\n" if $day < 1 || $day > 31;
495              
496 0 0       0 if (!defined $year) {
497 0         0 my @now = localtime(time);
498 0         0 my $curr_year = $now[5] + 1900;
499             # midnight today for comparison
500 0         0 my $today_midnight = mktime(0, 0, 0, $now[3], $now[4], $now[5]);
501 0         0 my $candidate = mktime(0, 0, 0, $day, $mon - 1, $curr_year - 1900);
502 0 0       0 $year = ($candidate >= $today_midnight) ? $curr_year : $curr_year + 1;
503             }
504              
505             # Validate via mktime round-trip (catches e.g. 31/02)
506 0         0 my $epoch = mktime(0, 0, 12, $day, $mon - 1, $year - 1900);
507 0         0 my @check = localtime($epoch);
508 0 0 0     0 if ($check[3] != $day || $check[4] + 1 != $mon || $check[5] + 1900 != $year) {
      0        
509 0         0 confess "ERROR NBI::Opts: Invalid date '$date' (day out of range for that month)\n";
510             }
511              
512 0         0 return sprintf("%04d-%02d-%02d", $year, $mon, $day);
513             }
514              
515             sub _compute_begin {
516             # Combine start_date and start_time into a SLURM --begin string.
517             # Returns undef when neither is set.
518             # Always normalises through the parsers so lvalue assignment of raw strings works.
519 418     418   461 my $self = shift;
520 418 100 66     1174 return undef unless defined $self->{start_time} || defined $self->{start_date};
521              
522             my $time_str = defined $self->{start_time}
523             ? _parse_start_time($self->{start_time})
524 1 50       8 : "00:00:00";
525 1         6 my ($h, $m, $s) = split /:/, $time_str;
526              
527 1         9 my $date_str;
528 1 50       4 if (defined $self->{start_date}) {
529 1         5 $date_str = _parse_start_date($self->{start_date});
530             } else {
531             # Only time given: use today if that time is still in the future, else tomorrow.
532 0         0 my @now = localtime(time);
533 0         0 my $today_begin = mktime($s, $m, $h, $now[3], $now[4], $now[5]);
534 0 0       0 if ($today_begin > time()) {
535 0         0 $date_str = strftime("%Y-%m-%d", @now);
536             } else {
537             # mktime normalises day overflow correctly (e.g. 31 -> 1st of next month)
538 0         0 my @tomorrow = localtime(mktime(0, 0, 0, $now[3] + 1, $now[4], $now[5]));
539 0         0 $date_str = strftime("%Y-%m-%d", @tomorrow);
540             }
541             }
542              
543 1         5 return "${date_str}T${time_str}";
544             }
545              
546             1;
547              
548             __END__