File Coverage

script/opan
Criterion Covered Total %
statement 136 167 81.4
branch 21 36 58.3
condition 7 11 63.6
subroutine 67 73 91.7
pod n/a
total 231 287 80.4


line stmt bran cond sub pod time code
1             #!perl
2              
3             package App::opan;
4              
5 3     3   4544 use strictures 2;
  3         1687  
  3         139  
6              
7             our $VERSION = '0.003004';
8              
9 3     3   2327 use Dist::Metadata;
  3         106482  
  3         117  
10 3     3   1822 use File::Open qw(fopen);
  3         4924  
  3         215  
11 3     3   1867 use List::UtilsBy qw(sort_by);
  3         5986  
  3         231  
12 3     3   1341 use IPC::System::Simple qw(capture);
  3         20259  
  3         247  
13 3     3   859 use Mojo::Util qw(monkey_patch);
  3         207776  
  3         281  
14 3     3   657 use Mojo::File qw(path);
  3         29867  
  3         164  
15 3     3   1085 use Import::Into;
  3         5604  
  3         8686  
16              
17             our %TOKENS = map { $_=>1 } split/:/, $ENV{OPAN_AUTH_TOKENS} || '';
18              
19             sub cpan_fetch {
20 5     5   208 my $app = shift;
21 5         96 my $url = Mojo::URL->new(shift)->to_abs($app->cpan_url);
22 5         5917 my $tx = $app->ua->get($url);
23 5 100       766458 return $tx->res unless my $err = $tx->error;
24 1         29 die sprintf "%s %s: %s\n", $tx->req->method, $url, $err->{message};
25             }
26              
27             sub packages_header {
28 18     18   142 my ($count) = @_;
29 18         223 (my $str = <<" HEADER") =~ s/^ //mg;
30             File: 02packages.details.txt
31             Description: Package names found in directory \$CPAN/authors/id/
32             Columns: package name, version, path
33             Intended-For: Automated fetch routines, namespace documentation.
34             Written-By: App::opan
35             Line-Count: ${count}
36 18         432 Last-Updated: ${\scalar gmtime} GMT
37              
38             HEADER
39 18         534 return $str;
40             }
41              
42             sub extract_provides_from_tarball {
43 5     5   18 my ($tarball) = @_;
44 5         197 Dist::Metadata->new(file => $tarball)->package_versions;
45             }
46              
47             sub pod_section {
48 36     36   75 my ($cmd, $for_description) = @_;
49 36         87 my $fh = fopen __FILE__;
50              
51 36         2620 my $pod = '';
52 36 100       693 while (<$fh>) { /^=head3 $cmd\s*$/ && last }
  18972         54827  
53 36 50 100     122 while (<$fh>) { /^=head/ && last || ($pod .= $_) }
  318         787  
54              
55 36 100       62 if ($for_description) {
56 24 50       225 $pod = $pod =~ m!\n(\S+.*?\.)(?:\s|$)!s ? $1 : "$0 $cmd --help for more info";
57 24         81 $pod =~ s![\n\r]+! !g;
58 24         160 $pod =~ s![\s\.]+$!!;
59             }
60              
61 36         252 $pod =~ s!\b[CIL]<\/?([^>]+)\>!$1!g; # Remove C<...> pod notation
62 36         1263 return $pod;
63             }
64              
65             sub provides_to_packages_entries {
66 5     5   283124 my ($path, $provides) = @_;
67             # <@mst> ok, I officially have no idea what order 02packages is actually in
68             # <@rjbs> $list .= join "", sort {lc $a cmp lc $b} @listing02;
69             [
70             map +[
71             $_, defined($provides->{$_}) ? $provides->{$_} : 'undef', $path
72 5 50   4   159 ], sort_by { lc } keys %$provides
  4         59  
73             ]
74             }
75              
76             sub entries_from_packages_file {
77 36     36   57078 my ($file) = @_;
78 36         430 my $fh = fopen $file;
79 36         5027 while (my $header = <$fh>) {
80 295 100       1076 last if $header =~ /^$/;
81             }
82 36         90 my @entries;
83 36         287 while (my $line = <$fh>) {
84 502592         752165 chomp($line);
85 502592         2666199 push @entries, [ split /\s+/, $line ];
86             }
87 36         1324 return \@entries;
88             }
89              
90             sub merge_packages_entries {
91 17     17   198 my ($base, $merge_these) = @_;
92 17 50       84 return $base unless $merge_these;
93 17         41 my @merged;
94 17         45 my @to_merge = @$merge_these;
95 17         165 foreach my $idx (0..$#$base) {
96 1004822   66     1620905 while (@to_merge and lc($to_merge[0][0]) lt lc($base->[$idx][0])) {
97 0         0 push @merged, shift @to_merge;
98             }
99 1004822 100 100     2135439 push @merged, (
100             (@to_merge and $to_merge[0][0] eq $base->[$idx][0])
101             ? shift @to_merge
102             : $base->[$idx]
103             );
104             }
105 17         78 push @merged, @to_merge;
106 17         138 return \@merged;
107             }
108              
109             sub write_packages_file {
110 18     18   14646 my ($file, $entries) = @_;
111 18         281 my $fh = fopen $file, 'w';
112 18         13545 print $fh packages_header(scalar @$entries);
113             local *_ = sub {
114             # mirroring 'sub rewrite02 {' in lib/PAUSE/mldistwatch.pm
115             # see http://github.com/andk/pause for the whole thing
116 1004826     1004826   1636373 my ($one, $two) = (30, 8);
117 1004826 100       1538087 if (length($_[0]) > $one) {
118 498040         588447 $one += 8 - length($_[1]);
119 498040         570290 $two = length($_[1]);
120             }
121 1004826         3832330 sprintf "%-${one}s %${two}s %s\n", @_;
122 18         371 };
123 18         176 print $fh _(@$_) for @$entries;
124 18         9099 close $fh;
125 18         374 path("${file}.gz")->spurt(scalar capture(gzip => -c => $file));
126             }
127              
128             sub add_dist_to_index {
129 5     5   581 my ($index, $dist) = @_;
130 5         37 my $existing = entries_from_packages_file($index);
131 5         42 my ($path) = $dist =~ m{pans/[a-z]+/dists/(.*)};
132 5         177 write_packages_file(
133             $index,
134             merge_packages_entries(
135             $existing,
136             provides_to_packages_entries(
137             $path,
138             extract_provides_from_tarball($dist)
139             ),
140             )
141             );
142             }
143              
144             sub remove_dist_from_index {
145 1     1   58 my ($index, $dist) = @_;
146 1         16 my $existing = entries_from_packages_file($index);
147 1         41 my $exclude = qr/\Q${dist}\E$/;
148 1         57 write_packages_file(
149             $index,
150             [ grep $_->[2] !~ $exclude, @$existing ],
151             );
152             }
153              
154             my @pan_names = qw(upstream custom pinset combined nopin);
155              
156             sub do_init {
157 2     2   14 my ($app) = @_;
158 2         15 path("pans/$_/dists")->make_path for @pan_names;
159 2         2531 write_packages_file("pans/$_/index", []) for qw(custom pinset);
160 2         13693 do_pull($app);
161             }
162              
163             sub do_fetch {
164 2     2   19 my ($app) = @_;
165 2         103 path('pans/upstream/index.gz')->spurt(
166             cpan_fetch($app, 'modules/02packages.details.txt.gz')->body
167             );
168 2         13350 path('pans/upstream/index')->spurt(
169             scalar capture qw(gzip -dc), 'pans/upstream/index.gz'
170             );
171             }
172              
173             sub do_merge {
174 4     4   83 my ($app) = @_;
175 4         68 my $upstream = entries_from_packages_file('pans/upstream/index');
176 4         54 my $pinset = entries_from_packages_file('pans/pinset/index');
177 4         42 my $custom = entries_from_packages_file('pans/custom/index');
178              
179 4         50 my $nopin = merge_packages_entries($upstream, $custom);
180 4         43 write_packages_file('pans/nopin/index', $nopin);
181              
182 4         1305705 my $combined = merge_packages_entries(
183             $upstream, merge_packages_entries($pinset, $custom)
184             );
185 4         99 write_packages_file('pans/combined/index', $combined);
186             }
187              
188             sub do_pull {
189 2     2   35 my ($app) = @_;
190 2         104 do_fetch($app);
191 2         382172 do_merge($app);
192             }
193              
194             sub do_add {
195 2     2   25 my ($app, $path_arg) = @_;
196 2         30 my $path = path($path_arg);
197 2         53 my $pan_dir = path('pans/custom/dists/M/MY/MY')->make_path;
198 2 50       818 $path->copy_to(my $pan_path = $pan_dir->child($path->basename))
199             or die "Failed to copy ${path} into custom pan: $!";
200 2         2001 add_dist_to_index('pans/custom/index', $pan_path);
201             }
202              
203             sub do_unadd {
204 0     0   0 my ($app, $dist) = @_;
205 0         0 remove_dist_from_index('pans/custom/index', $dist);
206             }
207              
208             sub do_pin {
209 3     3   40 my ($app, $path_arg) = @_;
210 3 100       38 $path_arg =~ /^(([A-Z])[A-Z])[A-Z]/ and $path_arg = join('/', $2, $1, $path_arg);
211 3         14 my $path = path($path_arg);
212 3         62 my $res = cpan_fetch($app, "authors/id/$path");
213 2         61 path("pans/pinset/dists/")->child($path->dirname)->make_path;
214 2         933 add_dist_to_index('pans/pinset/index', path("pans/pinset/dists/$path")->spurt($res->body));
215             }
216              
217             sub do_unpin {
218 1     1   24 my ($app, $dist) = @_;
219 1         19 remove_dist_from_index('pans/pinset/index', $dist);
220             }
221              
222             sub generate_purgelist {
223 2     2   125 my @list;
224 2         14 foreach my $pan (qw(pinset custom)) {
225             my %indexed = map +("pans/${pan}/dists/".$_->[2] => 1),
226 4         9 @{entries_from_packages_file("pans/${pan}/index")};
  4         27  
227 4         838 foreach my $file (sort glob "pans/${pan}/dists/*/*/*/*") {
228 6 100       55 push @list, $file unless $indexed{$file};
229             }
230             }
231 2         308 return @list;
232             }
233              
234             sub do_purgelist {
235 1     1   33 print "$_\n" for &generate_purgelist;
236             }
237              
238             sub do_purge {
239 1     1   19 unlink($_) for &generate_purgelist;
240             }
241              
242             sub run_with_server {
243 0     0   0 my ($app, $run, $pan, @args) = @_;
244 0 0 0     0 unless (
245             defined($pan) and $pan =~ /^--(combined|nopin|autopin)$/
246             ) {
247 0         0 unshift @args, grep defined, $pan;
248 0         0 $pan = '--combined';
249             }
250 0         0 $pan =~ s/^--//;
251 0         0 require Mojo::IOLoop::Server;
252 0         0 my $port = Mojo::IOLoop::Server->generate_port;
253 0         0 my $url = "http://localhost:${port}/";
254 0         0 my $pid = fork();
255 0 0       0 die "fork() fork()ed up: $!" unless defined $pid;
256 0 0       0 unless ($pid) {
257 0 0       0 $ENV{OPAN_AUTOPIN} = 1 if $pan eq 'autopin';
258 0         0 $app->start(daemon => -l => $url);
259 0         0 exit(0);
260             }
261 0         0 eval { $run->("${url}${pan}", @args) };
  0         0  
262 0         0 my $err = $@;
263 0         0 kill TERM => $pid;
264 0 0       0 warn "Run block failed: $err" if $err;
265             }
266              
267             sub do_cpanm {
268 0     0   0 my ($app, @args) = @_;
269             run_with_server($app, sub {
270 0     0   0 my ($mirror, @args) = @_;
271 0         0 system(cpanm => '--mirror', $mirror, '--mirror-only', @args);
272 0         0 }, @args);
273             }
274              
275             sub do_carton {
276 0     0   0 my ($app, @args) = @_;
277             run_with_server($app, sub {
278 0     0   0 my ($mirror, @args) = @_;
279 0         0 local $ENV{PERL_CARTON_MIRROR} = $mirror;
280 0         0 system(carton => @args);
281 0         0 }, @args);
282             }
283              
284             foreach my $cmd (
285             qw(init fetch add unadd pin unpin merge pull purgelist purge cpanm carton)
286             ) {
287             my $pkg = "App::opan::Command::${cmd}";
288             my $code = __PACKAGE__->can("do_${cmd}");
289             Mojo::Base->import::into($pkg, 'Mojolicious::Command');
290 24     24   597 monkey_patch $pkg, description => sub { pod_section($cmd, 1) };
        24      
        24      
        24      
        24      
        24      
        24      
        24      
        24      
        24      
        24      
        24      
291 12     12   7252 monkey_patch $pkg, usage => sub { pod_section($cmd, 0) };
        12      
        12      
        12      
        12      
        12      
        12      
        12      
        12      
        12      
        12      
        12      
292             monkey_patch $pkg,
293 10     10   77192 run => sub { my $self = shift; $code->($self->app, @_) };
  10     10   74  
        10      
        10      
        10      
        10      
        10      
        10      
        10      
        10      
        10      
        10      
294             }
295              
296 3     3   1602 use Mojolicious::Lite;
  3         674326  
  3         23  
