File Coverage

blib/lib/Progress/Any.pm
Criterion Covered Total %
statement 97 284 34.1
branch 47 174 27.0
condition 8 53 15.0
subroutine 15 26 57.6
pod 13 13 100.0
total 180 550 32.7


line stmt bran cond sub pod time code
1             package Progress::Any;
2              
3             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
4             our $DATE = '2020-07-10'; # DATE
5             our $DIST = 'Progress-Any'; # DIST
6             our $VERSION = '0.218'; # VERSION
7              
8 1     1   696 use 5.010001;
  1         9  
9 1     1   6 use strict;
  1         2  
  1         19  
10 1     1   5 use warnings;
  1         10  
  1         33  
11              
12 1     1   517 use Time::Duration qw();
  1         1991  
  1         27  
13 1     1   517 use Time::HiRes qw(time);
  1         1357  
  1         4  
14              
15             sub import {
16 1     1   9 my ($self, @args) = @_;
17 1         3 my $caller = caller();
18 1         32 for (@args) {
19 0 0       0 if ($_ eq '$progress') {
20 0         0 my $progress = $self->get_indicator(task => '');
21             {
22 1     1   224 no strict 'refs';
  1         10  
  1         774  
  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   32 my ($class, $task, $default_target) = @_;
49              
50             #say "D: _init_indicator($task)";
51              
52             # prevent double initialization
53 15 100       37 return $indicators{$task} if $indicators{$task};
54              
55 7         43 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         14 $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         15 $class->_init_indicator($task, 0);
72             }
73              
74 7         14 $progress;
75             }
76              
77             sub get_indicator {
78 10     10 1 1358 my ($class, %args) = @_;
79              
80 10         31 my %oargs = %args;
81              
82 10         21 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       77 die "Invalid task syntax '$task', please only use dotted words"
91             unless $task =~ /\A(?:\w+(\.\w+)*)?\z/;
92              
93 9         13 my %uargs;
94              
95 9         20 my $p = $class->_init_indicator($task);
96 9         20 for my $an (qw/title target pos remaining state/) {
97 45 100       84 if (exists $args{$an}) {
98 8         20 $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       29 $p->_update(%uargs) if keys %uargs;
104              
105 6         19 $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   39 my $self = shift;
122 14 100       36 if (@_) {
123 2         7 $self->_update($an => shift);
124             }
125 14         57 $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   8 no strict 'refs';
  1         2  
  1         2615  
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 36 my $self = shift;
150              
151 24         58 my $t = $self->{task};
152              
153 24         40 my $res = $self->{pos};
154 24         72 for (keys %indicators) {
155 104 100       180 if ($t eq '') {
156 30 100       61 next if $_ eq '';
157             } else {
158 74 100       163 next unless index($_, "$t.") == 0;
159             }
160 43         70 $res += $indicators{$_}{pos};
161             }
162 24         68 $res;
163             }
164              
165             sub total_target {
166 27     27 1 43 my $self = shift;
167              
168 27         47 my $t = $self->{task};
169              
170 27         38 my $res = $self->{target};
171 27 100       59 return undef unless defined($res);
172              
173 26         69 for (keys %indicators) {
174 104 100       168 if ($t eq '') {
175 32 100       62 next if $_ eq '';
176             } else {
177 72 100       151 next unless index($_, "$t.") == 0;
178             }
179 50 100       107 return undef unless defined $indicators{$_}{target};
180 44         73 $res += $indicators{$_}{target};
181             }
182 20         58 $res;
183             }
184              
185             sub percent_complete {
186 14     14 1 29 my $self = shift;
187              
188 14         30 my $total_pos = $self->total_pos;
189 14         29 my $total_target = $self->total_target;
190              
191 14 100       40 return undef unless defined($total_target);
192 10 50       20 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         73 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 undef;
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 undef 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 undef 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   18 my ($self, %args) = @_;
252              
253             # no need to check for unknown arg in %args, it's an internal method anyway
254              
255 8         23 my $now = time();
256              
257 8         18 my $task = $self->{task};
258             #use Data::Dump; print "D: _update($task) "; dd \%args;
259              
260             SET_TITLE:
261             {
262 8 50       15 last unless exists $args{title};
  8         20  
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         17  
272 7         8 my $val = $args{target};
273 7 100 100     42 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     23 if (defined($val) && $self->{pos} > $val) {
277 0         0 $self->{pos} = $val;
278             }
279 6         12 $self->{target} = $val;
280 6         10 undef $self->{_remaining};
281             }
282              
283             SET_POS:
284             {
285 7 100       10 last unless exists $args{pos};
  7         15  
286 3         5 my $val = $args{pos};
287 3 100 66     23 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     9 if (defined($self->{target}) && $val > $self->{target}) {
291 0         0 $val = $self->{target};
292             }
293 2         4 $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         11  
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       10 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         11 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 0 0         die "Unknown argument(s) to update(): ".join(", ", keys(%args))
403             if keys(%args);
404              
405 0           my $now = time();
406              
407             # find output(s) and call it
408             {
409 0 0 0       last unless $ENV{PROGRESS} // 1;
  0            
410 0           my $task = $self->{task};
411 0           while (1) {
412 0 0         if ($outputs{$task}) {
413 0           for my $output (@{ $outputs{$task} }) {
  0            
414 0 0 0       next unless $force_update ||
415             $self->_should_update_output($output, $now, $priority);
416 0 0         if (ref($message) eq 'CODE') {
417 0           $message = $message->();
418             }
419             $output->update(
420 0           indicator => $indicators{$task},
421             message => $message,
422             priority => $priority,
423             time => $now,
424              
425             # temporary, internal API. to let an output module know
426             # the same update() when there are multiple instances of
427             # it
428             _update_id => $now,
429             );
430 0           $output->{_mtime} = $now;
431 0           $output->{_pos} = $pos;
432             }
433             }
434 0 0         last unless $task =~ s/\.?\w+\z//;
435             }
436             }
437             }
438              
439             sub start {
440 0     0 1   my $self = shift;
441 0           $self->_update(state => 'started');
442             }
443              
444             sub stop {
445 0     0 1   my $self = shift;
446 0           $self->_update(state => 'stopped');
447             }
448              
449             sub finish {
450 0     0 1   my ($self, %args) = @_;
451 0           $self->update(pos=>$self->{target}, state=>'finished', %args);
452             }
453              
454             sub reset {
455 0     0 1   my ($self, %args) = @_;
456 0           $self->update(pos=>0, state=>'started', %args);
457             }
458              
459             our $template_regex = qr{( # all=1
460             %
461             ( #width=2
462             -?\d+ )?
463             ( #dot=3
464             \.?)
465             ( #prec=4
466             \d+)?
467             ( #conv=5
468             [A-Za-z%])
469             )}x;
470              
471             sub fill_template {
472 0     0 1   my ($self, $template0, %args) = @_;
473              
474             # TODO: some caching so "%e%e" produces two identical numbers
475              
476 0           my ($template, $opts);
477 0 0         if (ref $template0 eq 'HASH') {
478 0           $opts = $template0;
479 0           $template = $opts->{template};
480             } else {
481 0           $template = $template0;
482 0           $opts = {};
483             }
484              
485             state $sub = sub {
486 0     0     my %args = @_;
487              
488 0           my ($all, $width, $dot, $prec, $conv) = ($1, $2, $3, $4, $5);
489              
490 0           my $p = $args{indicator};
491              
492 0           my ($fmt, $sconv, $data);
493 0 0         if ($conv eq 'n') {
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
494 0           $data = $p->{task};
495             } elsif ($conv eq 't') {
496 0           $data = $p->{title};
497             } elsif ($conv eq '%') {
498 0           $data = '%';
499             } elsif ($conv eq 'm') {
500 0   0       $data = $args{message} // '';
501             } elsif ($conv eq 'p') {
502 0           my $val = $p->percent_complete;
503 0   0       $width //= 3;
504 0 0         if (defined $val) {
505 0           $data = $val;
506 0   0       $prec //= 0;
507 0           $sconv = "f";
508             } else {
509 0           $data = '?';
510             }
511             } elsif ($conv eq 'P') {
512 0           $data = $p->total_pos;
513 0   0       $prec //= 0;
514 0           $sconv = "f";
515             } elsif ($conv eq 'T') {
516 0           my $val = $p->total_target;
517 0 0         if (defined $val) {
518 0           $data = $val;
519 0   0       $prec //= 0;
520 0           $sconv = "f";
521             } else {
522 0           $data = '?';
523             }
524             } elsif ($conv eq 'e') {
525 0           my $val = $p->elapsed;
526 0 0         $val = 1 if $val < 1; # TMP, prevent duration() return "just now"
527 0           $data = Time::Duration::concise(Time::Duration::duration($val));
528 0   0       $width //= -8;
529             } elsif ($conv eq 'r') {
530 0           my $val = $p->total_remaining;
531 0 0         if (defined $val) {
532 0 0         $val = 1 if $val < 1; # TMP, prevent duration() return "just now
533 0           $data = Time::Duration::concise(Time::Duration::duration($val));
534             } else {
535 0           $data = '?';
536             }
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             " left"; # XXX i18n
544             } else {
545 0           $val = $p->elapsed;
546 0 0         $val = 1 if $val < 1; # TMP, prevent duration() return "just now
547 0           $data = Time::Duration::concise(Time::Duration::duration($val)).
548             " elapsed"; # XXX i18n
549             }
550 0   0       $width //= -(8 + 1 + 7);
551             } else {
552 0 0         if ($opts->{handle_unknown_conversion}) {
553 0           my @res = $opts->{handle_unknown_conversion}->(
554             indicator => $p,
555             args => \%args,
556              
557             all => $all,
558             width => $width,
559             dot => $dot,
560             conv => $conv,
561             prec => $prec,
562             );
563 0 0         if (@res) {
564 0           ($fmt, $data) = @res;
565             } else {
566             # return as-is
567 0           $fmt = '%s';
568 0           $data = $all;
569             }
570             } else {
571             # return as-is
572 0           $fmt = '%s';
573 0           $data = $all;
574             }
575             }
576              
577             # sprintf format
578 0   0       $sconv //= 's';
579 0 0         $dot = "." if $sconv eq 'f';
580 0   0       $fmt //= join("", grep {defined} ("%", $width, $dot, $prec, $sconv));
  0            
581              
582             #say "D:fmt=$fmt";
583 0           sprintf $fmt, $data;
584 0           };
585 0           $template =~ s{$template_regex}{$sub->(%args, indicator=>$self)}egox;
  0            
586              
587 0           $template;
588             }
589              
590             1;
591             # ABSTRACT: Record progress to any output
592              
593             __END__