File Coverage

blib/lib/NBI/Opts.pm
Criterion Covered Total %
statement 163 232 70.2
branch 85 168 50.6
condition 1 19 5.2
subroutine 27 32 84.3
pod 19 19 100.0
total 295 470 62.7


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 17     17   379919 use 5.012;
  17         67  
35 17     17   102 use warnings;
  17         28  
  17         1208  
36 17     17   121 use Carp qw(confess);
  17         31  
  17         1297  
37 17     17   895 use Data::Dumper;
  17         10853  
  17         1221  
38             $Data::Dumper::Sortkeys = 1;
39 17     17   110 use File::Basename;
  17         59  
  17         1345  
40 17     17   10095 use POSIX qw(mktime strftime);
  17         150290  
  17         113  
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 17     17   42187 use Term::ANSIColor;
  17         132202  
  17         77073  
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 108     108 1 4323 my $class = shift @_;
56 108         368 my ($queue, $memory, $threads, $opts_array, $tmpdir, $hours, $email_address, $email_when, $files, $placeholder, $start_time, $start_date) = (undef) x 12;
57            
58             # Descriptive instantiation with parameters -param => value
59 108 100       390 if (substr($_[0], 0, 1) eq '-') {
60            
61 105         1742 my %data = @_;
62              
63             # Try parsing
64 105         392 for my $i (keys %data) {
65            
66             # QUEUE
67 818 100       3666 if ($i =~ /^-queue/) {
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    50          
    50          
68 104 50       251 next unless (defined $data{$i});
69 104         253 $queue = $data{$i};
70            
71              
72             # THREADS
73             } elsif ($i =~ /^-threads/) {
74 102 50       195 next unless (defined $data{$i});
75             # Check it's an integer
76 102 50       489 if ($data{$i} =~ /^\d+$/) {
77 102         231 $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 102 50       273 next unless (defined $data{$i});
86 102         219 $memory = _mem_parse_mb($data{$i});
87            
88              
89             # TMPDIR
90             } elsif ($i =~ /^-tmpdir/) {
91 102 50       253 next unless (defined $data{$i});
92 102         245 $tmpdir = $data{$i};
93            
94             # MAIL ADDRESS
95             } elsif ($i =~ /^-(mail|email_address)/) {
96 101 50       260 next unless (defined $data{$i});
97 101         199 $email_address = $data{$i};
98            
99             # WHEN MAIL
100             } elsif ($i =~ /^-(when|email_type)/) {
101 101 50       201 next unless (defined $data{$i});
102 101         178 $email_when = $data{$i};
103            
104              
105             # OPTS ARRAY
106             } elsif ($i =~ /^-opts/) {
107 101 50       200 next unless (defined $data{$i});
108             # in this case we expect an array
109 101 50       867 if (ref($data{$i}) ne "ARRAY") {
110 0         0 confess "ERROR NBI::Seq: -opts expects an array\n";
111             }
112 101         197 $opts_array = $data{$i};
113            
114              
115             # TIME
116             } elsif ($i =~ /^-time/) {
117 102         253 $hours = _time_to_hour($data{$i});
118            
119             # PLACEHOLDER
120             } elsif ($i =~ /^-placeholder/) {
121             # check if placeholder contains special regex characters
122 1 50       4 if (not defined $data{$i}) {
123 0         0 confess "ERROR NBI::Seq: Placeholder cannot be empty\n";
124             }
125 1 50       5 if ($data{$i} =~ /[\*\+\?]/) {
126 0         0 confess "ERROR NBI::Seq: Placeholder cannot contain special regex characters\n";
127             }
128 1         3 $placeholder = $data{$i};
129            
130             # ARRAY
131             } elsif ($i =~ /^-files/) {
132             # expects ref to array
133 1 50       7 if (ref($data{$i}) ne "ARRAY") {
134 0         0 confess "ERROR NBI::Seq: -files expects an array\n";
135             } else {
136 1         3 $files = $data{$i};
137             }
138            
139             # START TIME
140             } elsif ($i =~ /^-start_time/) {
141 0 0       0 next unless defined $data{$i};
142 0         0 $start_time = _parse_start_time($data{$i});
143              
144             # START DATE
145             } elsif ($i =~ /^-start_date/) {
146 0 0       0 next unless defined $data{$i};
147 0         0 $start_date = _parse_start_date($data{$i});
148              
149             } else {
150 1         292 confess "ERROR NBI::Seq: Unknown parameter $i\n";
151             }
152             }
153             }
154            
155 107         394 my $self = bless {}, $class;
156            
157             # Set attributes
158 107 100       358 $self->queue = defined $queue ? $queue : "nbi-short";
159 107 100       309 $self->threads = defined $threads ? $threads : 1;
160 107 100       888 $self->memory = defined $memory ? $memory : 100;
161 107 100       283 $self->hours = defined $hours ? $hours : 1;
162 107 100       279 $self->tmpdir = defined $tmpdir ? $tmpdir : $SYSTEM_TEMPDIR;
163 107 100       245 $self->email_address = defined $email_address ? $email_address : undef;
164 107 100       263 $self->email_type = defined $email_when ? $email_when : "none";
165 107 100       324 $self->files = defined $files ? $files : [];
166 107 100       281 $self->placeholder = defined $placeholder ? $placeholder : "#FILE#";
167             # Set options
168 107 100       315 $self->opts = defined $$opts_array[0] ? $opts_array : [];
169             # Begin time (optional)
170 107         299 $self->{start_time} = $start_time;
171 107         207 $self->{start_date} = $start_date;
172            
173            
174            
175            
176              
177 107         381 return $self;
178            
179             }
180              
181              
182             sub queue : lvalue {
183             # Update queue
184 109     109 1 249 my ($self, $new_val) = @_;
185 109 50       207 $self->{queue} = $new_val if (defined $new_val);
186 109         399 return $self->{queue};
187             }
188              
189             sub threads : lvalue {
190             # Update threads
191 107     107 1 212 my ($self, $new_val) = @_;
192 107 50       222 $self->{threads} = $new_val if (defined $new_val);
193 107         273 return $self->{threads};
194             }
195              
196             sub memory : lvalue {
197             # Update memory
198 107     107 1 196 my ($self, $new_val) = @_;
199 107 50       197 $self->{memory} = _mem_parse_mb($new_val) if (defined $new_val);
200 107         220 return $self->{memory};
201             }
202              
203             sub email_address : lvalue {
204             # Update memory
205 107     107 1 158 my ($self, $new_val) = @_;
206 107 50       236 $self->{email_address} = $new_val if (defined $new_val);
207 107         237 return $self->{email_address};
208             }
209              
210             sub email_type : lvalue {
211             # Update memory
212 107     107 1 225 my ($self, $new_val) = @_;
213 107 50       211 $self->{email_type} = $new_val if (defined $new_val);
214 107         211 return $self->{email_type};
215             }
216              
217             # property files (list of files)
218             sub files : lvalue {
219             # Update files
220 108     108 1 165 my ($self, $new_val) = @_;
221 108 50       243 $self->{files} = $new_val if (defined $new_val);
222 108         370 return $self->{files};
223             }
224              
225             sub placeholder : lvalue {
226             # Update placeholder
227 312     312 1 520 my ($self, $new_val) = @_;
228 312 50       656 $self->{placeholder} = $new_val if (defined $new_val);
229 312         670 return $self->{placeholder};
230             }
231             sub start_time : lvalue {
232 0     0 1 0 my ($self, $new_val) = @_;
233 0 0       0 $self->{start_time} = _parse_start_time($new_val) if defined $new_val;
234 0         0 return $self->{start_time};
235             }
236              
237             sub start_date : lvalue {
238 0     0 1 0 my ($self, $new_val) = @_;
239 0 0       0 $self->{start_date} = _parse_start_date($new_val) if defined $new_val;
240 0         0 return $self->{start_date};
241             }
242              
243             sub is_array {
244             # Check if the job is an array
245 715     715 1 1078 my $self = shift @_;
246 715         982 return scalar @{$self->{files}} > 0;
  715         2071  
247             }
248             sub hours : lvalue {
249             # Update memory
250 107     107 1 167 my ($self, $new_val) = @_;
251 107 50       209 $self->{hours} = _time_to_hour($new_val) if (defined $new_val);
252 107         211 return $self->{hours};
253             }
254              
255             sub tmpdir : lvalue {
256             # Update tmpdir
257 311     311 1 511 my ($self, $new_val) = @_;
258 311 50       572 $self->{tmpdir} = $new_val if (defined $new_val);
259 311         737 return $self->{tmpdir};
260             }
261              
262             sub opts : lvalue {
263             # Update opts
264 107     107 1 175 my ($self, $new_val) = @_;
265 107 50       266 if (not defined $self->{opts}) {
266 107         230 $self->{opts} = [];
267 107         316 return $self->{opts};
268             }
269             # check newval is an array
270 0 0       0 confess "ERROR NBI::Opts: opts must be an array, got $new_val\n" if (ref($new_val) ne "ARRAY");
271 0 0       0 $self->{opts} = $new_val if (defined $new_val);
272 0         0 return $self->{opts};
273             }
274             sub add_option {
275             # Add an option
276 1     1 1 761 my ($self, $new_val) = @_;
277 1         2 push @{$self->{opts}}, $new_val;
  1         4  
278 1         3 return $self->{opts};
279             }
280              
281             sub opts_count {
282             # Return the number of options
283 1     1 1 5 my $self = shift @_;
284 1 50       31 return defined $self->{opts} ? scalar @{$self->{opts}} : 0;
  1         9  
285             }
286              
287             sub view {
288             # Return a string representation of the object
289 203     203 1 575 my $self = shift @_;
290 203         303 my $str = " --- NBI::Opts object ---\n";
291 203         470 $str .= " queue:\t" . $self->{queue} . "\n";
292 203         398 $str .= " threads:\t" . $self->{threads} . "\n";
293 203         326 $str .= " memory MB:\t" . $self->{memory} . "\n";
294 203         1124 $str .= " time (h):\t" . $self->{hours} . "\n";
295 203         335 $str .= " tmpdir:\t" . $self->{tmpdir} . "\n";
296 203         441 my $begin = $self->_compute_begin();
297 203 50       410 $str .= " begin:\t" . $begin . "\n" if defined $begin;
298 203         309 $str .= " ---------------------------\n";
299 203         272 for my $o (@{$self->{opts}}) {
  203         442  
300 203 50       459 $str .= "#SBATCH $o\n" if defined $o;
301             }
302 203         530 return $str;
303             }
304              
305             sub header {
306             # Return a header for the script based on the options
307 203     203 1 295 my $self = shift @_;
308 203         289 my $str = "#!/bin/bash\n";
309             # Queue
310 203         493 $str .= "#SBATCH -p " . $self->{queue} . "\n";
311             # Nodes: 1
312 203         357 $str .= "#SBATCH -N 1\n";
313             # Time
314 203         427 $str .= "#SBATCH -t " . $self->timestring() . "\n";
315             # Memory
316 203         511 $str .= "#SBATCH --mem=" . $self->{memory} . "\n";
317             # Threads
318 203         337 $str .= "#SBATCH -c " . $self->{threads} . "\n";
319             # Mail
320 203 100       438 if (defined $self->{email_address}) {
321 202         365 $str .= "#SBATCH --mail-user=" . $self->{email_address} . "\n";
322 202         348 $str .= "#SBATCH --mail-type=" . $self->{email_type} . "\n";
323             }
324              
325             # Custom options
326 203         263 for my $o (@{$self->{opts}}) {
  203         384  
327 202 50       403 next if not defined $o;
328 202         410 $str .= "#SBATCH $o\n";
329             }
330              
331             # Job array
332 203 100       386 if ($self->is_array()) {
333 1         2 my $len = scalar @{$self->{files}} - 1;
  1         3  
334 1         4 $str .= "#SBATCH --array=0-$len\n";
335             }
336             # Begin time
337 203         439 my $begin = $self->_compute_begin();
338 203 50       378 $str .= "#SBATCH --begin=$begin\n" if defined $begin;
339 203         479 return $str;
340             }
341              
342             sub timestring {
343 203     203 1 267 my $self = shift @_;
344 203         383 my $hours = $self->{hours};
345 203         475 my $days = 0+ int($hours / 24);
346 203         339 $hours = $hours % 24;
347             # Format hours to be 2 digits
348 203         635 $hours = sprintf("%02d", $hours);
349 203         561 return "${days}-${hours}:00:00";
350             }
351              
352             sub _mem_parse_mb {
353 102     102   219 my $mem = shift @_;
354 102 50       427 if ($mem=~/^(\d+)$/) {
    0          
355             # bare number: interpret as MB
356 102         470 return $mem;
357             } elsif ($mem=~/^(\d+)\.?(MB?|GB?|TB?|KB?)$/i) {
358 0 0       0 if (substr(uc($2), 0, 1) eq "G") {
    0          
    0          
    0          
359 0         0 $mem = $1 * 1024;
360             } elsif (substr(uc($2), 0, 1) eq "T") {
361 0         0 $mem = $1 * 1024 * 1024;
362             } elsif (substr(uc($2), 0, 1) eq "M") {
363 0         0 $mem = $1;
364             } elsif (substr(uc($2), 0, 1) eq "K") {
365 0         0 $mem = int($1/1024);
366             } else {
367             # Consider MB
368 0         0 $mem = $1;
369             }
370             } else {
371 0         0 confess "ERROR NBI::Opts: Cannot parse memory value $mem\n";
372             }
373 0         0 return $mem;
374             }
375              
376             sub _time_to_hour {
377             # Get an integer (hours) or a string in the format \d+D \d+H \d+M \d+S
378 102     102   196 my $time = shift @_;
379 102         233 $time = uc($time);
380            
381 102 50       384 if ($time =~ /^(\d+)$/) {
382             # Got an integer
383 0         0 return $1;
384             } else {
385 102         156 my $hours = 0;
386 102         517 while ($time =~ /(\d+)([DHMS])/g) {
387 404         736 my $val = $1;
388 404         736 my $unit = $2;
389 404 100       937 if ($unit eq "D") {
    100          
    100          
    50          
390 101         437 $hours += $val * 24;
391             } elsif ($unit eq "H") {
392 101         319 $hours += $val;
393             } elsif ($unit eq "M") {
394 101         344 $hours += $val / 60;
395             } elsif ($unit eq "S") {
396 101         254 $hours += $val / 3600;
397             } else {
398 0         0 die "ERROR NBI::Opts: Cannot parse time value $time\n";
399             }
400             }
401 102         385 return $hours;
402             }
403             }
404              
405              
406             sub _parse_start_time {
407             # Accept H:MM, HH:MM, H:MM:SS, HH:MM:SS (24h only). Returns "HH:MM:SS".
408 0     0   0 my $time = shift;
409 0 0       0 unless ($time =~ /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/) {
410 0         0 confess "ERROR NBI::Opts: Cannot parse start time '$time'. Use HH:MM or HH:MM:SS (24h format)\n";
411             }
412 0   0     0 my ($h, $m, $s) = ($1, $2, $3 // 0);
413 0 0       0 confess "ERROR NBI::Opts: Invalid hour $h in '$time' (must be 0-23)\n" if $h > 23;
414 0 0       0 confess "ERROR NBI::Opts: Invalid minute $m in '$time' (must be 0-59)\n" if $m > 59;
415 0 0       0 confess "ERROR NBI::Opts: Invalid second $s in '$time' (must be 0-59)\n" if $s > 59;
416 0         0 return sprintf("%02d:%02d:%02d", $h, $m, $s);
417             }
418              
419             sub _parse_start_date {
420             # Accept DD/MM or DD/MM/YYYY. Returns "YYYY-MM-DD".
421             # When year is omitted, infers current year; if that date is already past, uses next year.
422             # Also accepts the already-normalised YYYY-MM-DD form (idempotent).
423 0     0   0 my $date = shift;
424              
425             # Already normalised by a previous call — pass through unchanged.
426 0 0       0 return $date if $date =~ /^\d{4}-\d{2}-\d{2}$/;
427              
428 0 0       0 unless ($date =~ m{^(\d{1,2})/(\d{1,2})(?:/(\d{4}))?$}) {
429 0         0 confess "ERROR NBI::Opts: Cannot parse start date '$date'. Use DD/MM or DD/MM/YYYY\n";
430             }
431 0         0 my ($day, $mon, $year) = ($1, $2, $3);
432 0 0 0     0 confess "ERROR NBI::Opts: Invalid month $mon in '$date' (must be 1-12)\n" if $mon < 1 || $mon > 12;
433 0 0 0     0 confess "ERROR NBI::Opts: Invalid day $day in '$date' (must be 1-31)\n" if $day < 1 || $day > 31;
434              
435 0 0       0 if (!defined $year) {
436 0         0 my @now = localtime(time);
437 0         0 my $curr_year = $now[5] + 1900;
438             # midnight today for comparison
439 0         0 my $today_midnight = mktime(0, 0, 0, $now[3], $now[4], $now[5]);
440 0         0 my $candidate = mktime(0, 0, 0, $day, $mon - 1, $curr_year - 1900);
441 0 0       0 $year = ($candidate >= $today_midnight) ? $curr_year : $curr_year + 1;
442             }
443              
444             # Validate via mktime round-trip (catches e.g. 31/02)
445 0         0 my $epoch = mktime(0, 0, 12, $day, $mon - 1, $year - 1900);
446 0         0 my @check = localtime($epoch);
447 0 0 0     0 if ($check[3] != $day || $check[4] + 1 != $mon || $check[5] + 1900 != $year) {
      0        
448 0         0 confess "ERROR NBI::Opts: Invalid date '$date' (day out of range for that month)\n";
449             }
450              
451 0         0 return sprintf("%04d-%02d-%02d", $year, $mon, $day);
452             }
453              
454             sub _compute_begin {
455             # Combine start_date and start_time into a SLURM --begin string.
456             # Returns undef when neither is set.
457             # Always normalises through the parsers so lvalue assignment of raw strings works.
458 406     406   572 my $self = shift;
459 406 50 33     1814 return undef unless defined $self->{start_time} || defined $self->{start_date};
460              
461             my $time_str = defined $self->{start_time}
462             ? _parse_start_time($self->{start_time})
463 0 0         : "00:00:00";
464 0           my ($h, $m, $s) = split /:/, $time_str;
465              
466 0           my $date_str;
467 0 0         if (defined $self->{start_date}) {
468 0           $date_str = _parse_start_date($self->{start_date});
469             } else {
470             # Only time given: use today if that time is still in the future, else tomorrow.
471 0           my @now = localtime(time);
472 0           my $today_begin = mktime($s, $m, $h, $now[3], $now[4], $now[5]);
473 0 0         if ($today_begin > time()) {
474 0           $date_str = strftime("%Y-%m-%d", @now);
475             } else {
476             # mktime normalises day overflow correctly (e.g. 31 -> 1st of next month)
477 0           my @tomorrow = localtime(mktime(0, 0, 0, $now[3] + 1, $now[4], $now[5]));
478 0           $date_str = strftime("%Y-%m-%d", @tomorrow);
479             }
480             }
481              
482 0           return "${date_str}T${time_str}";
483             }
484              
485             1;
486              
487             __END__