CPAN-Testers-WWW-Reports-Mailer
view release on metacpan or search on metacpan
lib/CPAN/Testers/WWW/Reports/Mailer.pm view on Meta::CPAN
# default configuration settings
my %default = (
lastmail => '_lastmail',
verbose => 0,
nomail => 1,
logclean => 0,
mode => 'daily',
mailrc => 'data/01mailrc.txt'
);
my $sponsorfile = 'sponsors.json';
my (%AUTHORS,%PREFS,@SPONSORS,$MT,$IHEART,$serializer);
my %MODES = (
daily => { type => 1, period => '24 hours', report => 'Daily Summary' },
weekly => { type => 2, period => '7 days', report => 'Weekly Summary' }, # typically a Saturday
reports => { type => 3, period => '', report => 'Test' },
monthly => { type => 4, period => 'month', report => 'Monthly Summary' },
sun => { type => 5, period => '7 days', report => 'Weekly Summary' },
mon => { type => 6, period => '7 days', report => 'Weekly Summary' },
tue => { type => 7, period => '7 days', report => 'Weekly Summary' },
wed => { type => 8, period => '7 days', report => 'Weekly Summary' },
thu => { type => 9, period => '7 days', report => 'Weekly Summary' },
fri => { type => 10, period => '7 days', report => 'Weekly Summary' },
sat => { type => 11, period => '7 days', report => 'Weekly Summary' },
);
my $FROM = 'CPAN Tester Report Server <do_not_reply@cpantesters.org>';
my $HOW = '/usr/sbin/sendmail -bm';
my $HEAD = 'To: "NAME" <EMAIL>
From: FROM
Date: DATE
Subject: SUBJECT
';
my @dotw = ( "Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday" );
my @months = (
{ 'id' => 1, 'value' => "January", },
{ 'id' => 2, 'value' => "February", },
{ 'id' => 3, 'value' => "March", },
{ 'id' => 4, 'value' => "April", },
{ 'id' => 5, 'value' => "May", },
{ 'id' => 6, 'value' => "June", },
{ 'id' => 7, 'value' => "July", },
{ 'id' => 8, 'value' => "August", },
{ 'id' => 9, 'value' => "September", },
{ 'id' => 10, 'value' => "October", },
{ 'id' => 11, 'value' => "November", },
{ 'id' => 12, 'value' => "December" },
);
our %phrasebook = (
'LastReport' => "SELECT MAX(id) FROM cpanstats.cpanstats",
'GetEarliest' => "SELECT id FROM cpanstats.cpanstats WHERE fulldate > ? ORDER BY id LIMIT 1",
'FindAuthorType' => "SELECT pauseid FROM prefs_distributions WHERE report = ?",
'GetReports' => "SELECT id,guid,dist,version,platform,perl,state FROM cpanstats.cpanstats WHERE id > ? AND state IN ('pass','fail','na','unknown') ORDER BY id",
'GetReports2' => "SELECT c.id,c.guid,c.dist,c.version,c.platform,c.perl,c.state FROM cpanstats.cpanstats AS c INNER JOIN cpanstats.ixlatest AS x ON x.dist=c.dist WHERE c.id > ? AND c.state IN ('pass','fail','na','unknown') AND author IN (%s...
'GetReportCount' => "SELECT id FROM cpanstats.cpanstats WHERE platform=? AND perl=? AND state=? AND id < ? AND dist=? AND version=? LIMIT 2",
'GetLatestDistVers' => "SELECT version FROM cpanstats.uploads WHERE dist=? ORDER BY released DESC LIMIT 1",
'GetAuthor' => "SELECT author FROM cpanstats.uploads WHERE dist=? AND version=? LIMIT 1",
'GetAuthors' => "SELECT author,dist,version FROM cpanstats.uploads",
'GetAuthorPrefs' => "SELECT * FROM prefs_authors WHERE pauseid=?",
'GetDefaultPrefs' => "SELECT * FROM prefs_authors AS a INNER JOIN prefs_distributions AS d ON d.pauseid=a.pauseid AND d.distribution='-' WHERE a.pauseid=?",
'GetDistPrefs' => "SELECT * FROM prefs_distributions WHERE pauseid=? AND distribution=?",
'InsertAuthorLogin' => 'INSERT INTO prefs_authors (active,lastlogin,pauseid) VALUES (1,?,?)',
'InsertDistPrefs' => "INSERT INTO prefs_distributions (pauseid,distribution,ignored,report,grade,tuple,version,patches,perl,platform) VALUES (?,?,0,1,'FAIL','FIRST','LATEST',0,'ALL','ALL')",
'GetArticle' => "SELECT * FROM articles.articles WHERE id=?",
'GetReportTest' => "SELECT id,guid,dist,version,platform,perl,state FROM cpanstats.cpanstats WHERE id = ? AND state IN ('pass','fail','na','unknown') ORDER BY id",
'GetMetabaseByGUID' => 'SELECT * FROM metabase.metabase WHERE guid=?',
'GetTestersEmail' => 'SELECT * FROM metabase.testers_email',
'GetTesters' => 'SELECT * FROM metabase.testers_email ORDER BY id'
);
#----------------------------------------------------------------------------
# The Application Programming Interface
__PACKAGE__->mk_accessors(
qw( lastmail verbose nomail test logfile logclean mode mailrc tt pause ));
# -------------------------------------
# The Public Interface Functions
sub new {
my $class = shift;
my %hash = @_;
my $self = {};
bless $self, $class;
my %options;
GetOptions( \%options,
'config=s',
'lastmail=s',
'mailrc=s',
'test=i',
'logfile=s',
'logclean',
'verbose',
'nomail',
'mode=s',
'help|h',
'version|v'
) or help(1);
# default to API settings if no command line option
for(qw(config help version)) {
$options{$_} ||= $hash{$_} if(defined $hash{$_});
}
$self->help(1) if($options{help});
$self->help(0) if($options{version});
# ensure we have a configuration file
die "Must specify a configuration file\n" unless( $options{config});
die "Configuration file [$options{config}] not found\n" unless(-f $options{config});
# load configuration
my $cfg = Config::IniFiles->new( -file => $options{config} );
# configure databases
for my $db (qw(CPANPREFS)) {
die "No configuration for $db database\n" unless($cfg->SectionExists($db));
my %opts;
lib/CPAN/Testers/WWW/Reports/Mailer.pm view on Meta::CPAN
detect_sereal => 1,
detect_json => 1,
);
return $self;
}
sub check_reports {
my $self = shift;
my $mode = $self->mode;
my $report_type = $MODES{$mode}->{type};
my $last_id = int( $self->_get_lastid() );
my (%reports,%tvars);
$self->_log( "INFO: START checking reports in '$mode' mode\n" );
$self->_log( "INFO: last_id=$last_id\n" );
my $next;
if($self->test) {
$next = $self->{CPANPREFS}->iterator('hash',$phrasebook{'GetReportTest'},$self->test);
} elsif($mode ne 'daily') {
my @authors = $self->{CPANPREFS}->get_query('hash',$phrasebook{'FindAuthorType'}, $report_type);
return $self->_set_lastid() unless(@authors);
my $sql = sprintf $phrasebook{'GetReports2'}, join(',',map {"'$_->{pauseid}'"} @authors);
$next = $self->{CPANPREFS}->iterator('hash',$sql,$last_id);
} else {
# find all reports since last update
$next = $self->{CPANPREFS}->iterator('hash',$phrasebook{'GetReports'},$last_id);
unless($next) {
$self->_log( "INFO: STOP checking reports\n" );
return;
}
}
my $rows = 0;
while( my $row = $next->()) {
$rows++;
$self->_log( "DEBUG: processing report: $row->{id}\n" ) if($self->verbose);
$self->{counts}{REPORTS}++;
$last_id = $row->{id};
$row->{state} = uc $row->{state};
$self->{counts}{$row->{state}}++;
$self->_log( "DEBUG: dist: $row->{dist} $row->{version} $row->{state}\n" ) if($self->verbose);
my $author = $self->_get_author($row->{dist}, $row->{version});
$self->_log( "DEBUG: author: ".($author||'')."\n" ) if($self->verbose);
next unless($author);
unless($author) {
$self->_log( "WARN: author not found for distribution [$row->{dist}], [$row->{version}]\n" );
next;
}
$row->{version} ||= '';
$row->{platform} ||= '';
$row->{perl} ||= '';
# get author preferences
my $prefs = $self->_get_prefs($author) || next;
# do we need to worry about this author?
if($prefs->{active} == 2) {
$self->{counts}{NOMAIL}++;
$self->_log( "DEBUG: author: $author - not active\n" ) if($self->verbose);
next;
}
# get distribution preferences
$prefs = $self->_get_prefs($author, $row->{dist});
$self->_log( "DEBUG: dist prefs: " .($prefs ? 'Found' : 'Not Found')."\n" ) if($self->verbose);
next unless($prefs);
$self->_log( "DEBUG: dist prefs: ignored=" .($prefs->{ignored} || 0)."\n" ) if($self->verbose);
next if($prefs->{ignored});
$self->_log( "DEBUG: dist prefs: report=$prefs->{report}, report type=$report_type\n" ) if($self->verbose);
next if($prefs->{report} != $report_type);
$self->_log( "DEBUG: dist prefs: $row->{state}=" .($prefs->{grades}{$row->{state}}||'undef')."\n" ) if($self->verbose);
$self->_log( "DEBUG: dist prefs: ALL=" .($prefs->{grades}{ALL}||'undef')."\n" ) if($self->verbose);
next unless($prefs->{grades}{$row->{state}} || $prefs->{grades}{'ALL'});
$self->_log( "DEBUG: dist prefs: CONTINUE\n" ) if($self->verbose);
# Check whether distribution version is required.
# If version set to 'LATEST' check this is the current version, if set
# to 'ALL' then we should allow EVERYTHING through, otherwise filter
# on the requested versions.
if($row->{version} && $prefs->{version} && $prefs->{version} ne 'ALL') {
if($prefs->{version} eq 'LATEST') {
my @vers = $self->{CPANPREFS}->get_query('array',$phrasebook{'GetLatestDistVers'},$row->{dist});
$self->_log( "DEBUG: dist prefs: vers=".(scalar(@vers))."\n" ) if($self->verbose);
$self->_log( "DEBUG: dist prefs: version=$vers[0]->[0], $row->{version}\n" ) if($self->verbose);
next if(@vers && $vers[0]->[0] ne $row->{version});
} else {
$prefs->{version} =~ s/\s*//g;
my %m = map {$_ => 1} split(',',$prefs->{version});
$self->_log( "DEBUG: dist prefs: $row->{version}\n" ) if($self->verbose);
next unless($m{$row->{version}});
}
}
# Check whether this platform is required.
if($row->{platform} && $prefs->{platform} && $prefs->{platform} ne 'ALL') {
$prefs->{platform} =~ s/\s*//g;
$prefs->{platform} =~ s/,/|/g;
$prefs->{platform} =~ s/\./\\./g;
$prefs->{platform} =~ s/^(\w+)\|//;
if($1 && $1 eq 'NOT') {
$self->_log( "DEBUG: dist prefs: $row->{platform}, =~ $prefs->{platform}\n" ) if($self->verbose);
next if($row->{platform} =~ /$prefs->{platform}/);
} else {
$self->_log( "DEBUG: dist prefs: $row->{platform}, !~ $prefs->{platform}\n" ) if($self->verbose);
next if($row->{platform} !~ /$prefs->{platform}/);
}
}
# Check whether this perl version is required.
if($row->{perl} && $prefs->{perl} && $prefs->{perl} ne 'ALL') {
my $perlv = $row->{perl};
$perlv = $row->{perl};
$perlv =~ s/\s.*//;
$prefs->{perl} =~ s/\s*//g;
$prefs->{perl} =~ s/,/|/g;
$prefs->{perl} =~ s/\./\\./g;
my $v = version->new("$perlv")->numify;
$prefs->{platform} =~ s/^(\w+)\|//;
if($1 && $1 eq 'NOT') {
$self->_log( "DEBUG: dist prefs: $perlv || $v =~ $prefs->{perl}\n" ) if($self->verbose);
next if($perlv =~ /$prefs->{perl}/ && $v =~ /$prefs->{perl}/);
} else {
$self->_log( "DEBUG: dist prefs: $perlv || $v !~ $prefs->{perl}\n" ) if($self->verbose);
next if($perlv !~ /$prefs->{perl}/ && $v !~ /$prefs->{perl}/);
}
}
# Check whether patches are required.
$self->_log( "DEBUG: dist prefs: patches=$prefs->{patches}, row perl $row->{perl}\n" ) if($self->verbose);
next if(!$prefs->{patches} && $row->{perl} =~ /(RC\d+|patch)/);
# check whether only first instance required
if($prefs->{tuple} eq 'FIRST') {
my @count = $self->{CPANPREFS}->get_query('array',$phrasebook{'GetReportCount'},
$row->{platform}, $row->{perl}, $row->{state}, $row->{id}, $row->{dist}, $row->{version});
$self->_log( "DEBUG: dist prefs: tuple=FIRST, count=".(scalar(@count))."\n" ) if($self->verbose);
next if(@count > 0);
}
$self->_log( "DEBUG: report is being added to mailshot\n" ) if($self->verbose);
if($mode eq 'reports') {
$self->_send_report($author,$row);
}
push @{$reports{$author}->{dists}{$row->{dist}}->{versions}{$row->{version}}->{platforms}{$row->{platform}}->{perls}{$row->{perl}}->{states}{uc $row->{state}}->{value}}, ($row->{guid} || $row->{id});
}
$self->_log( "INFO: STOP checking reports in '$mode' mode\n" );
return $self->_set_lastid() unless($rows);
if($mode ne 'reports') {
$self->_log( "INFO: START parsing data in '$mode' mode\n" );
$self->_log( "DEBUG: processing authors: ".(scalar(keys %reports))."\n" ) if($self->verbose);
for my $author (sort keys %reports) {
$self->_log( "DEBUG: $author\n" ) if($self->verbose);
my $pause = $self->pause->author($author);
$tvars{name} = $pause ? $pause->name : $author;
$tvars{author} = $author;
$tvars{dists} = ();
# get author preferences
my $prefs = $self->_get_prefs($author);
# active:
# 0 - new author, no correspondance
# 1 - new author, notification mailed
# 2 - author requested no mail
# 3 - author requested summary report
if(!$prefs->{active} || $prefs->{active} == 0) {
$tvars{subject} = 'Welcome to CPAN Testers';
$self->_write_mail('notification.eml',\%tvars);
$self->{counts}{NEWAUTH}++;
# insert author defaults, however check that they don't already
# exists in the system first, in case entries are out of sync.
my @auth = $self->{CPANPREFS}->get_query('hash',$phrasebook{'GetAuthorPrefs'}, $author);
$self->{CPANPREFS}->do_query($phrasebook{'InsertAuthorLogin'}, time(), $author) unless(@auth);
my @dist = $self->{CPANPREFS}->get_query('hash',$phrasebook{'GetDistPrefs'}, $author,'-');
$self->{CPANPREFS}->do_query($phrasebook{'InsertDistPrefs'}, $author, '-') unless(@dist);
}
$self->_log( "DEBUG: $author - distributions = ".(scalar(keys %{$reports{$author}->{dists}}))."\n" ) if($self->verbose);
my ($reports,@e);
for my $dist (sort keys %{$reports{$author}->{dists}}) {
my $v = $reports{$author}->{dists}{$dist};
my @d;
for my $version (sort keys %{$v->{versions}}) {
my $w = $v->{versions}{$version};
my @c;
for my $platform (sort keys %{$w->{platforms}}) {
my $x = $w->{platforms}{$platform};
my @b;
for my $perl (sort keys %{$x->{perls}}) {
my $y = $x->{perls}{$perl};
my @a;
for my $state (sort keys %{$y->{states}}) {
my $z = $y->{states}{$state};
push @a, {state => $state, ids => $z->{value}};
$reports++;
}
push @b, {perl => $perl, states => \@a};
}
push @c, {platform => $platform, perls => \@b};
}
push @d, {version => $version, platforms => \@c};
}
push @e, {dist => $dist, versions => \@d};
}
next unless($reports);
if($self->verbose) { $self->_log( "DEBUG: $author - reports = $reports\n" ) }
else { $self->_log( "INFO: $author - dists=".(scalar(keys %{$reports{$author}->{dists}})).", reports=$reports\n" ) }
$tvars{dists} = \@e;
$tvars{period} = $MODES{$mode}->{period};
$tvars{report} = $MODES{$mode}->{report};
$tvars{subject} = "CPAN Testers $tvars{report} Report";
$self->_write_mail('mailer.eml',\%tvars);
}
$self->_log( "INFO: STOP parsing data in '$mode' mode\n" );
}
$self->_set_lastid($last_id);
}
sub check_counts {
lib/CPAN/Testers/WWW/Reports/Mailer.pm view on Meta::CPAN
my $text = read_file($self->lastmail);
return $id if(($id) = $text =~ m!$mode=(\d+)!);
return $self->_get_earliest(); # mode not found, find earliest id based on mode
}
sub _set_lastid {
my ($self,$id) = @_;
if(!defined $id) {
my @lastid = $self->{CPANPREFS}->get_query('array',$phrasebook{'LastReport'});
$id = @lastid ? $lastid[0]->[0] : 0;
}
$self->_log( "INFO: new last_id=$id\n" );
$self->_log( "INFO: STOP checking reports\n" );
return $id if($self->nomail);
$self->_get_lastid($id);
}
sub _get_earliest {
my $self = shift;
my $mode = $self->mode;
my @date = localtime(time);
$date[5] += 1900;
$date[4] += 1;
if($mode eq 'monthly') {
$date[4] -= 1;
$date[3] = 1;
} elsif($mode eq 'daily' || $mode eq 'reports') {
$date[3] -= 1;
} else {
$date[3] -=7;
}
if($date[3] < 1) {
$date[4] -= 1;
if($date[4] == 2 && $date[5] % 4) {
$date[3] = 28 - $date[3];
} elsif($date[3] == 2) {
$date[3] = 29 - $date[3];
} elsif($date[3] == 4 || $date[3] == 6 || $date[3] == 9 || $date[3] == 11) {
$date[3] = 30 - $date[3];
} else {
$date[3] = 31 - $date[3];
}
if($date[4] < 1) {
$date[4] = 12;
$date[5] -= 1;
}
}
my $fulldate = sprintf "%04d%02d%02d000000", $date[5], $date[4], $date[3];
my @report = $self->{CPANPREFS}->get_query('array',$phrasebook{'GetEarliest'}, $fulldate);
return 0 unless(@report);
return $report[0]->[0] || 0;
}
sub _get_prefs {
my $self = shift;
my ($author,$dist) = @_;
my $active = 0;
return unless($author);
# get distribution defaults
if($author && $dist) {
if(defined $PREFS{$author}{dists}{$dist}) {
return $PREFS{$author}{dists}{$dist};
}
my @rows = $self->{CPANPREFS}->get_query('hash',$phrasebook{'GetDistPrefs'}, $author,$dist);
if(@rows) {
$PREFS{$author}{dists}{$dist} = $self->_parse_prefs($rows[0]);
return $PREFS{$author}{dists}{$dist};
}
# fall through and assume author defaults
}
# get author defaults
if($author) {
if(defined $PREFS{$author}{default}) {
return $PREFS{$author}{default};
}
my @auth = $self->{CPANPREFS}->get_query('hash',$phrasebook{'GetAuthorPrefs'}, $author);
if(@auth) {
$PREFS{$author}{default}{active} = $auth[0]->{active} || 0;
my @rows = $self->{CPANPREFS}->get_query('hash',$phrasebook{'GetDefaultPrefs'}, $author);
if(@rows) {
$PREFS{$author}{default} = $self->_parse_prefs($rows[0]);
$PREFS{$author}{default}{active} = $rows[0]->{active} || 0;
return $PREFS{$author}{default};
} else {
$self->{CPANPREFS}->do_query($phrasebook{'InsertDistPrefs'}, $author, '-');
$active = $PREFS{$author}{default}{active};
}
}
# fall through and assume new author
}
$dist ||= '-';
# use global defaults
my %prefs = (
active => $active,
ignored => 0,
report => 1,
grades => {'FAIL' => 1},
tuple => 'FIRST',
version => 'LATEST',
patches => 0,
perl => 'ALL',
platform => 'ALL',
);
$PREFS{$author}{dists}{$dist} = \%prefs;
return \%prefs;
}
sub _parse_prefs {
my ($self,$row) = @_;
my %hash;
$row->{grade} ||= 'FAIL';
my %grades = map {$_ => 1} split(',',$row->{grade});
$hash{grades} = \%grades;
$hash{ignored} = $self->_defined_or($row->{ignored}, 0);
$hash{report} = $self->_defined_or($row->{report}, 1);
$hash{tuple} = $self->_defined_or($row->{tuple}, 'FIRST');
$hash{version} = $self->_defined_or($row->{version}, 'LATEST');
$hash{patches} = $self->_defined_or($row->{patches}, 0);
$hash{perl} = $self->_defined_or($row->{perl}, 'ALL');
$hash{platform} = $self->_defined_or($row->{platform}, 'ALL');
return \%hash;
}
sub _send_report {
my ($self,$author,$row) = @_;
my %tvars;
my $nntpid = guid_to_nntp($row->{guid});
# old NNTP article lookup
if($nntpid) {
# get article
my @rows = $self->{CPANPREFS}->get_query('hash',$phrasebook{'GetArticle'}, $nntpid);
#$self->_log( "ARTICLE: $nntpid: $rows[0]->{article}\n" );
# disassemble article
$rows[0]->{article} = decode_qp($rows[0]->{article}) if($rows[0]->{article} =~ /=3D/);
my $mail = Email::Simple->new($rows[0]->{article});
return unless $mail;
# get from & subject line
my $from = $mail->header("From");
my $subject = $mail->header("Subject");
return unless $subject;
my ($address) = Email::Address->parse($from);
my $reply = sprintf "%s\@%s", $address->user, $address->host;
# extract the body
my $encoding = $mail->header('Content-Transfer-Encoding');
my $body = $mail->body;
$body = decode_base64($body) if($encoding && $encoding eq 'base64');
# set up new mail headers
my $pause = $self->pause->author($author);
%tvars = (
author => $author,
name => ($pause ? $pause->name : $author),
subject => $subject,
from => $reply,
body => $body,
reply => $reply
);
lib/CPAN/Testers/WWW/Reports/Mailer.pm view on Meta::CPAN
=item * lastfile
Path to the file containing the last NNTPID processed.
=item * verbose
Provides the current verbose configuration setting.
=item * nomail
Provides the current nomail configuration setting.
=item * test
Provides a single test ID, if not all NNTPIDs need testing.
=item * logfile
Path to output log file for progress and debugging messages.
=item * logclean
If set to a true value will create/overwrite the logfile, otherwise will
append any messages.
=item * mode
Provides the current mode being executed.
=item * mailrc
Path to the 01mailrc.txt file.
=item * tt
Provides the Template Toolkit object.
=item * pause
Provides the Parse::CPAN::Authors object.
=back
=head2 Internal Methods
=over 4
=item * _get_lastid
Returns the last NNTPID processed for the current mode.
=item * _set_lastid
Sets the given NNTPID for the current mode.
=item * _get_author
Returns the author of a given distribution/version.
=item * _get_prefs
Returns the author preferences.
=item * _parse_prefs
Parse a preferences record and returns a hash instance.
=item * _send_report
Repackages a report as an email for an individual author.
=item * _write_mail
Composes and sends a mail message.
=item * _emaildate
Returns an RFC 2822 compliant formatted date string.
=item * _download_mailrc
Downloads and/or reads a copy of the 01mailrc.txt file.
=back
=head1 SEE ALSO
L<CPAN::Testers::Data::Generator>
L<CPAN::Testers::WWW::Statistics>
F<http://prefs.cpantesters.org/>,
F<http://www.cpantesters.org/>,
F<http://stats.cpantesters.org/>,
F<http://wiki.cpantesters.org/>,
F<http://blog.cpantesters.org/>
=head1 BUGS, PATCHES & FIXES
There are no known bugs at the time of this release. However, if you spot a
bug or are experiencing difficulties, that is not explained within the POD
documentation, please send bug reports and patches to the RT Queue (see below).
Fixes are dependent upon their severity and my availability. Should a fix not
be forthcoming, please feel free to (politely) remind me.
RT Queue -
http://rt.cpan.org/Public/Dist/Display.html?Name=CPAN-Testers-WWW-Reports-Mailer
=head1 CPAN TESTERS FUND
CPAN Testers wouldn't exist without the help and support of the Perl
community. However, since 2008 CPAN Testers has grown far beyond the
expectations of it's original creators. As a consequence it now requires
considerable funding to help support the infrastructure.
In early 2012 the Enlightened Perl Organisation very kindly set-up a
CPAN Testers Fund within their donatation structure, to help the project
cover the costs of servers and services.
If you would like to donate to the CPAN Testers Fund, please follow the link
below to the Enlightened Perl Organisation's donation site.
F<https://members.enlightenedperl.org/drupal/donate-cpan-testers>
If your company would like to support us, you can donate financially via the
fund link above, or if you have servers or services that we might use, please
send an email to admin@cpantesters.org with details.
Our full list of current sponsors can be found at our I <3 CPAN Testers site.
F<http://iheart.cpantesters.org>
=head1 AUTHOR
Barbie, <barbie@missbarbell.co.uk> for
Miss Barbell Productions, L<http://www.missbarbell.co.uk/>
=head1 COPYRIGHT & LICENSE
Copyright (C) 2008-2016 Barbie for Miss Barbell Productions.
This distribution is free software; you can redistribute it and/or
modify it under the Artistic Licence v2.
=cut
( run in 1.306 second using v1.01-cache-2.11-cpan-364913b4093 )