File Coverage

blib/lib/YAML/LoadBundle.pm
Criterion Covered Total %
statement 195 220 88.6
branch 87 122 71.3
condition 7 12 58.3
subroutine 24 24 100.0
pod 4 4 100.0
total 317 382 82.9


line stmt bran cond sub pod time code
1             package YAML::LoadBundle;
2             # ABSTRACT: Load a directory of YAML files as a bundle
3 4     4   234167 use version;
  4         6611  
  4         36  
4             our $VERSION = 'v0.4.3'; # VERSION
5              
6 4     4   400 use base qw(Exporter);
  4         8  
  4         463  
7 4     4   22 use warnings;
  4         5  
  4         98  
8 4     4   21 use strict;
  4         9  
  4         77  
9              
10 4     4   16 use Carp;
  4         16  
  4         282  
11 4     4   21 use Cwd qw( abs_path );
  4         8  
  4         163  
12 4     4   1669 use Digest::SHA1 qw( sha1_hex sha1 );
  4         2283  
  4         230  
13 4     4   27 use File::Find qw( find );
  4         7  
  4         267  
14 4     4   1613 use Hash::Merge::Simple ();
  4         1668  
  4         93  
15 4     4   23 use Scalar::Util qw( reftype refaddr );
  4         7  
  4         250  
16 4     4   2142 use Storable qw( freeze dclone );
  4         10961  
  4         265  
17 4     4   1207 use YAML::XS qw(Load);
  4         7068  
  4         9824  
