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