File Coverage

blib/lib/Progress/Any.pm
Criterion Covered Total %
statement 97 288 33.6
branch 47 176 26.7
condition 8 53 15.0
subroutine 15 26 57.6
pod 13 13 100.0
total 180 556 32.3


line stmt bran cond sub pod time code
1             package Progress::Any;
2              
3 1     1   616 use 5.010001;
  1         9  
4 1     1   6 use strict;
  1         1  
  1         41  
5 1     1   12 use warnings;
  1         3  
  1         27  
6              
7 1     1   484 use Time::Duration qw();
  1         2059  
  1         27  
8 1     1   525 use Time::HiRes qw(time);
  1         1383  
  1         4  
9              
10             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
11             our $DATE = '2022-10-18'; # DATE
12             our $DIST = 'Progress-Any'; # DIST
13             our $VERSION = '0.220'; # VERSION
14              
15             sub import {
16 1     1   9 my ($self, @args) = @_;
17 1         2 my $caller = caller();
18 1         33 for (@args) {
19 0 0       0 if ($_ eq '$progress') {
20 0         0 my $progress = $self->get_indicator(task => '');
21             {
22 1     1   269 no strict 'refs'; ## no critic: TestingAndDebugging::ProhibitNoStrict
  1         2  
  1         735  
  0         0  
23 0         0 my $v = "$caller\::progress";
24 0         0 *$v = \$progress;
25             }
26             } else {
27 0         0 die "Unknown import argument: $_";
28             }
29             }
30             }
31              
32             # store Progress::Any objects for each task
33             our %indicators; # key = task name
34              
35             # store output objects
36             our %outputs; # key = task name, value = [$outputobj, ...]
37              
38             # internal attributes:
39             # - _elapsed (float*) = accumulated elapsed time so far
40             # - _start_time (float) = when is the last time the indicator state is changed
41             # from 'stopped' to 'started'. when indicator is changed from 'started' to
42             # 'stopped', this will be set to undef.
43             # - _remaining = used to store user's estimation of remaining time. will be
44             # unset after each update().
45              
46             # return 1 if created, 0 if already created/initialized
47             sub _init_indicator {
48 15     15   33 my ($class, $task, $default_target) = @_;
49              
50             #say "D: _init_indicator($task)";
51              
52             # prevent double initialization
53 15 100       43 return $indicators{$task} if $indicators{$task};
54              
55 7         44 my $progress = bless({
56             task => $task,
57             title => $task,
58             target => $default_target,
59             pos => 0,
60             state => 'stopped',
61              
62             _remaining => undef,
63             _set_remaining_time => undef,
64             _elapsed => 0,
65             _start_time => 0,
66             }, $class);
67 7         17 $indicators{$task} = $progress;
68              
69             # if we create an indicator named a.b.c, we must also create a.b, a, and ''.
70 7 100       36 if ($task =~ s/\.?\w+\z//) {
71 6         24 $class->_init_indicator($task, 0);
72             }
73              
74 7         14 $progress;
75             }
76              
77             sub get_indicator {
78 10     10 1 1483 my ($class, %args) = @_;
79              
80 10         31 my %oargs = %args;
81              
82 10         22 my $task = delete($args{task});
83 10 50       29 if (!defined($task)) {
84 0         0 my @caller = caller(0);
85             #say "D:caller=".join(",",map{$_//""} @caller);
86 0 0       0 $task = $caller[0] eq '(eval)' ? 'main' : $caller[0];
87 0         0 $task =~ s/::/./g;
88 0         0 $task =~ s/[^.\w]+/_/g;
89             }
90 10 100       79 die "Invalid task syntax '$task', please only use dotted words"
91             unless $task =~ /\A(?:\w+(\.\w+)*)?\z/;
92              
93 9         12 my %uargs;
94              
95 9         21 my $p = $class->_init_indicator($task);
96 9         17 for my $an (qw/title target pos remaining state/) {
97 45 100       86 if (exists $args{$an}) {
98 8         18 $uargs{$an} = delete($args{$an});
99             }
100             }
101 9 100       33 die "Unknown argument(s) to get_indicator(): ".join(", ", keys(%args))
102             if keys(%args);
103 8 100       35 $p->_update(%uargs) if keys %uargs;
104              
105 6         26 $p;
106             }
107              
108             my %attrs = (
109             title => {is => 'rw'},
110             target => {is => 'rw'},
111             pos => {is => 'rw'},
112             state => {is => 'rw'},
113             );
114              
115             # create attribute methods
116             for my $an (keys %attrs) {
117             next if $attrs{$an}{manual};
118             my $code;
119             if ($attrs{$an}{is} eq 'rw') {
120             $code = sub {
121 14     14   47 my $self = shift;
122 14 100       37 if (@_) {
123 2         6 $self->_update($an => shift);
124             }
125 14         63 $self->{$an};
126             };
127             } else {
128             $code = sub {
129             my $self = shift;
130             die "Can't set value, $an is an ro attribute" if @_;
131             $self->{$an};
132             };
133             }
134 1     1   7 no strict 'refs'; ## no critic: TestingAndDebugging::ProhibitNoStrict
  1         2  
  1         2736  
135             *{$an} = $code;
136             }
137              
138             sub elapsed {
139 0     0 1 0 my $self = shift;
140              
141 0 0       0 if ($self->{state} eq 'started') {
142 0         0 return $self->{_elapsed} + (time()-$self->{_start_time});
143             } else {
144 0         0 return $self->{_elapsed};
145             }
146             }
147              
148             sub total_pos {
149 24     24 1 39 my $self = shift;
150              
151 24         41 my $t = $self->{task};
152              
153 24         34 my $res = $self->{pos};
154 24         78 for (keys %indicators) {
155 104 100       180 if ($t eq '') {
156 30 100       63 next if $_ eq '';
157             } else {
158 74 100       177 next unless index($_, "$t.") == 0;
159             }
160 43         69 $res += $indicators{$_}{pos};
161             }
162 24         92 $res;
163             }
164              
165             sub total_target {
166 27     27 1 50 my $self = shift;
167              
168 27         47 my $t = $self->{task};
169              
170 27         42 my $res = $self->{target};
171 27 100       66 return unless defined($res);
172              
173 26         66 for (keys %indicators) {
174 98 100       172 if ($t eq '') {
175 30 100       58 next if $_ eq '';
176             } else {
177 68 100       151 next unless index($_, "$t.") == 0;
178             }
179 48 100       107 return unless defined $indicators{$_}{target};
180 42         58 $res += $indicators{$_}{target};
181             }
182 20         62 $res;
183             }
184              
185             sub percent_complete {
186 14     14 1 28 my $self = shift;
187              
188 14         31 my $total_pos = $self->total_pos;
189 14         27 my $total_target = $self->total_target;
190              
191 14 100       52 return unless defined($total_target);
192 10 50       22 if ($total_target == 0) {
193 0 0       0 if ($self->{state} eq 'finished') {
194 0         0 return 100;
195             } else {
196 0         0 return 0;
197             }
198             } else {
199 10         85 return $total_pos / $total_target * 100;
200             }
201             }
202              
203             sub remaining {
204 0     0 1 0 my $self = shift;
205              
206 0 0       0 if (defined $self->{_remaining}) {
207 0 0       0 if ($self->{state} eq 'started') {
208 0         0 my $r = $self->{_remaining}-(time()-$self->{_set_remaining_time});
209 0 0       0 return $r > 0 ? $r : 0;
210             } else {
211 0         0 return $self->{_remaining};
212             }
213             } else {
214 0 0       0 if (defined $self->{target}) {
215 0 0       0 if ($self->{pos} == 0) {
216 0         0 return 0;
217             } else {
218             return ($self->{target} - $self->{pos})/$self->{pos} *
219 0         0 $self->elapsed;
220             }
221             } else {
222 0         0 return;
223             }
224             }
225             }
226              
227             sub total_remaining {
228 0     0 1 0 my $self = shift;
229              
230 0         0 my $t = $self->{task};
231              
232 0         0 my $res = $self->remaining;
233 0 0       0 return unless defined $res;
234              
235 0         0 for (keys %indicators) {
236 0 0       0 if ($t eq '') {
237 0 0       0 next if $_ eq '';
238             } else {
239 0 0       0 next unless index($_, "$t.") == 0;
240             }
241 0         0 my $res2 = $indicators{$_}->remaining;
242 0 0       0 return unless defined $res2;
243 0         0 $res += $res2;
244             }
245 0         0 $res;
246             }
247              
248             # the routine to use to update rw attributes, does validation and checks to make
249             # sure things are consistent.
250             sub _update {
251 8     8   23 my ($self, %args) = @_;
252              
253             # no need to check for unknown arg in %args, it's an internal method anyway
254              
255 8         21 my $now = time();
256              
257 8         19 my $task = $self->{task};
258             #use Data::Dump; print "D: _update($task) "; dd \%args;
259              
260             SET_TITLE:
261             {
262 8 50       11 last unless exists $args{title};
  8         22  
263 0         0 my $val = $args{title};
264 0 0       0 die "Invalid value for title, must be defined"
265             unless defined($val);
266 0         0 $self->{title} = $val;
267             }
268              
269             SET_TARGET:
270             {
271 8 100       10 last unless exists $args{target};
  8         16  
272 7         11 my $val = $args{target};
273 7 100 100     39 die "Invalid value for target, must be a positive number or undef"
274             unless !defined($val) || $val >= 0;
275             # ensure that pos does not exceed target
276 6 50 66     21 if (defined($val) && $self->{pos} > $val) {
277 0         0 $self->{pos} = $val;
278             }
279 6         11 $self->{target} = $val;
280 6         9 undef $self->{_remaining};
281             }
282              
283             SET_POS:
284             {
285 7 100       11 last unless exists $args{pos};
  7         15  
286 3         7 my $val = $args{pos};
287 3 100 66     22 die "Invalid value for pos, must be a positive number"
288             unless defined($val) && $val >= 0;
289             # ensure that pos does not exceed target
290 2 50 33     27 if (defined($self->{target}) && $val > $self->{target}) {
291 0         0 $val = $self->{target};
292             }
293 2         6 $self->{pos} = $val;
294 2         4 undef $self->{_remaining};
295             }
296              
297             SET_REMAINING:
298             {
299 6 50       8 last unless exists $args{remaining};
  6         14  
300 0         0 my $val = $args{remaining};
301 0 0 0     0 die "Invalid value for remaining, must be a positive number"
302             unless defined($val) && $val >= 0;
303 0         0 $self->{_remaining} = $val;
304 0         0 $self->{_set_remaining_time} = $now;
305             }
306              
307             SET_STATE:
308             {
309 6 50       8 last unless exists $args{state};
  6         13  
310 0         0 my $old = $self->{state};
311 0   0     0 my $val = $args{state} // 'started';
312 0 0       0 die "Invalid value for state, must be stopped/started/finished"
313             unless $val =~ /\A(?:stopped|started|finished)\z/;
314 0 0       0 last if $old eq $val;
315 0 0       0 if ($val eq 'started') {
316 0         0 $self->{_start_time} = $now;
317              
318             # automatically start parents
319 0         0 my @parents;
320             {
321 0         0 my $t = $task;
  0         0  
322 0         0 while (1) {
323 0 0       0 last unless $t =~ s/\.\w+\z//;
324 0         0 push @parents, $t;
325             }
326 0         0 push @parents, '';
327             }
328 0         0 for my $t (@parents) {
329 0         0 my $p = $indicators{$t};
330 0 0       0 if ($p->{state} ne 'started') {
331 0         0 $p->{state} = 'started';
332 0         0 $p->{_start_time} = $now;
333             }
334             }
335             } else {
336 0         0 $self->{_elapsed} += $now - $self->{_start_time};
337 0 0       0 if ($val eq 'finished') {
338             die "BUG: Can't finish task '$task', pos is still < target"
339             if defined($self->{target}) &&
340 0 0 0     0 $self->{pos} < $self->{target};
341 0         0 $self->{_remaining} = 0;
342 0         0 $self->{_set_remaining_time} = $now;
343             }
344             }
345 0         0 $self->{state} = $val;
346             }
347              
348             DONE:
349             #use Data::Dump; print "after update: "; dd $self;
350 6         12 return;
351             }
352              
353             sub _should_update_output {
354 0     0     my ($self, $output, $now, $priority) = @_;
355              
356 0           my $key = "$output";
357 0 0         if (!defined($output->{_mtime})) {
    0          
    0          
    0          
358             # output has never been updated, update
359 0           return 1;
360             } elsif ($self->{state} eq 'finished') {
361             # finishing, update the output to show finished state
362 0           return 1;
363             } elsif ($output->{force_update}) {
364             # this is an undocumented force update for now, the output itself or
365             # something else can set this to force an update. but this will only be
366             # done once because we delete the key; if another update wants to be
367             # forced, they need to set this again.
368 0           delete $output->{force_update};
369 0           return 1;
370             } elsif ($priority eq 'high') {
371             # high priority, send to output module
372 0           return 1;
373             } else {
374             # normal-/low-priority update, update if not too frequent
375 0 0         if (!defined($output->{freq})) {
376             # negative number means seconds, positive means pos delta. only
377             # update if that number of seconds, or that difference in pos has
378             # been passed.
379 0           $output->{freq} = -0.5;
380             }
381 0 0         if ($output->{freq} == 0) {
382 0           return 1;
383 0 0         } if ($output->{freq} < 0) {
384 0 0         return 1 if $now >= $output->{_mtime} - $output->{freq};
385             } else {
386 0 0         return 1 if abs($self->{pos} - $output->{_pos}) >= $output->{freq};
387             }
388 0           return 0;
389             }
390             }
391              
392             sub update {
393 0     0 1   my ($self, %args) = @_;
394              
395 0   0       my $pos = delete($args{pos}) // $self->{pos} + 1;
396 0   0       my $state = delete($args{state}) // 'started';
397 0           $self->_update(pos => $pos, state => $state);
398              
399 0           my $message = delete($args{message});
400 0   0       my $priority = delete($args{priority}) // 'normal';
401 0           my $force_update = delete($args{force_update});
402              
403 0           my %other_args_for_output;
404 0           for (keys %args) {
405 0 0         if (/\Amessage\.alt\./) {
406 0           $other_args_for_output{$_} = delete $args{$_};
407             }
408             }
409              
410 0 0         die "Unknown argument(s) to update(): ".join(", ", keys(%args))
411             if keys(%args);
412              
413 0           my $now = time();
414              
415             # find output(s) and call it
416             {
417 0 0 0       last unless $ENV{PROGRESS} // 1;
  0            
418 0           my $task = $self->{task};
419 0           while (1) {
420 0 0         if ($outputs{$task}) {
421 0           for my $output (@{ $outputs{$task} }) {
  0            
422 0 0 0       next unless $force_update ||
423             $self->_should_update_output($output, $now, $priority);
424 0 0         if (ref($message) eq 'CODE') {
425 0           $message = $message->();
426             }
427             $output->update(
428 0           indicator => $indicators{$task},
429             message => $message,
430             priority => $priority,
431             time => $now,
432              
433             # temporary, internal API. to let an output module know
434             # the same update() when there are multiple instances of
435             # it
436             _update_id => $now,
437             %other_args_for_output,
438             );
439 0           $output->{_mtime} = $now;
440 0           $output->{_pos} = $pos;
441             }
442             }
443 0 0         last unless $task =~ s/\.?\w+\z//;
444             }
445             }
446             }
447              
448             sub start {
449 0     0 1   my $self = shift;
450 0           $self->_update(state => 'started');
451             }
452              
453             sub stop {
454 0     0 1   my $self = shift;
455 0           $self->_update(state => 'stopped');
456             }
457              
458             sub finish {
459 0     0 1   my ($self, %args) = @_;
460 0           $self->update(pos=>$self->{target}, state=>'finished', %args);
461             }
462              
463             sub reset {
464 0     0 1   my ($self, %args) = @_;
465 0           $self->update(pos=>0, state=>'started', %args);
466             }
467              
468             our $template_regex = qr{( # all=1
469             %
470             ( #width=2
471             -?\d+ )?
472             ( #dot=3
473             \.?)
474             ( #prec=4
475             \d+)?
476             ( #conv=5
477             [A-Za-z%])
478             )}x;
479              
480             sub fill_template {
481 0     0 1   my ($self, $template0, %args) = @_;
482              
483             # TODO: some caching so "%e%e" produces two identical numbers
484              
485 0           my ($template, $opts);
486 0 0         if (ref $template0 eq 'HASH') {
487 0           $opts = $template0;
488 0           $template = $opts->{template};
489             } else {
490 0           $template = $template0;
491 0           $opts = {};
492             }
493              
494             state $sub = sub {
495 0     0     my %args = @_;
496              
497 0           my ($all, $width, $dot, $prec, $conv) = ($1, $2, $3, $4, $5);
498              
499 0           my $p = $args{indicator};
500              
501 0           my ($fmt, $sconv, $data);
502 0 0         if ($conv eq 'n') {
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
503 0           $data = $p->{task};
504             } elsif ($conv eq 't') {
505 0           $data = $p->{title};
506             } elsif ($conv eq '%') {
507 0           $data = '%';
508             } elsif ($conv eq 'm') {
509 0   0       $data = $args{message} // '';
510             } elsif ($conv eq 'p') {
511 0           my $val = $p->percent_complete;
512 0   0       $width //= 3;
513 0 0         if (defined $val) {
514 0           $data = $val;
515 0   0       $prec //= 0;
516 0           $sconv = "f";
517             } else {
518 0           $data = '?';
519             }
520             } elsif ($conv eq 'P') {
521 0           $data = $p->total_pos;
522 0   0       $prec //= 0;
523 0           $sconv = "f";
524             } elsif ($conv eq 'T') {
525 0           my $val = $p->total_target;
526 0 0         if (defined $val) {
527 0           $data = $val;
528 0   0       $prec //= 0;
529 0           $sconv = "f";
530             } else {
531 0           $data = '?';
532             }
533             } elsif ($conv eq 'e') {
534 0           my $val = $p->elapsed;
535 0 0         $val = 1 if $val < 1; # TMP, prevent duration() return "just now"
536 0           $data = Time::Duration::concise(Time::Duration::duration($val));
537 0   0       $width //= -8;
538             } elsif ($conv eq 'r') {
539 0           my $val = $p->total_remaining;
540 0 0         if (defined $val) {
541 0 0         $val = 1 if $val < 1; # TMP, prevent duration() return "just now
542 0           $data = Time::Duration::concise(Time::Duration::duration($val));
543             } else {
544 0           $data = '?';
545             }
546 0   0       $width //= -8;
547             } elsif ($conv eq 'R') {
548 0           my $val = $p->total_remaining;
549 0 0         if (defined $val) {
550 0 0         $val = 1 if $val < 1; # TMP, prevent duration() return "just now
551 0           $data = Time::Duration::concise(Time::Duration::duration($val)).
552             " left"; # XXX i18n
553             } else {
554 0           $val = $p->elapsed;
555 0 0         $val = 1 if $val < 1; # TMP, prevent duration() return "just now
556 0           $data = Time::Duration::concise(Time::Duration::duration($val)).
557             " elapsed"; # XXX i18n
558             }
559 0   0       $width //= -(8 + 1 + 7);
560             } else {
561 0 0         if ($opts->{handle_unknown_conversion}) {
562 0           my @res = $opts->{handle_unknown_conversion}->(
563             indicator => $p,
564             args => \%args,
565              
566             all => $all,
567             width => $width,
568             dot => $dot,
569             conv => $conv,
570             prec => $prec,
571             );
572 0 0         if (@res) {
573 0           ($fmt, $data) = @res;
574             } else {
575             # return as-is
576 0           $fmt = '%s';
577 0           $data = $all;
578             }
579             } else {
580             # return as-is
581 0           $fmt = '%s';
582 0           $data = $all;
583             }
584             }
585              
586             # sprintf format
587 0   0       $sconv //= 's';
588 0 0         $dot = "." if $sconv eq 'f';
589 0   0       $fmt //= join("", grep {defined} ("%", $width, $dot, $prec, $sconv));
  0            
590              
591             #say "D:fmt=$fmt";
592 0           sprintf $fmt, $data;
593 0           };
594 0           $template =~ s{$template_regex}{$sub->(%args, indicator=>$self)}egox;
  0            
595              
596 0           $template;
597             }
598              
599             1;
600             # ABSTRACT: Record progress to any output
601              
602             __END__