File Coverage

blib/lib/CPAN/InGit/ArchiveTree.pm
Criterion Covered Total %
statement 257 311 82.6
branch 63 130 48.4
condition 17 55 30.9
subroutine 28 29 96.5
pod 14 15 93.3
total 379 540 70.1


line stmt bran cond sub pod time code
1             package CPAN::InGit::ArchiveTree;
2             our $VERSION = '0.003'; # VERSION
3             # ABSTRACT: An object managing a CPAN file structure in a Git Tree
4              
5              
6 5     5   3589 use Carp;
  5         21  
  5         432  
7 5     5   57 use Scalar::Util 'refaddr', 'blessed';
  5         11  
  5         320  
8 5     5   47 use POSIX 'strftime';
  5         15  
  5         65  
9 5     5   491 use IO::Uncompress::Gunzip qw( gunzip $GunzipError );
  5         11  
  5         914  
10 5     5   37 use JSON::PP;
  5         11  
  5         435  
11 5     5   44 use Time::Piece;
  5         13  
  5         58  
12 5     5   470 use Log::Any '$log';
  5         9  
  5         49  
13 5     5   1511 use Moo;
  5         11  
  5         28  
14 5     5   2531 use v5.36;
  5         19  
15              
16             extends 'CPAN::InGit::MutableTree';
17              
18              
19 6     6 0 68 sub BUILD($self, $args, @) {
  6         11  
  6         10  
  6         8  
20 6 100       20 $self->load_config if $self->config_blob;
21 6 100       156 $self->name($self->branch? $self->branch->shorthand : '(anonymous)')
    50          
22             unless defined $self->name;
23             }
24              
25             has name => ( is => 'rw' );
26             has config => ( is => 'rw' );
27              
28 12     12 1 18 sub config_blob($self) {
  12         17  
  12         14  
29 12 100       45 my $ent= $self->get_path('cpan_ingit.json')
30             or return undef;
31 10 50       160 return $ent->[0]->is_blob? $ent->[0] : undef;
32             }
33              
34              
35 5     5 1 100 sub load_config($self) {
  5         11  
  5         7  
36 5 50       15 my $cfg_blob= $self->config_blob
37             or die "Missing '/cpan_ingit.json'";
38 5         69 my $attrs= JSON::PP->new->utf8->relaxed->decode($cfg_blob->content);
39 5 50       4178 ref $attrs eq 'HASH' or croak "Configuration file does not contain an object?".$cfg_blob->content;
40 5         17 $self->{config}= $attrs;
41 5         24 $self->_unpack_config($self->{config});
42 5         44 $attrs;
43             }
44              
45 5     5   10 sub _unpack_config($self, $config) {
  5         10  
  5         8  
  5         7  
46 5         16 for (qw( default_import_sources corelist_perl_version canonical_url )) {
47 15 100       73 $self->$_($config->{$_}) if defined $config->{$_};
48             }
49             }
50              
51 1     1   2 sub _pack_config($self, $config) {
  1         1  
  1         1  
  1         2  
52 1         2 for (qw( default_import_sources corelist_perl_version canonical_url )) {
53 3         8 my $val= $self->$_;
54 3 50       5 $val= "$val" if ref $val eq 'version';
55 3         6 $config->{$_}= $val;
56             }
57             }
58              
59 1     1 1 2 sub write_config($self) {
  1         1  
  1         2  
60 1   50     7 my $config= $self->config // {};
61 1         4 $self->_pack_config($config);
62 1         11 my $json= JSON::PP->new->utf8->canonical->pretty->encode($config);
63 1 50 33     323 $self->set_path('cpan_ingit.json', \$json)
64             unless $self->config_blob && $self->config_blob->content eq $json;
65 1         4 $self;
66             }
67              
68              
69             has canonical_url => ( is => 'rw' );
70             has default_import_sources => ( is => 'rw' );
71             has corelist_perl_version => ( is => 'rw', default => '5.008009' );
72              
73              
74 8     8 1 42 sub package_details_blob($self) {
  8         14  
  8         13  
75 8 100       33 my $ent= $self->get_path('modules/02packages.details.txt')
76             or return undef;
77 7 50       78 return $ent->[0]->is_blob? $ent->[0] : undef;
78             }
79              
80             has package_details => ( is => 'rw', lazy => 1, builder => 1, clearer => 1 );
81 3     3   4080 sub _build_package_details($self) {
  3         7  
  3         5  
82 3         10 $self->parse_package_details($self->package_details_blob->content);
83             }
84              
85              
86 3     3 1 6 sub parse_package_details($self, $content) {
  3         7  
  3         6  
  3         3  
87 3         7 my %attrs;
88 3         32 while ($content =~ /\G([^:\n]+):\s+(.*)\n/gc) {
89 24         122 $attrs{$1}= $2;
90             }
91 3 50       15 $content =~ /\G\n/gc or croak "missing blank line after headers";
92 3         8 my %by_mod;
93             my %by_dist;
94 3         15 while ($content =~ /\G(\S+)\s+(\S+)\s+(\S+)\n/gc) {
95 18 100       81 my $row= [ $1, ($2 eq 'undef'? undef : $2), $3 ];
96 18         49 $by_mod{$1}= $row;
97 18         26 push @{$by_dist{$3}}, $row;
  18         125  
98             }
99 3 50       14 pos $content == length $content
100             or croak "Parse error at '".substr($content, pos($content), 10)."'";
101 3 50       43 my $timestamp = $attrs{'Last-Updated'}? Time::Piece->strptime($attrs{'Last-Updated'}, "%a, %d %b %Y %H:%M:%S GMT")
102             : undef; # TODO: fall back to date from branch commit
103             return {
104 3         311 last_update => $timestamp,
105             by_module => \%by_mod,
106             by_dist => \%by_dist,
107             };
108             }
109              
110              
111 3     3 1 6 sub write_package_details($self) {
  3         7  
  3         5  
112 3   50     19 my $url= $self->canonical_url // 'cpan_mirror_ingit.local';
113             # on initial creation, need to write an empty package_details without triggering
114             # lazy-build of package_details
115             my @mod_list= !$self->package_details_blob? ()
116 3 100       11 : values %{$self->package_details->{by_module}};
  2         62  
117 3         21 my $line_count= @mod_list;
118 3         15 my $date= strftime("%a, %d %b %Y %H:%M:%S GMT", gmtime);
119 3         256 my $content= <<~END;
120             File: 02packages.details.txt
121             URL: $url
122             Description: Package names found in directory \$CPAN/authors/id/
123             Columns: package name, version, path
124             Intended-For: Automated fetch routines, namespace documentation.
125             Written-By: PAUSE version 1.005
126             Line-Count: $line_count
127             Last-Updated: $date
128              
129             END
130             # List can be huge, so try to be efficient about stringifying it
131 3         10 @mod_list= sort { fc $a->[0] cmp fc $b->[0] } @mod_list;
  1         5  
132 3         6 my @lines;
133 3         7 for (@mod_list) {
134 3   100     29 push @lines, sprintf("%s %s %s\n", $_->[0], $_->[1] // 'undef', $_->[2]);
135             }
136 3         27 $self->set_path('modules/02packages.details.txt', \join('', $content, @lines));
137             }
138              
139              
140 7     7 1 905 sub has_module($self, $mod_name, $reqs=undef) {
  7         8  
  7         8  
  7         8  
  7         8  
141 7         13 my $mod_ver= $self->get_module_version($mod_name);
142 7 100 100     24 if (defined $mod_ver && defined $reqs) {
143 4 50       23 $reqs= CPAN::Meta::Requirements->from_string_hash({ $mod_name => $reqs })
144             unless ref $reqs;
145 4         484 return !!$reqs->accepts_module($mod_name, $mod_ver);
146             }
147 3         7 return defined $mod_ver;
148             }
149              
150 14     14 1 16977 sub get_module_version($self, $mod_name) {
  14         22  
  14         18  
  14         16  
151 14 100       369 if (my $current= $self->package_details->{by_module}{$mod_name}) {
    50          
152 10         59 my $mod_ver= $current->[1];
153             # grab the version out of the package filename?
154 10 100       22 if (!defined $mod_ver) {
155 5 50       33 $mod_ver= $current->[2] =~ /-([0-9]+(?:\.[0-9_]+?)*)\./? $1
156             : 0; # return 0 to differentiate from undef=nonexisting
157             }
158 10         23 return $mod_ver;
159             } elsif ($mod_name eq 'perl') {
160 0         0 return $self->corelist_perl_version;
161             } else {
162 4         49 return undef;
163             }
164             }
165              
166 2     2 1 6 sub get_module_dist($self, $mod_name) {
  2         5  
  2         6  
  2         3  
167 2         67 my $by_name= $self->package_details->{by_module}{$mod_name};
168 2 50       23 return $by_name? $by_name->[2] : undef;
169             }
170              
171              
172 4     4 1 8 sub meta_path_for_dist($self, $author_path) {
  4         7  
  4         8  
  4         6  
173             # replace archive extension with '.meta.json'
174 4         36 $author_path =~ s/\.(zip|tar\.gz|tgz|tar\.bz2|tbz2)\z//;
175 4         14 return "authors/id/$author_path.meta";
176             }
177              
178              
179 2     2 1 5 sub import_dist($self, $peer, $author_path, %options) {
  2         4  
  2         4  
  2         4  
  2         4  
  2         4  
180 2         10 my $dist_path= "authors/id/$author_path";
181 2 50       12 my $distfile_ent= $peer->get_path($dist_path)
182             or croak "Import source branch '".$peer->name."' does not contain $dist_path";
183 2         23 $log->info("Importing $author_path from ".$peer->name." to ".$self->name);
184 2         164 my $existing_ent= $self->get_path($dist_path);
185             # If exists, must be same gitobj as before or this is an error
186 2 50       9 if ($existing_ent) {
187 0 0       0 croak "$dist_path already exists with different content"
188             unless $existing_ent->[0]->id eq $distfile_ent->[0]->id;
189             }
190 2         13 $self->set_path($dist_path, $distfile_ent->[0], mode => $distfile_ent->[1]);
191 2         93 my $modules_registered= $peer->package_details->{by_dist}{$author_path};
192 2 50       23 if ($modules_registered) {
193 2         50 $self->package_details->{by_dist}{$author_path}= [ @$modules_registered ];
194             $self->package_details->{by_module}{$_->[0]}= $_
195 2         56 for @$modules_registered;
196 2         21 $self->write_package_details;
197             }
198 2         11 my $meta_path= $self->meta_path_for_dist($author_path);
199 2         8 my $meta_ent= $peer->get_path($meta_path);
200 2 100       10 if ($meta_ent) {
201 1         8 $self->set_path($meta_path, $meta_ent->[0], mode => $meta_ent->[1]);
202             } else {
203             # TODO: parse module for META.json and dependnecies
204 1         9 $log->warn("No META for $author_path");
205             }
206 2         95 return $self;
207             }
208              
209              
210 2     2 1 4 sub get_dist_meta($self, $author_path, %options) {
  2         4  
  2         4  
  2         4  
  2         4  
211 2         7 my $meta_path= $self->meta_path_for_dist($author_path);
212 2         9 my $meta_ent= $self->get_path($meta_path);
213 2 100       21 return CPAN::Meta->load_string($meta_ent->[0]->content)
214             if $meta_ent;
215             # TODO: process the tar file to generate the meta
216             }
217              
218              
219 3     3   10 sub _filter_prereqs($self, $reqs, $corelist={}, $log_prefix='') {
  3         10  
  3         6  
  3         7  
  3         9  
  3         5  
220 3         12 for my $mod (sort $reqs->required_modules) {
221 2         27 my $req_version= $reqs->requirements_for_module($mod);
222 2         150 my $have_ver= $self->get_module_version($mod);
223             # Is this requirement already in the tree?
224 2 50 33     23 if (defined $have_ver && $reqs->accepts_module($mod, $have_ver)) {
    50 33        
225 0 0       0 $log->debugf($log_prefix.'(requirement %s %s already satisfied by %s from %s)',
    0          
226             $mod, $req_version, $have_ver,
227             ($mod eq 'perl'? 'corelist_perl_version' : $self->get_module_dist($mod)))
228             if $log->is_info;
229 0         0 $reqs->clear_requirement($mod);
230             }
231             # Is the requirement satisfied by a core perl module in the version of perl
232             # the app will be running under?
233             elsif (defined $corelist->{$mod} && $reqs->accepts_module($mod, $corelist->{$mod})) {
234 0         0 $log->debugf($log_prefix.'(requirement %s %s satisfied by corelist)', $mod, $req_version);
235 0         0 $reqs->clear_requirement($mod);
236             }
237             }
238 3         15 return $reqs;
239             }
240              
241             # merges new requirements into existing, and returns a list of anything that changed
242 1     1   2 sub _merge_prereqs($self, $reqs, $new_reqs) {
  1         1  
  1         2  
  1         2  
  1         1  
243 1         4 my $before= $reqs->as_string_hash;
244 1         42 $reqs->add_requirements($new_reqs);
245 1         68 my $after= $reqs->as_string_hash;
246 1         78 my @changed;
247 1         3 for my $mod (sort $new_reqs->required_modules) {
248 1 50 50     20 if (($before->{$mod} // '') ne ($after->{$mod} // 0)) {
      50        
249 1         5 push @changed, $mod;
250 1 50       11 $log->infof(' requires %s%s', $mod, $after->{$mod}? " $after->{$mod}" : '');
251             }
252             }
253 1         128 return @changed;
254             }
255              
256 1     1 1 1100 sub import_modules($self, $reqs, %options) {
  1         3  
  1         2  
  1         3  
  1         1  
257 1         1 my %imported_dists;
258              
259             # Build list of source trees
260 1   33     9 my $sources= $options{sources} // $self->default_import_sources;
261 1 50 33     4 $sources && @$sources
262             or croak "No import sources specified";
263             # coerce every source name to an ArchiveTree object
264 1         2 my @autocommit;
265 1         2 for (@$sources) {
266 1 50 33     4 unless (ref $_ and $_->can('package_details')) {
267 1 50       5 my $t= $self->parent->get_archive_tree($_)
268             or croak "No such archive tree $_";
269             # If we've created new objects for MirrorTree and the MirrorTree has autofetch
270             # enabled, then we also need to commit those changes before returning.
271 1 50 33     12 push @autocommit, $t if $t->can('autofetch') && $t->autofetch;
272 1         3 $_= $t;
273             }
274             }
275              
276             # Coerce the argument to a Requirements object
277 1         8 require CPAN::Meta::Requirements;
278 1         3 my $prereq_phases= [qw( configure build runtime test )];
279 1         2 my $prereq_types= [qw( requires )];
280 1         3 my $log_recommends= !grep $_ eq 'recommends', @$prereq_types;
281 1         8 my $recommended= CPAN::Meta::Requirements->new;
282             # coerce the requirements into a CPAN::Meta::Requirements object
283 1 0 0     22 $reqs= ref $reqs eq 'HASH'? CPAN::Meta::Requirements->from_string_hash($reqs)
    0 0        
    50          
284             : blessed($reqs) && $reqs->isa('CPAN::Meta::Requirements')? $reqs
285             : blessed($reqs) && $reqs->isa('CPAN::Meta::Prereqs')? $reqs->merged_requirements($prereq_phases, $prereq_types)
286             : croak "Expected CPAN::Meta::Requirements object, ::Prereqs object, or HASH ref";
287              
288             # Determine what module versions were available for the app's version of perl.
289 1         4172 require Module::CoreList;
290 1   33     200109 my $perl_v= $options{corelist_perl_version} // $self->corelist_perl_version;
291 1         17 $perl_v= version->parse($perl_v)->numify;
292 1 50       8 my $corelist= Module::CoreList::find_version($perl_v)
293             or carp "No corelist for $perl_v";
294              
295             # Filter out the prereqs we already have, or which are in the corelist
296 1         31 $log->tracef('todo reqs: %s', $reqs->as_string_hash);
297 1         135 $self->_filter_prereqs($reqs, $corelist);
298 1         5 my @initial_list= $reqs->required_modules;
299 1         10 my @todo= @initial_list;
300 1         4 while (@todo) {
301 2         35 my $mod= shift @todo;
302 2         10 my $req_version= $reqs->requirements_for_module($mod);
303 2         103 $log->infof('Add %s %s', $mod, $req_version);
304             # Walk through the list of import sources looking for a version that works
305 2         1883 my ($author_path, $prereqs);
306 2         124 for my $peer (@$sources) {
307 2         241 my $peer_ver= $peer->get_module_version($mod);
308 2 50       15 if (!defined $peer_ver) {
    50          
309 0         0 $log->debugf(' branch %s does not have module %s', $peer->name, $mod);
310             }
311             elsif (!$reqs->accepts_module($mod, $peer_ver)) {
312 0         0 $log->debugf(' branch %s module %s version %s does not match %s', $peer->name, $mod, $peer_ver, $req_version);
313             }
314             else {
315 2         86 $log->debugf(' branch %s has %s %s, matching %s', $peer->name, $mod, $peer_ver, $req_version);
316 2         39 $author_path= $peer->get_module_dist($mod);
317 2         11 $self->import_dist($peer, $author_path);
318 2         10 my $meta= $self->get_dist_meta($author_path);
319 2 100       13414 $prereqs= $meta->effective_prereqs if $meta;
320 2         385 $imported_dists{$author_path}= $peer;
321 2         17 last;
322             }
323             }
324 2 50       9 croak("No import_sources branch had module $mod with version $req_version")
325             unless length $author_path;
326             # Push things into the TODO list if they aren't already in %$reqs or if they have a higher
327             # version requirement.
328 2 100       13 if ($prereqs) {
329 1         6 my $dist_reqs= $prereqs->merged_requirements($prereq_phases, $prereq_types);
330 1         391 $log->infof('Dist %s:', $author_path);
331 1         147 my $n= $#todo;
332 1         9 push @todo, $self->_merge_prereqs($reqs, $self->_filter_prereqs($dist_reqs, $corelist, ' '));
333 1 50       24 $log->infof(' (no additional reqs)') if $#todo == $n;
334             # Collect recommendations
335 1 50       4 if ($log_recommends) {
336 1         7 my $dist_recommends= $prereqs->merged_requirements(['runtime'], ['recommends']);
337 1         152 $self->_filter_prereqs($dist_recommends, $corelist);
338 1         4 my @list= sort $dist_recommends->required_modules;
339 1 50       8 $log->noticef('Dist %s recommends %s', $mod, [ sort @list ])
340             if @list;
341 1         5 $recommended->add_requirements($dist_recommends);
342             }
343             }
344             }
345 1 50       4 if ($log_recommends) {
346 1 50       6 if (my @list= sort $recommended->required_modules) {
347 0         0 $log->notice('Full list of recommended modules:');
348             $log->noticef(' %s %s', $_, $recommended->requirements_for_module($_))
349 0         0 for @list;
350             }
351             }
352             # If any sources are 'autofetch' and caller didn't supply the MirrorTree object,
353             # commit the changes before returning.
354 1         13 for my $mirror (grep $_->has_changes, @autocommit) {
355 0         0 my $message= join "\n",
356             'Auto-commit packages fetched for branch '.$self->name,
357             '',
358             'For $archive_tree->import_modules:',
359             map(" - $_ ".$reqs->requirements_for_module($_), @initial_list),
360             '';
361 0         0 $mirror->commit($message);
362             }
363 1         15 return \%imported_dists;
364             }
365              
366              
367 0     0 1   sub import_cpanfile_snapshot($self, $snapshot_spec, %options) {
  0            
  0            
  0            
  0            
368 0           my %imported_dists;
369              
370 0   0       my $sources= $options{sources} // $self->default_import_sources;
371 0 0 0       $sources && @$sources
372             or croak "No import sources specified";
373             # coerce every source name to an ArchiveTree object
374 0           my @autocommit;
375 0           for (@$sources) {
376 0 0 0       unless (ref $_ and $_->can('package_details')) {
377 0 0         my $t= $self->parent->get_archive_tree($_)
378             or croak "No such archive tree $_";
379             # If we've created new objects for MirrorTree and the MirrorTree has autofetch
380             # enabled, then we also need to commit those changes before returning.
381 0 0 0       push @autocommit, $t if $t->can('autofetch') && $t->autofetch;
382 0           $_= $t;
383             }
384             }
385              
386 0           dist: for my $dist_name (sort keys %$snapshot_spec) {
387 0           my $dist_info= $snapshot_spec->{$dist_name};
388             # Locate 'pathname'
389 0           my $author_path= $dist_info->{pathname};
390 0 0         unless ($author_path) {
391 0           my $msg= "Dist $dist_name lacks 'pathname' attribute";
392 0 0         $options{partial}? $log->notice($msg) : croak $msg;
393 0           next;
394             }
395             # Which source has this file?
396 0           for my $source (@$sources) {
397 0           $log->debugf("check %s for %s", $source->name, $author_path);
398 0 0         my $distfile_ent= $source->get_path("authors/id/$author_path")
399             or next;
400 0           $self->import_dist($source, $author_path);
401 0           $imported_dists{$author_path}= $source;
402             # Update index with the modules provided by this distribution if it wasn't imported
403             # from $source by import_dist.
404 0 0         if (!$source->package_details->{by_dist}{$author_path}) {
405             # Fall back to the 'provides' from the cpanfile.snapshot
406 0 0         if (ref $dist_info->{provides} eq 'HASH') {
407             my @mod_index= map [ $_, $dist_info->{provides}{$_}, $author_path ],
408 0           keys %{$dist_info->{provides}};
  0            
409 0           $self->package_details->{by_dist}{$author_path}= \@mod_index;
410             $self->package_details->{by_module}{$_->[0]}= $_
411 0           for @mod_index;
412 0           $self->write_package_details;
413             } else {
414 0           my $msg= "Snapshot lacks 'provides' for $dist_name, and not indexed in ".$source->name." either";
415 0 0         $options{partial}? $log->notice($msg) : croak $msg;
416             }
417             }
418 0           next dist;
419             }
420 0           my $msg= "No source contains file $author_path";
421 0 0         $options{partial}? $log->notice($msg) : croak $msg;
422             }
423             # If any sources are 'autofetch' and caller didn't supply the MirrorTree object,
424             # commit the changes before returning.
425 0           for my $mirror (grep $_->has_changes, @autocommit) {
426 0           my $message= join "\n",
427             'Auto-commit packages fetched for branch '.$self->name,
428             '',
429             'For $archive_tree->import_cpanfile_snapshot',
430             '';
431 0           $mirror->commit($message);
432             }
433 0           return \%imported_dists;
434             }
435              
436             1;
437              
438             __END__