297              
298             Mojo::IOLoop->recurring(600 => sub { do_pull(app); }) if $ENV{OPAN_RECURRING_PULL};
299              
300             post "/upload" => sub {
301             my $c = shift;
302             unless ($TOKENS{$c->req->url->to_abs->password || ""}) {
303             $c->res->headers->www_authenticate("Basic realm=opan");
304             return $c->render(status => 401, text => "Token missing or not found\n");
305             }
306             my $upload = $c->req->upload('dist') || $c->req->upload('pause99_add_uri_httpupload')
307             or return $c->render(status => 400, text => "dist file missing\n");
308             my $pan_dir = path('pans/custom/dists/M/MY/MY')->make_path;
309             $upload->move_to(my $pan_path = $pan_dir->child($upload->filename))
310             or $c->render(
311             status => 500,
312             text => "Failed to move ${upload} into custom pan: $!\n"
313             );
314             add_dist_to_index('pans/custom/index', $pan_path);
315             do_merge(app);
316             $c->render(text => "$pan_path Added to opan\n");
317             };
318              
319             push(@{app->commands->namespaces}, 'App::opan::Command');
320              
321             helper cpan_url => sub { Mojo::URL->new($ENV{OPAN_MIRROR} || 'https://www.cpan.org/') };
322              
323             my $nopin_static = Mojolicious::Static->new(
324             paths => [ 'pans/custom/dists' ]
325             );
326              
327             my $pinset_static = Mojolicious::Static->new(
328             paths => [ 'pans/pinset/dists' ]
329             );
330              
331             my $combined_static = Mojolicious::Static->new(
332             paths => [ 'pans/custom/dists', 'pans/pinset/dists' ]
333             );
334              
335             my $base_static = Mojolicious::Static->new(
336             paths => [ 'pans' ]
337             );
338              
339             foreach my $pan (qw(upstream nopin combined pinset custom)) {
340             get "/${pan}/modules/02packages.details.txt" => sub {
341             $base_static->dispatch($_[0]->stash(path => "${pan}/index"));
342             };
343             get "/${pan}/modules/02packages.details.txt.gz" => sub {
344             $base_static->dispatch($_[0]->stash(path => "${pan}/index.gz"));
345             };
346             }
347              
348             my $serve_upstream = sub {
349             my ($c) = @_;
350             $c->render_later;
351             $c->ua->get(
352             $c->cpan_url.'authors/id/'.$c->stash->{dist_path},
353             sub {
354             my (undef, $tx) = @_;
355             $c->tx->res($tx->res);
356             $c->rendered;
357             }
358             );
359             return;
360             };
361              
362             get '/upstream/authors/id/*dist_path' => $serve_upstream;
363              
364             get '/combined/authors/id/*dist_path' => sub {
365             $_[0]->stash(path => $_[0]->stash->{dist_path});
366             $combined_static->dispatch($_[0]) or $serve_upstream->($_[0]);
367             };
368              
369             get '/nopin/authors/id/*dist_path' => sub {
370             $_[0]->stash(path => $_[0]->stash->{dist_path});
371             $nopin_static->dispatch($_[0]) or $serve_upstream->($_[0]);
372             };
373              
374             get "/autopin/modules/02packages.details.txt" => sub {
375             return $_[0]->render(text => 'Autopin off', status => 404)
376             unless $ENV{OPAN_AUTOPIN};
377             $base_static->dispatch($_[0]->stash(path => "nopin/index"));
378             };
379              
380             get "/autopin/modules/02packages.details.txt.gz" => sub {
381             return $_[0]->render(text => 'Autopin off', status => 404)
382             unless $ENV{OPAN_AUTOPIN};
383             $base_static->dispatch($_[0]->stash(path => "nopin/index.gz"));
384             };
385              
386             get '/autopin/authors/id/*dist_path' => sub {
387             return $_[0]->render(text => 'Autopin off', status => 404)
388             unless $ENV{OPAN_AUTOPIN};
389             return if $nopin_static->dispatch($_[0]->stash(path => $_[0]->stash->{dist_path}));
390             return if eval {
391             do_pin(app, $_[0]->stash->{path});
392             $pinset_static->dispatch($_[0]);
393             };
394             return $_[0]->render(text => 'Not found', status => 404);
395             };
396              
397             caller() ? app : app->tap(sub { shift->log->level('fatal') })->start;
398              
399             =head1 NAME
400              
401             App::opan - A CPAN overlay for darkpan and pinning purposes
402              
403             =head1 SYNOPSIS
404              
405             Set up an opan (creates a directory tree in C):
406              
407             $ opan init
408             $ opan pin MSTROUT/M-1.tar.gz
409             $ opan add ./My-Dist-1.23.tar.gz
410              
411             Now, you can start the server:
412              
413             $ opan daemon -l http://localhost:8030/
414             Server available at http://localhost:8030/
415              
416             Then in another terminal, run one of:
417              
418             $ cpanm --mirror http://localhost:8030/combined/ --mirror-only --installdeps .
419             $ PERL_CARTON_MIRROR=http://localhost:8030/combined/ carton install
420              
421             Or, to let opan do that part for you, skip starting the server and run one of:
422              
423             $ opan cpanm --installdeps .
424             $ opan carton install
425              
426             =head1 DESCRIPTION
427              
428             Two basic approaches to using this thing. First, if you're using carton, you
429             can probably completely ignore the pinning system, so just do:
430              
431             $ opan init
432             $ opan add ./My-DarkPan-Dist-1.23.tar.gz
433             $ git add pans/; git commit -m 'fresh opan'
434             $ opan carton install
435              
436             You can reproduce this install with simply:
437              
438             $ opan carton install --deployment
439              
440             When you want to update to a new version of the cpan index (assuming you
441             already have an additional requirement that's too old in your current
442             snapshot):
443              
444             $ opan pull
445             $ git add pans/; git commit -m 'update pans'
446             $ opan carton install
447              
448             Second, if you're not using carton, but you want reproducible installs, you
449             can still mostly ignore the pinning system by doing:
450              
451             $ opan init
452             $ opan add ./My-DarkPan-Dist-1.23.tar.gz
453             $ opan cpanm --autopin --installdeps .
454             $ git add pans/; git commit -m 'opan with current version pinning'
455              
456             Your reproducible install is now:
457              
458             $ opan cpanm --installdeps .
459              
460             When you want to update to a new version of the cpan index (assuming you
461             already have an additional requirement that's too old in your current
462             snapshot):
463              
464             $ opan pull
465             $ opan cpanm --autopin --installdeps .
466             $ git add pans/; git commit -m 'update pans'
467              
468             To update a single dist in this system, the easy route is:
469              
470             $ opan unpin Thingy-1.23.tar.gz
471             $ opan cpanm Thingy
472             Fetching http://www.cpan.org/authors/id/S/SO/SOMEONE/Thingy-1.25.tar.gz
473             ...
474             $ opan pin SOMEONE/Thing-1.25.tar.gz
475              
476             This will probably make more sense if you read the L and L
477             documentation following before trying to set things up.
478              
479             =head2 Commands
480              
481             =head3 init
482              
483             opan init
484              
485             Creates a C directory with empty indexes for L and L
486             and a fresh index for L (i.e. runs L for you at the end
487             of initialisation).
488              
489             =head3 fetch
490              
491             opan fetch
492              
493             Fetches 02packages from www.cpan.org into the L PAN.
494              
495             =head3 add
496              
497             opan add Dist-Name-1.23.tar.gz
498              
499             Imports a distribution file into the L PAN under author C. Any
500             path parts provided before the filename will be stripped.
501              
502             Support for other authors is pending somebody explaining why that would have
503             a point. See L for the command you probably wanted instead.
504              
505             =head3 unadd
506              
507             opan unadd Dist-Name-1.23.tar.gz
508              
509             Looks for a C path in the L PAN index
510             and removes the entries.
511              
512             Does not remove the dist file, see L.
513              
514             =head3 pin
515              
516             opan pin AUTHOR/Dist-Name-1.23.tar.gz
517              
518             Fetches the file from the L PAN and adds it to L.
519              
520             =head3 unpin
521              
522             opan unpin Dist-Name-1.23.tar.gz
523              
524             Looks for a C path in the L PAN index
525             and removes the entries.
526              
527             Does not remove the dist file, see L.
528              
529             =head3 merge
530              
531             opan merge
532              
533             Rebuilds the L and L PANs' index files.
534              
535             =head3 pull
536              
537             opan pull
538              
539             Does a L and then a L. There's no equivalent for others,
540             on the assumption what you'll do is roughly L, L, L,
541             L, ... repeat ..., L.
542              
543             =head3 purgelist
544              
545             opan purgelist
546              
547             Outputs a list of all non-indexed dists in L and L.
548              
549             =head3 purge
550              
551             opan purge
552              
553             Deletes all files that would have been listed by L.
554              
555             =head3 daemon
556              
557             opan daemon
558              
559             Starts a single process server using L.
560              
561             =head3 prefork
562              
563             opan prefork
564              
565             Starts a multi-process preforking server using
566             L.
567              
568             =head3 get
569              
570             opan get /upstream/modules/02packages.details.txt.gz
571              
572             Runs a request against the opan URL space using L.
573              
574             =head3 cpanm
575              
576             opan cpanm --installdeps .
577              
578             Starts a temporary server process and runs cpanm.
579              
580             cpanm --mirror http://localhost:/combined/ --mirror-only
581              
582             Can also be run with one of:
583              
584             opan cpanm --nopin
585             opan cpanm --autopin
586             opan cpanm --combined
587              
588             to request a specific PAN.
589              
590             =head3 carton
591              
592             opan carton install
593              
594             Starts a temporary server process and runs carton.
595              
596             PERL_CARTON_MIRROR=http://localhost:/combined/ carton
597              
598             Can also be run with one of:
599              
600             opan carton --nopin
601             opan carton --autopin
602             opan carton --combined
603              
604             to request a specific PAN.
605              
606             =head2 PANs
607              
608             =head3 upstream
609              
610             02packages: Fetched from www.cpan.org by the L command.
611              
612             Dist files: Fetched from www.cpan.org on-demand.
613              
614             =head3 pinset
615              
616             02packages: Managed by L and L commands.
617              
618             Dist files: Fetched from www.cpan.org by L command.
619              
620             =head3 custom
621              
622             02packages: Managed by L and L commands.
623              
624             Dist files: Imported from local disk by L command.
625              
626             =head3 combined
627              
628             02packages: Merged from upstream, pinset and custom PANs by L command.
629              
630             Dist files: Fetched from custom, pinset and upstream in that order.
631              
632             =head3 nopin
633              
634             02packages: Merged from upstream and custom PANs by L command.
635              
636             Dist files: Fetched from custom, pinset and upstream in that order.
637              
638             =head3 autopin
639              
640             Virtual PAN with no presence on disk.
641              
642             Identical to nopin, but fetching a dist from upstream does an implict L.
643              
644             Since this can modify your opan config, it's only enabled if the environment
645             variable C is set to a true value (calling the L or
646             L commands with C<--autopin> sets this for you, because you already
647             specified you wanted that).
648              
649             =head2 uploads
650              
651             To enable the /upload endpoint, set the ENV var OPAN_AUTH_TOKENS to a colon
652             separated list of accepted tokens for uploads. This will allow a post with a
653             'file' upload argument, checking http basic auth password against the provided
654             auth tokens.
655              
656             =head2 recurring pull
657              
658             Set ENV OPAN_RECURRING_PULL to a true value to make opan automatically pull
659             from upstream every 600 seconds
660              
661             =head2 custom upstream
662              
663             Set the ENV var OPAN_MIRROR to specify a cpan mirror - the default is
664             www.cpan.org. Remember that if you need to temporarily overlay your overlay
665             but only for one user, there's nothing stopping you setting OPAN_MIRROR to
666             another opan.
667              
668             =head1 AUTHOR
669              
670             Matt S. Trout (mst)
671              
672             =head1 CONTRIBUTORS
673              
674             Aaron Crane (arc)
675              
676             Marcus Ramburg (marcus)
677              
678             =head1 COPYRIGHT
679              
680             Copyright (c) 2016-2018 the L L and L
681             as listed above.
682              
683             =head1 LICENSE
684              
685             This library is free software and may be distributed under the same terms
686             as perl itself.
687              
688             =cut