# Copyright 2014, Paul Johnson (paul@pjcj.net) # This software is free. It is licensed under the same terms as Perl itself. # The latest version of this software should be available from my homepage: # http://www.pjcj.net package Devel::Cover::Collection; use 5.16.0; use warnings; our $VERSION = '1.15'; # VERSION use Devel::Cover::DB; use Devel::Cover::DB::IO::JSON; use Devel::Cover::Dumper; use Parallel::Iterator "iterate_as_array"; use POSIX "setsid"; use Template; use Time::HiRes "time"; use Class::XSAccessor (); use Moo; use namespace::clean; use warnings FATAL => "all"; # be explicit since Moo sets this my %A = ( ro => [ qw( bin_dir cpancover_dir cpan_dir results_dir force output_file report timeout verbose workers docker ) ], rwp => [ qw( build_dirs local_timeout modules module_file ) ], rw => [ qw( ) ], ); while (my ($type, $names) = each %A) { has $_ => (is => $type) for @$names } sub BUILDARGS { my $class = shift; my (%args) = @_; { build_dirs => [], cpan_dir => [grep -d, glob("~/.cpan ~/.local/share/.cpan")], docker => "docker", force => 0, local_timeout => 0, modules => [], output_file => "index.html", report => "html_basic", timeout => 1800, # half an hour verbose => 0, workers => 0, %args, } }; # display $non_buffered characters, then buffer sub _sys { my $self = shift; my ($non_buffered, @command) = @_; my ($output1, $output2) = ("", ""); $output1 = "dc -> @command\n" if $self->verbose; my $timeout = $self->local_timeout || $self->timeout || 30 * 60; my $max = 4e4; # say "Setting alarm for $timeout seconds"; my $pid; eval { open STDIN, "<", "/dev/null" or die "Can't read /dev/null: $!"; $pid = open my $fh, "-|" // die "Can't fork: $!"; if ($pid) { my $printed = 0; local $SIG{ALRM} = sub { die "alarm\n" }; alarm $timeout; while (<$fh>) { # print "got: $_"; # say "printed $printed of $non_buffered"; if ($printed < $non_buffered) { print; if (($printed += length) >= $non_buffered) { say "Devel::Cover: buffering ..."; } } elsif (length $output2) { $output2 = substr $output2 . $_, $max * -.1, $max * .1; } else { $output1 .= $_; if (length $output1 > $max * .9) { $output1 = substr $output1, 0, $max * .9; $output2 = "\n"; } } } alarm 0; } else { setsid() != -1 or die "Can't start a new session: $!"; open STDERR, ">&STDOUT" or die "Can't dup stdout: $!"; exec @command or die "Can't exec @command: $!"; } }; if ($@) { die "propogate: $@" unless $@ eq "alarm\n"; # propagate unexpected errors warn "Timed out after $timeout seconds!\n"; my $pgrp = getpgrp($pid); my $n = kill "-KILL", $pgrp; warn "killed $n processes"; } length $output2 ? "$output1\n...\n$output2" : $output1 } sub sys { my $self = shift; $self->_sys(4e4, @_) } sub bsys { my $self = shift; $self->_sys(0, @_) } sub add_modules { my $self = shift; push @{$self->modules}, @_; } sub set_modules { my $self = shift; @{$self->modules} = @_; } sub set_module_file { my $self = shift; my ($file) = @_; $self->set_module_file($file); } sub process_module_file { my $self = shift; my $file = $self->module_file; return unless defined $file && length $file; open my $fh, "<", $file or die "Can't open $file: $!"; my $modules = do { local $/; <$fh> }; close $fh or die "Can't close $file: $!"; my @modules = grep /\S/, grep !/^ *#/, split /\n/, $modules; $self->add_modules(@modules); } sub build_modules { my $self = shift; my @command = qw( cpan -i -T ); push @command, "-f" if $self->force; # my @command = qw( cpan ); # $ENV{CPAN_OPTS} = "-i -T"; # $ENV{CPAN_OPTS} .= " -f" if $self->force; # $self->_set_local_timeout(300); my %m; for my $module (sort grep !$m{$_}++, @{$self->modules}) { say "Building $module"; my $output = $self->sys(@command, $module); say $output; } $self->_set_local_timeout(0); } sub add_build_dirs { my $self = shift; push @{$self->build_dirs}, grep -d, map glob("$_/build/*"), @{$self->cpan_dir}; } sub run { my $self = shift; my ($build_dir) = @_; my ($module) = $build_dir =~ m|.*/([^/]+?)(?:-\w{6})$| or return; my $db = "$build_dir/cover_db"; my $line = "=" x 80; my $output = "**** Checking coverage of $module ****\n"; my $results_dir = $self->results_dir // die "No results dir"; $output .= $self->sys("mkdir", "-p", $results_dir); $results_dir .= "/$module"; chdir $build_dir or die "Can't chdir $build_dir: $!\n"; say "Checking coverage of $module"; if (-d $db || -d "$build_dir/structure" || -d $results_dir) { $output .= "Already analysed\n"; unless ($self->force) { say "\n$line\n$output$line\n"; return; } } $output .= "Testing $module in $build_dir\n"; # say "\n$line\n$output$line\n"; return; $ENV{DEVEL_COVER_TEST_OPTS} = "-Mblib=" . $self->bin_dir; my @cmd = ($^X, $ENV{DEVEL_COVER_TEST_OPTS}, $self->bin_dir . "/cover"); $output .= $self->bsys( @cmd, "-test", "-report", $self->report, "-outputfile", $self->output_file, ); $output .= $self->sys(@cmd, "-report", "json", "-nosummary"); # TODO - option to merge DB with existing one # TODO - portability $output .= $self->sys("rm", "-rf", $results_dir); $output .= $self->sys("mv", $db, $results_dir); $output .= $self->sys("rm", "-rf", $db); say "\n$line\n$output$line\n"; } sub run_all { my $self = shift; my $results_dir = $self->results_dir // die "No results dir"; $self->sys("mkdir", "-p", $results_dir); my @res = iterate_as_array( { workers => $self->workers }, sub { my (undef, $dir) = @_; eval { $self->run($dir) }; warn "\n\n\n[$dir]: $@\n\n\n" if $@; }, $self->build_dirs ); # print Dumper \@res; } sub write_json { my $self = shift; my ($vars) = @_; # print Dumper $vars; my $results = {}; for my $module (keys %{$vars->{vals}}) { my $m = $vars->{vals}{$module}; my $mod = $m->{module}; my ($name, $version) = ($mod->{module} // $module) =~ /(.+)-(\d+\.\d+)$/; $name = $mod->{name} if defined $mod->{name}; $version = $mod->{version} if defined $mod->{version}; if (defined $name && defined $version) { $results->{$name}{$version}{coverage}{total} = { map { $_ => $m->{$_}{pc} } grep $m->{$_}{pc} ne 'n/a', grep !/link|module/, keys %$m }; } else { print "Cannot process $module: ", Dumper $m; } }; # print Dumper $vars, $results; my $io = Devel::Cover::DB::IO::JSON->new(options => "pretty"); my $file = $self->results_dir . "/cpancover.json"; $io->write($results, $file); say "Wrote json output to $file"; } sub class { my ($pc) = @_; $pc eq "n/a" ? "na" : $pc < 75 ? "c0" : $pc < 90 ? "c1" : $pc < 100 ? "c2" : "c3" } sub generate_html { my $self = shift; my $d = $self->results_dir; chdir $d or die "Can't chdir $d: $!\n"; my $f = "$d/index.html"; say "\n\nWriting collection output to $f ..."; my $vars = { title => "Coverage report", modules => [], vals => {}, headers => [ grep !/path|time/, @Devel::Cover::DB::Criteria_short, "total" ], criteria => [ grep !/path|time/, @Devel::Cover::DB::Criteria, "total" ], }; opendir my $dh, $d or die "Can't opendir $d: $!"; my @modules = sort grep !/^\./, readdir $dh; closedir $dh or die "Can't closedir $d: $!"; for my $module (@modules) { my $cover = "$d/$module/cover.json"; next unless -e $cover; say "Adding $module"; my $io = Devel::Cover::DB::IO::JSON->new; my $json = $io->read($cover); my $mod = { module => $module, map { $_ => $json->{runs}[0]{$_} } qw( name version dir ) }; unless (defined $mod->{name} && defined $mod->{version}) { my ($name, $version) = ($mod->{module} // $module) =~ /(.+)-(\d+\.\d+)$/; $mod->{name} //= $name; $mod->{version} //= $version; } push @{$vars->{modules}}, $mod; my $m = $vars->{vals}{$module} = {}; $m->{module} = $mod; $m->{link} = "$module/index.html" if $json->{summary}{Total}{total}{total}; for my $criterion (@{$vars->{criteria}}) { my $summary = $json->{summary}{Total}{$criterion}; # print "summary:", Dumper $summary; my $pc = $summary->{percentage}; $pc = defined $pc ? sprintf "%.2f", $pc : "n/a"; $m->{$criterion}{pc} = $pc; $m->{$criterion}{class} = class($pc); $m->{$criterion}{details} = ($summary->{covered} || 0) . " / " . ($summary->{total} || 0); } } # print "vars ", Dumper $vars; $self->write_stylesheet; my $template = Template->new({ LOAD_TEMPLATES => [ Devel::Cover::Collection::Template::Provider->new({}), ], }); $template->process("summary", $vars, $f) or die $template->error; $self->write_json($vars); say "Wrote collection output to $f"; } sub local_build { my $self = shift; $self->process_module_file; $self->build_modules; $self->add_build_dirs; $self->run_all; $self->generate_html; } sub cover_modules { my $self = shift; $self->process_module_file; my @command = qw( utils/dc cpancover-docker-module ); $self->_set_local_timeout(0); my @res = iterate_as_array( { workers => $self->workers }, sub { my (undef, $module) = @_; my $dir = $module =~ s|.*/||r =~ s/\.(?:zip|tgz|(?:tar\.(?:gz|bz2)))$//r; if (-d $self->results_dir . "/$dir") { say "$module already covered"; return; } my $timeout = $self->local_timeout || $self->timeout || 30 * 60; # say "Setting alarm for $timeout seconds"; my $name = sprintf("%s-%18.6f", $module, time) =~ tr/a-zA-Z0-9_./-/cr; say "$dir -> $name"; eval { local $SIG{ALRM} = sub { die "alarm\n" }; alarm $timeout; system @command, $module, $name; alarm 0; say "$dir done"; }; if ($@) { die "propogate: $@" unless $@ eq "alarm\n"; # unexpected errors say "Timed out after $timeout seconds!"; $self->sys($self->docker, "kill", $name); say "Killed docker container $name"; } }, do { my %m; [sort grep !$m{$_}++, @{$self->modules}] } ); $self->_set_local_timeout(0); } sub get_latest { my $self = shift; require CPAN::Releases::Latest; my $latest = CPAN::Releases::Latest->new; my $iterator = $latest->release_iterator; while (my $release = $iterator->next_release) { say $release->path; next; printf "%s path=%s time=%d size=%d\n", $release->distname, $release->path, $release->timestamp, $release->size; } } sub write_stylesheet { my $self = shift; my $css = $self->results_dir . "/collection.css"; open my $fh, ">", $css or die "Can't open $css: $!\n"; print $fh <= 75% * c2 : coverage >= 90% * c3 : path covered or coverage = 100% */ .c0 { background-color: #ff9999; border: solid 1px #cc0000; } .c1 { background-color: #ffcc99; border: solid 1px #ff9933; } .c2 { background-color: #ffff99; border: solid 1px #cccc66; } .c3 { background-color: #99ff99; border: solid 1px #009900; } EOF close $fh or die "Can't close $css: $!\n"; } package Devel::Cover::Collection::Template::Provider; use strict; use warnings; our $VERSION = '1.15'; # VERSION use base "Template::Provider"; my %Templates; sub fetch { my $self = shift; my ($name) = @_; # print "Looking for <$name>\n"; $self->SUPER::fetch(exists $Templates{$name} ? \$Templates{$name} : $name) } $Templates{colours} = <<'EOT'; [% colours = { default => "#ffffad", text => "#000000", number => "#ffffc0", error => "#ff0000", ok => "#00ff00", } %] [% MACRO bg BLOCK -%] bgcolor="[% colours.$colour %]" [%- END %] EOT $Templates{html} = <<'EOT'; [% PROCESS colours %] [% title %] [% content %] EOT $Templates{summary} = <<'EOT'; [% WRAPPER html %]

[% title %]

[% IF modules %] [% FOREACH header = headers %] [% END %] [% END %] [% FOREACH module = modules %] [% m = module.module %] [% FOREACH criterion = criteria %] [% END %] [% END %]
Module Version [% header %]
[% IF vals.$m.link %] [% module.name || module.module %] [% ELSE %] [% module.name || module.module %] [% END %] [% module.version %] [% vals.$m.$criterion.pc %]


Coverage information from Devel::Cover by Paul Johnson.
Core coverage (under development)

This server generously donated by bytemark [% END %] EOT " We have normality, I repeat we have normality. Anything you still can’t cope with is therefore your own problem. " __END__ =head1 NAME Devel::Cover::Collection - Code coverage for a collection of modules =head1 VERSION version 1.15 =head1 SYNOPSIS =head1 DESCRIPTION =head1 OPTIONS =head1 ENVIRONMENT =head1 BUGS Almost certainly. =head1 LICENCE Copyright 2014, Paul Johnson (paul@pjcj.net) This software is free. It is licensed under the same terms as Perl itself. The latest version of this software should be available on CPAN and from my homepage: http://www.pjcj.net/. =cut