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   365910 use 5.012;
  28         113  
35 28     28   200 use warnings;
  28         53  
  28         2027  
36 28     28   173 use Carp qw(confess);
  28         54  
  28         1873  
37 28     28   914 use Data::Dumper;
  28         10659  
  28         1850  
38             $Data::Dumper::Sortkeys = 1;
39 28     28   172 use File::Basename;
  28         84  
  28         2329  
40 28     28   15527 use POSIX qw(mktime strftime);
  28         219064  
  28         230  
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   61287 use Term::ANSIColor;
  28         177216  
  28         136615  
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 18046 my $class = shift @_;
56 124         534 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       494 if (substr($_[0], 0, 1) eq '-') {
60            
61 121         861 my %data = @_;
62              
63             # Try parsing
64 121         510 for my $i (keys %data) {
65            
66             # QUEUE
67 941 100       5187 if ($i =~ /^-queue/) {
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    50          
    50          
68 120 50       287 next unless (defined $data{$i});
69 120         298 $queue = $data{$i};
70            
71              
72             # THREADS
73             } elsif ($i =~ /^-threads/) {
74 118 50       350 next unless (defined $data{$i});
75             # Check it's an integer
76 118 50       545 if ($data{$i} =~ /^\d+$/) {
77 118         350 $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       295 next unless (defined $data{$i});
86 118         324 $memory = _mem_parse_mb($data{$i});
87            
88              
89             # TMPDIR
90             } elsif ($i =~ /^-tmpdir/) {
91 118 50       289 next unless (defined $data{$i});
92 118         256 $tmpdir = $data{$i};
93            
94             # MAIL ADDRESS
95             } elsif ($i =~ /^-(mail|email_address)/) {
96 105 100       335 next unless (defined $data{$i});
97 101         234 $email_address = $data{$i};
98            
99             # WHEN MAIL
100             } elsif ($i =~ /^-(when|email_type)/) {
101 105 50       302 next unless (defined $data{$i});
102 105         251 $email_when = $data{$i};
103            
104              
105             # OPTS ARRAY
106             } elsif ($i =~ /^-opts/) {
107 105 50       309 next unless (defined $data{$i});
108             # in this case we expect an array
109 105 50       393 if (ref($data{$i}) ne "ARRAY") {
110 0         0 confess "ERROR NBI::Seq: -opts expects an array\n";
111             }
112 105         269 $opts_array = $data{$i};
113            
114              
115             # TIME
116             } elsif ($i =~ /^-time/) {
117 118         406 $hours = _time_to_hour($data{$i});
118            
119             # PLACEHOLDER
120             } elsif ($i =~ /^-placeholder/) {
121             # check if placeholder contains special regex characters
122 6 50       24 if (not defined $data{$i}) {
123 0         0 confess "ERROR NBI::Seq: Placeholder cannot be empty\n";
124             }
125 6 50       23 if ($data{$i} =~ /[\*\+\?]/) {
126 0         0 confess "ERROR NBI::Seq: Placeholder cannot contain special regex characters\n";
127             }
128 6         20 $placeholder = $data{$i};
129            
130             # ARRAY
131             } elsif ($i =~ /^-files/) {
132             # expects ref to array
133 5 50       21 if (ref($data{$i}) ne "ARRAY") {
134 0         0 confess "ERROR NBI::Seq: -files expects an array\n";
135             } else {
136 5         9 $files = $data{$i};
137             }
138              
139             # PARAMS ARRAY
140             } elsif ($i =~ /^-params_array/) {
141 6 50       37 next unless defined $data{$i};
142 6         24 $params_array = $data{$i};
143              
144             # PARAMS ARRAY ROW COUNT
145             } elsif ($i =~ /^-params_rows/) {
146 6 50       31 next unless defined $data{$i};
147 6 50       33 if ($data{$i} =~ /^\d+$/) {
148 6         19 $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       17 next unless defined $data{$i};
156 5 50       210 if ($data{$i} =~ /^\d+$/) {
157 5         17 $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       18 next unless defined $data{$i};
165 5 50       28 if ($data{$i} =~ /^\d+$/) {
166 5         15 $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         280 confess "ERROR NBI::Seq: Unknown parameter $i\n";
183             }
184             }
185             }
186            
187 123         472 my $self = bless {}, $class;
188            
189             # Set attributes
190 123 100       1229 $self->queue = defined $queue ? $queue : "nbi-short";
191 123 100       387 $self->threads = defined $threads ? $threads : 1;
192 123 100       346 $self->memory = defined $memory ? $memory : 100;
193 123 100       389 $self->hours = defined $hours ? $hours : 1;
194 123 100       1155 $self->tmpdir = defined $tmpdir ? $tmpdir : $SYSTEM_TEMPDIR;
195 123 100       384 $self->email_address = defined $email_address ? $email_address : undef;
196 123 100       368 $self->email_type = defined $email_when ? $email_when : "none";
197 123 100       559 $self->files = defined $files ? $files : [];
198 123 100       361 $self->placeholder = defined $placeholder ? $placeholder : "#FILE#";
199 123 100       425 $self->params_array = defined $params_array ? $params_array : undef;
200 123 100       397 $self->params_rows = defined $params_rows ? $params_rows : 0;
201 123 100       306 $self->{array_offset} = defined $array_offset ? $array_offset : 0;
202 123 100       284 $self->{array_tasks} = defined $array_tasks ? $array_tasks : 0;
203             # Set options
204 123 100       430 $self->opts = defined $$opts_array[0] ? $opts_array : [];
205             # Begin time (optional)
206 123         248 $self->{start_time} = $start_time;
207 123         222 $self->{start_date} = $start_date;
208            
209            
210            
211            
212              
213 123         493 return $self;
214            
215             }
216              
217              
218             sub queue : lvalue {
219             # Update queue
220 132     132 1 326 my ($self, $new_val) = @_;
221 132 50       392 $self->{queue} = $new_val if (defined $new_val);
222 132         725 return $self->{queue};
223             }
224              
225             sub threads : lvalue {
226             # Update threads
227 123     123 1 223 my ($self, $new_val) = @_;
228 123 50       278 $self->{threads} = $new_val if (defined $new_val);
229 123         347 return $self->{threads};
230             }
231              
232             sub memory : lvalue {
233             # Update memory
234 123     123 1 230 my ($self, $new_val) = @_;
235 123 50       287 $self->{memory} = _mem_parse_mb($new_val) if (defined $new_val);
236 123         296 return $self->{memory};
237             }
238              
239             sub email_address : lvalue {
240             # Update memory
241 123     123 1 237 my ($self, $new_val) = @_;
242 123 50       266 $self->{email_address} = $new_val if (defined $new_val);
243 123         451 return $self->{email_address};
244             }
245              
246             sub email_type : lvalue {
247             # Update memory
248 123     123 1 217 my ($self, $new_val) = @_;
249 123 50       281 $self->{email_type} = $new_val if (defined $new_val);
250 123         311 return $self->{email_type};
251             }
252              
253             # property files (list of files)
254             sub files : lvalue {
255             # Update files
256 124     124 1 221 my ($self, $new_val) = @_;
257 124 50       304 $self->{files} = $new_val if (defined $new_val);
258 124         307 return $self->{files};
259             }
260              
261             sub placeholder : lvalue {
262             # Update placeholder
263 124     124 1 237 my ($self, $new_val) = @_;
264 124 50       346 $self->{placeholder} = $new_val if (defined $new_val);
265 124         327 return $self->{placeholder};
266             }
267             sub params_array : lvalue {
268 130     130 1 243 my ($self, $new_val) = @_;
269 130 50       316 $self->{params_array} = $new_val if (defined $new_val);
270 130         313 return $self->{params_array};
271             }
272             sub params_rows : lvalue {
273 123     123 1 266 my ($self, $new_val) = @_;
274 123 50       285 $self->{params_rows} = $new_val if (defined $new_val);
275 123         278 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 1543 my $self = shift @_;
292 773   100     1546 return $self->is_files_array() || $self->is_params_array();
293             }
294             sub is_files_array {
295 863     863 1 1375 my $self = shift @_;
296 863         1276 return scalar @{$self->{files}} > 0;
  863         3470  
297             }
298             sub is_params_array {
299 855     855 1 1366 my $self = shift @_;
300 855   66     4520 return defined $self->{params_array} && $self->{params_rows} > 0;
301             }
302             sub array_size {
303 3     3 1 4 my $self = shift @_;
304 3 100       7 return scalar @{$self->{files}} if $self->is_files_array();
  1         2  
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 245 my ($self, $new_val) = @_;
311 124 50       428 $self->{hours} = _time_to_hour($new_val) if (defined $new_val);
312 124         356 return $self->{hours};
313             }
314              
315             sub tmpdir : lvalue {
316             # Update tmpdir
317 362     362 1 747 my ($self, $new_val) = @_;
318 362 50       763 $self->{tmpdir} = $new_val if (defined $new_val);
319 362         1010 return $self->{tmpdir};
320             }
321              
322             sub opts : lvalue {
323             # Update opts
324 123     123 1 279 my ($self, $new_val) = @_;
325 123 50       339 if (not defined $self->{opts}) {
326 123         425 $self->{opts} = [];
327 123         390 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 803 my ($self, $new_val) = @_;
337 2         12 push @{$self->{opts}}, $new_val;
  2         12  
338 2         10 return $self->{opts};
339             }
340              
341             sub opts_count {
342             # Return the number of options
343 1     1 1 5 my $self = shift @_;
344 1 50       5 return defined $self->{opts} ? scalar @{$self->{opts}} : 0;
  1         6  
345             }
346              
347             sub view {
348             # Return a string representation of the object
349 203     203 1 758 my $self = shift @_;
350 203         348 my $str = " --- NBI::Opts object ---\n";
351 203         636 $str .= " queue:\t" . $self->{queue} . "\n";
352 203         509 $str .= " threads:\t" . $self->{threads} . "\n";
353 203         418 $str .= " memory MB:\t" . $self->{memory} . "\n";
354 203         1375 $str .= " time (h):\t" . $self->{hours} . "\n";
355 203         446 $str .= " tmpdir:\t" . $self->{tmpdir} . "\n";
356 203         474 my $begin = $self->_compute_begin();
357 203 50       469 $str .= " begin:\t" . $begin . "\n" if defined $begin;
358 203         476 $str .= " ---------------------------\n";
359 203         334 for my $o (@{$self->{opts}}) {
  203         498  
360 203 50       660 $str .= "#SBATCH $o\n" if defined $o;
361             }
362 203         725 return $str;
363             }
364              
365             sub header {
366             # Return a header for the script based on the options
367 215     215 1 394 my $self = shift @_;
368 215         368 my $str = "#!/bin/bash\n";
369             # Queue
370 215         625 $str .= "#SBATCH -p " . $self->{queue} . "\n";
371             # Nodes: 1
372 215         392 $str .= "#SBATCH -N 1\n";
373             # Time
374 215         496 $str .= "#SBATCH -t " . $self->timestring() . "\n";
375             # Memory
376 215         603 $str .= "#SBATCH --mem=" . $self->{memory} . "\n";
377             # Threads
378 215         471 $str .= "#SBATCH -c " . $self->{threads} . "\n";
379             # Mail
380 215 100       562 if (defined $self->{email_address}) {
381 202         404 $str .= "#SBATCH --mail-user=" . $self->{email_address} . "\n";
382 202         466 $str .= "#SBATCH --mail-type=" . $self->{email_type} . "\n";
383             }
384              
385             # Custom options
386 215         320 for my $o (@{$self->{opts}}) {
  215         562  
387 203 50       439 next if not defined $o;
388 203         404 $str .= "#SBATCH $o\n";
389             }
390              
391             # Job array
392 215 100       687 if ($self->is_array()) {
393 8 100       51 my $tasks = $self->{array_tasks} > 0 ? $self->{array_tasks} : $self->array_size();
394 8         19 my $len = $tasks - 1;
395 8         30 $str .= "#SBATCH --array=0-$len\n";
396             }
397             # Begin time
398 215         617 my $begin = $self->_compute_begin();
399 215 100       472 $str .= "#SBATCH --begin=$begin\n" if defined $begin;
400 215         630 return $str;
401             }
402              
403             sub timestring {
404 215     215 1 378 my $self = shift @_;
405 215         449 my $hours = $self->{hours};
406 215         601 my $days = 0+ int($hours / 24);
407 215         438 $hours = $hours % 24;
408             # Format hours to be 2 digits
409 215         790 $hours = sprintf("%02d", $hours);
410 215         1307 return "${days}-${hours}:00:00";
411             }
412              
413             sub _mem_parse_mb {
414 118     118   235 my $mem = shift @_;
415 118 100       566 if ($mem=~/^(\d+)$/) {
    50          
416             # bare number: interpret as MB
417 108         448 return $mem;
418             } elsif ($mem=~/^(\d+)\.?(MB?|GB?|TB?|KB?)$/i) {
419 10 50       41 if (substr(uc($2), 0, 1) eq "G") {
    0          
    0          
    0          
420 10         23 $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         24 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   241 my $time = shift @_;
440 125         328 $time = uc($time);
441            
442 125 100       513 if ($time =~ /^(\d+)$/) {
443             # Got an integer
444 1         6 return $1;
445             } else {
446 124         210 my $hours = 0;
447 124         607 while ($time =~ /(\d+)([DHMS])/g) {
448 426         963 my $val = $1;
449 426         831 my $unit = $2;
450 426 100       1098 if ($unit eq "D") {
    100          
    100          
    50          
451 102         498 $hours += $val * 24;
452             } elsif ($unit eq "H") {
453 121         421 $hours += $val;
454             } elsif ($unit eq "M") {
455 102         472 $hours += $val / 60;
456             } elsif ($unit eq "S") {
457 101         330 $hours += $val / 3600;
458             } else {
459 0         0 die "ERROR NBI::Opts: Cannot parse time value $time\n";
460             }
461             }
462 124         622 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       88 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     11 my ($h, $m, $s) = ($1, $2, $3 // 0);
474 1 50       6 confess "ERROR NBI::Opts: Invalid hour $h in '$time' (must be 0-23)\n" if $h > 23;
475 1 50       3 confess "ERROR NBI::Opts: Invalid minute $m in '$time' (must be 0-59)\n" if $m > 59;
476 1 50       4 confess "ERROR NBI::Opts: Invalid second $s in '$time' (must be 0-59)\n" if $s > 59;
477 1         8 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       10 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   685 my $self = shift;
520 418 100 66     1969 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       21 : "00:00:00";
525 1         5 my ($h, $m, $s) = split /:/, $time_str;
526              
527 1         4 my $date_str;
528 1 50       15 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__