18              
19             our @EXPORT_OK = qw(
20             load_yaml
21             load_yaml_bundle
22             add_yaml_observer
23             remove_yaml_observer
24             );
25             our %EXPORT_TAGS = ( all => [@EXPORT_OK] );
26              
27             our $CacheDir;
28             $CacheDir = $ENV{YAML_LOADBUNDLE_CACHEDIR} unless defined $CacheDir;
29              
30             my @load_yaml_observers;
31              
32             sub add_yaml_observer {
33 1     1 1 597 my $observer = shift;
34 1 50       4 die "Observer must be a code ref." unless ref($observer) eq 'CODE';
35 1         4 push @load_yaml_observers, $observer;
36             }
37              
38             sub _notify_yaml_observers {
39 5     5   1465 my $file = shift;
40 5         21 for my $observer (@load_yaml_observers) {
41 1         4 $observer->($file);
42             }
43             }
44              
45             sub remove_yaml_observer {
46 1     1 1 1236 my $observer = shift;
47 1 50       4 die "Observer must be a code ref." unless ref($observer) eq 'CODE';
48 1         5 my $obref = refaddr $observer;
49              
50             @load_yaml_observers = grep {
51 1         3 refaddr($_) != $obref
  1         6  
52             } @load_yaml_observers;
53             }
54              
55             our %seen;
56              
57             sub load_yaml {
58 29     29 1 3154883 my ($arg, $dont_cache) = @_;
59 29         78 my @yaml;
60             my $cache_mtime;
61 29         0 my %params;
62              
63             # We clone references that appear more than once in the data
64             # structure. (For compatibility with Data::Visitor.)
65 29         71 local %seen = ();
66              
67 29 100 33     289 if (ref $arg) {
    100          
    50          
68 2         35 @yaml = <$arg>;
69             }
70             elsif ($arg =~ /\n/) {
71 22         20050 my $digest = sha1($arg);
72 22         46 $cache_mtime = 1;
73 22         62 my $perl = _yaml_cache_peek($digest, $cache_mtime);
74 22 100       40856 return $perl if defined $perl;
75 3     3   27 open my $fh, '<', \$arg;
  3         4  
  3         19  
  18         275  
76 18         51700 @yaml = <$fh>;
77 18         453 $arg = $digest;
78 18         92 $params{no_disk_cache} = 1;
79             }
80             elsif (-f $arg and -s _) {
81             # $arg is a file path.
82 5         26 _notify_yaml_observers($arg);
83              
84 5         23 my $mtime = (stat _)[9];
85 5         19 my $perl = _yaml_cache_peek($arg, $mtime);
86 5 100       42 return $perl if defined $perl;
87              
88 4 50       192 open my $fh, $arg
89             or croak "Can't open YAML file $arg: $!";
90 4         17789 @yaml = <$fh>;
91 4         64 $cache_mtime = $mtime;
92             }
93             else {
94 0         0 croak "Can't load empty/missing YAML file: $arg.";
95             }
96              
97 24         44 my $perl;
98 24         36 eval { $perl = Load(join '', @yaml) };
  24         229323  
99              
100 24 100       10431 die "$@\nYAML File: $arg\n" if $@;
101              
102             # Can't cache/flatten empty YAML
103 23 50       68 return unless $perl;
104              
105             # TODO: this is a temporary fix. previous functionaly skipped caching if a
106             # second arg was passed into load_yaml. a recent refactor introduced a bug
107             # that caused the code to never cache. as a temporary workaround we will
108             # just set $cache_mtime to 0 if there's a second arg to this method. this
109             # will tell _unravel_and_cache to skip the caching step.
110 23 100       54 $cache_mtime = 0 if $dont_cache;
111 23         100 $perl = _unravel_and_cache($arg, $perl, $cache_mtime, %params);
112              
113 23         10593 return $perl;
114             }
115              
116             my $shallow_merge = sub {
117             my ($left, $right) = @_;
118             if (reftype($left) eq 'ARRAY') {
119             $left = { map %$_, @$left };
120             }
121             return (
122             (map { %$_ } (reftype($left) eq 'ARRAY' ? @$left : $left)),
123             %$right
124             );
125             };
126             my $deep_merge = sub {
127             my ($left, $right) = @_;
128             return %{ Hash::Merge::Simple->merge(
129             (reftype($left) eq 'ARRAY' ? @$left : $left),
130             $right,
131             ) };
132             };
133              
134             my $clone_merge = sub {
135              
136             my ($left, $right) = @_;
137              
138             if (reftype($left) eq 'HASH' && reftype($right) eq 'HASH')
139             {
140             my %seen;
141              
142             foreach my $key (keys %$left)
143             {
144             my $clone = $left->{$key};
145              
146             die "-clone of $key is not a hash\n"
147             unless reftype($clone) eq "HASH";
148              
149             my $list = delete $clone->{$key};
150              
151             die "-clone of $key is missing a list of keys under the same key\n"
152             unless $list && reftype($list) eq 'ARRAY';
153              
154             foreach my $cloned_key (@$list)
155             {
156             die "-clone key $cloned_key must appear only once.\n"
157             if $seen{$cloned_key}++;
158              
159             $right->{$cloned_key} = $clone;
160             }
161             }
162              
163             return %$right;
164             }
165              
166             die "-clone must appear in a hash, and contain a hash.\n";
167             };
168              
169             # in order of priority:
170             my @SPECIAL = qw(
171             -merge
172             export
173             -export
174             import
175             -import
176             -clone
177             );
178             my %SPECIAL = (
179             -import => $shallow_merge,
180             import => $shallow_merge,
181             -export => $shallow_merge,
182             export => $shallow_merge,
183             -merge => $deep_merge,
184             -clone => $clone_merge,
185             );
186              
187             # Note: This used to add a (heavy) dependency on Data::Visitor
188             # to do these simple transformations. I *think* this is exactly
189             # equivalent to what it used to do.
190              
191             sub _unravel {
192 12565     12565   15345 my $data = shift;
193              
194 12565 100       18278 if (ref $data) {
195 12495 100       38752 $data = dclone($data) if $seen{$data}++;
196              
197 12495 100       25440 if (reftype $data eq 'HASH') {
    50          
198 12460         16164 return _unravel_hash($data);
199             }
200             elsif (reftype $data eq 'ARRAY') {
201 35         46 for my $elt (@$data) {
202 75         105 $elt = _unravel($elt);
203             }
204 35         66 return $data;
205             }
206             }
207              
208 70         109 return $data;
209             }
210              
211             # Note: this modifies the argument in place. But sometimes it returns
212             # a different reference, in order to replace itself in the enclosing
213             # data structure. (If it encounters a "-flatten" entry.)
214              
215             sub _unravel_hash {
216 12460     12460   13613 my $data = shift;
217            
218 12460         16491 while (my @keys = grep { exists $data->{$_} } @SPECIAL) {
  74880         116136  
219             # Make sure that deeper -merges and such will be handled first
220 20         49 for my $key ( grep { ! $SPECIAL{ $_ } } keys %$data ) {
  36         82  
221             # False values can be skipped for performance
222 14 50       34 next unless $data->{$key};
223 14         26 $data->{$key} = _unravel($data->{$key})
224             }
225              
226 20         40 for my $key (@keys) {
227 22         34 my $handler = $SPECIAL{$key};
228 22         35 my $val = delete $data->{$key};
229 22 50       43 next unless $val;
230 22         37 %$data = $handler->(_unravel($val), $data);
231             }
232             }
233              
234 12460 100       27049 if (keys %$data == 1) {
235 38 100       103 if (my $arrs = $data->{-flatten}) {
    100          
236 3         5 _unravel($arrs);
237 3         17 return [ map @$_, @$arrs ];
238             }
239             elsif (my $hrefs = $data->{-flattenhash}) {
240 1         4 _unravel($hrefs);
241 1         9 return { map %$_, @$hrefs };
242             }
243             }
244              
245 12456         23307 for my $elt (values %$data) {
246 123684 100       181327 $elt = _unravel($elt) if ref($elt);
247             }
248              
249 12456 100       442779 $data = dclone($data) if $seen{$data}++;
250 12456         98333 return $data;
251             }
252              
253              
254             {
255             my %YAML_cache;
256             sub _unravel_and_cache {
257 25     25   85 my ($path, $perl, $cache_mtime, %params) = @_;
258              
259 25         76 _unravel($perl);
260              
261             # TODO: need a better way to explicitly not cache here
262 25 100       64 if ($cache_mtime) {
263 22         101 my $frozen = Storable::freeze($perl);
264 22         50225 $YAML_cache{$path} = [ $cache_mtime, $frozen ];
265 22 100 100     99 if ($CacheDir and not $params{no_disk_cache}) {
266 2         57 my $cache_file = join "/", $CacheDir, sha1_hex($path);
267              
268 2         6 eval { mkdir $CacheDir };
  2         131  
269 2 50       11 if ($@) {
270 0         0 warn "Can't write yaml cache: $@";
271             }
272             else {
273 2 50       169 open my $fh, '>', $cache_file or die "Cannot open $cache_file for writing $!";
274 2         119 print $fh $frozen;
275             }
276             }
277             }
278              
279 25         61 return $perl;
280             }
281              
282             sub _yaml_cache_peek {
283 29     29   91 my ($path, $mtime) = @_;
284              
285 29         65 my $cache = $YAML_cache{$path};
286 29 100       100 if ($cache) {
    100          
287 5         16 my ($oldtime, $oldyaml) = @$cache;
288 5 50       35 return Storable::thaw($oldyaml) if $oldtime == $mtime;
289             }
290             elsif ($CacheDir) {
291 7         51 my $cache_file = join "/", $CacheDir, sha1_hex($path);
292 7 50       142 if (-f $cache_file) {
293 0         0 my $cache_time = (stat $cache_file)[9];
294 0 0       0 if ($cache_time >= $mtime) {
295 0         0 open my $fh, "<$cache_file";
296 0         0 my $file_contents = do { local $/; <$fh> };
  0         0  
  0         0  
297 0         0 my $thawed = Storable::thaw($file_contents);
298 0         0 $YAML_cache{$path} = [ $mtime, $file_contents ];
299 0 0       0 return $thawed if $cache_time >= $mtime;
300             }
301             else {
302 0         0 unlink $cache_file;
303             }
304             }
305             }
306              
307 24         55 return;
308             }
309             }
310              
311             {
312             my %default_options = (
313             follow_symlinks_when => 'bundled',
314             follow_symlinks_fail => 'error',
315             conf_suffixes => [ 'conf', 'yml' ],
316             max_depth => 20,
317             );
318              
319             my %symlink_skipper = (
320             error => sub { croak "Symlink $_[1] was skipped.\nYAML Bundle: $_[0]\n" },
321             warn => sub { carp "Symlink $_[1] was skipped.\nYAML Bundle: $_[0]\n" },
322             ignore => sub { },
323             );
324              
325             sub _merge_bundle {
326 16     16   45 my ($current, $nested) = @_;
327              
328 16 50       38 if (ref($nested) eq 'ARRAY') {
329 0 0       0 $current = [] unless defined $current;
330 0         0 return +{ $deep_merge->($current, $nested) };
331             }
332             else {
333 16 100       37 $current = {} unless defined $current;
334 16         32 return +{ $deep_merge->($current, $nested) };
335             }
336             }
337              
338             sub load_yaml_bundle {
339 18     18 1 127 my ($path, $given_options) = @_;
340 18         21 my $cache_mtime;
341              
342             # Setup the default configuration
343             my %options = (
344 18 100       22 %{ $given_options || {} },
  18         150  
345             %default_options,
346             );
347              
348             # Add _vars to the options to allow recursive calls to share state.
349 4         16 $options{_match_suffix} = join "|", map { quotemeta } @{ $options{conf_suffixes} }
  2         7  
350 18 100       51 unless defined $options{_match_suffix};
351 18         29 $options{max_depth}--;
352              
353             # Calculate the absolute base path to start from
354 18 100       32 unless (defined $options{_original_path}) {
355 2         143 $options{_original_path} = abs_path($path);
356 2         10 $options{_original_path_length} = length $options{_original_path};
357              
358             # This is the top call, so check the cache
359 2         4 my $this_mtime;
360 2         4 $cache_mtime = 0;
361             find({
362             follow_fast => 1,
363             wanted => sub {
364 22 100   22   1226 if (/^.*\.(?:$options{_match_suffix})\z/s) {
365 10         25 $this_mtime = (lstat _)[9];
366 10 100       332 $cache_mtime = $this_mtime if $this_mtime > $cache_mtime;
367             }
368             },
369             },
370             $options{_original_path},
371 4         415 grep { -f $_ }
372 4         14 map { "$options{_original_path}.$_" }
373 2         20 @{ $options{conf_suffixes} }
  2         7  
374             );
375 2         20 my $perl = _yaml_cache_peek($path, $cache_mtime);
376              
377 2 50       10 return $perl if defined $perl;
378             }
379              
380 18         35 my $symlink_skipper = $symlink_skipper{ $options{follow_symlinks_fail} };
381              
382             # Stop, we've gone too far.
383 18 50       35 if ($options{max_depth} < 0) {
384 0         0 carp "Reached maximum path search depth while at $path.\nYAML Bundle: $options{_original_path}\n";
385 0         0 return;
386             }
387              
388 18         22 my $perl;
389              
390             # Do we have a top level .conf/.yml/.whatever in the bundle?
391 18         21 for my $suffix (@{ $options{conf_suffixes} }) {
  18         35  
392 36         90 my $file = $path . '.' . $suffix;
393 36 100 66     503 if (-f $file and -s _) {
394              
395             # If $perl is already defined, we have a case where multiple
396             # configuration files are present, which is not a defined case.
397 10 50       41 carp "Multiple configuration files match $path. This will lead to unexpected results.\nYAML Bundle: $options{_original_path}\n"
398             if defined $perl;
399              
400             # We don't use load_yml because we don't want the intermediate
401             # pieces cached and it does a lot of work we'd repeat anyway.
402              
403 10 50       320 open my $fh, $file
404             or croak "Can't open YAML file $file: $!";
405 10         30 my $yaml = do { local $/; <$fh> };
  10         38  
  10         250  
406              
407 10         22 $perl = eval { Load($yaml) };
  10         420  
408 10 50       133 if ($@) {
409 0         0 croak "Eror in file $file: $@\nYAML Bundle: $options{_original_path}\n";
410             }
411             }
412             }
413              
414             # if no file found, we have to start somewhere
415 18 100       60 $perl = {} unless defined $perl;
416              
417             # If this is a directory, let's suck in all the nested configs
418 18 100       223 if (-d $path) {
419 12 50       303 opendir my $dir_fh, $path or croak "Cannot opendir $path: $!";
420              
421             # Saves us from duplicating work while recursing...
422 12         60 my %closed_list;
423              
424 12         156 ENTRY: while (my $entry = readdir $dir_fh) {
425              
426             # Ignore all dot files
427 44 100       727 next if $entry =~ m{^[.]};
428              
429 20         1184 my $nested_path = abs_path("$path/$entry");
430              
431 20 50       66 if (not defined $nested_path) {
432 0         0 croak "Broken symlink or other problem while locating $path/$entry.\nYAML Bundle: $options{_original_path}\n";
433             }
434              
435             # If bundled, make sure this abs path is in the root bas path
436 20 50       71 if ($options{follow_symlinks_when} eq 'bundled') {
    0          
437 20 50       64 unless (substr($nested_path, 0, $options{_original_path_length}) eq $options{_original_path}) {
438 0         0 $symlink_skipper->($options{_original_path}, "$path/$entry");
439 0         0 next ENTRY;
440             }
441             }
442              
443             # If never, skip any symlink
444             elsif ($options{follow_symlinks_when} eq 'never') {
445 0 0       0 if (-l "$path/$entry") {
446 0         0 $symlink_skipper->($options{_original_path}, "$path/$entry");
447 0         0 next ENTRY;
448             }
449             }
450              
451             # Is this a directory? If so, load that as a bundle.
452 20 100 33     464 if (-d $nested_path) {
    50          
453 10 100       45 next ENTRY if $closed_list{$nested_path};
454              
455             # We don't follow symlinks to directories. This is a naive way
456             # to prevent infinite recursion.
457 6 50       74 if (-l "$path/$entry") {
458 0         0 croak "Symlink to directory $path/$entry is not permitted.\nYAML Bundle: $options{_original_path}\n";
459             }
460              
461             # Load the nested bundle and merge.
462 6         23 $closed_list{$nested_path}++;
463             $perl->{ $entry } = _merge_bundle(
464 6         61 $perl->{ $entry },
465             load_yaml_bundle($nested_path, \%options)
466             );
467             }
468              
469             # Is this a file with the right suffix?
470             elsif (-f $nested_path and $entry =~ s/[.](?:$options{_match_suffix})$//) {
471 10         27 my $nested_path_minus_suffix = $nested_path;
472 10         79 $nested_path_minus_suffix =~ s/[.](?:$options{_match_suffix})$//;
473 10 50       29 next ENTRY if $closed_list{$nested_path_minus_suffix};
474              
475             # Load the nested bundle and merge.
476 10         30 $closed_list{$nested_path_minus_suffix}++;
477             $perl->{ $entry } = _merge_bundle(
478 10         76 $perl->{ $entry },
479             load_yaml_bundle($nested_path_minus_suffix, \%options)
480             );
481             }
482              
483             # What the hey? Carp about this...
484             else {
485 0         0 carp "Ignoring unexpected path $nested_path of unknown type.\nYAML Bundle: $options{_original_path}\n";
486             }
487             }
488             }
489              
490             # Only unravel our format layer and cache the top
491             # $cache_mtime is only set in the call _original_path is set
492 18 100       307 if ($cache_mtime) {
493 2         11 $perl = _unravel_and_cache($options{_original_path}, $perl, $cache_mtime);
494             }
495              
496 18         84 return $perl;
497             }
498             }
499              
500             1;
501              
502             __END__