Perl Part II · Module 17 · Engineering Files and Directory Automation
Anchoring a Script to the Project Root
A flow script is called from at least three places: by an engineer standing in a run directory, by a Makefile rule from the project root, and by a job scheduler from a working directory nobody chose. If the script resolves its paths relative to wherever it happens to be, it does three different things — and the one that breaks is always the one nobody tested, which is the scheduler. This chapter measures that failure by running one script from three directories and comparing what it can see about itself, then gives the four-line recipe that makes the behaviour identical in all of them. It is the first chapter of Part II because every tool that follows depends on it.
Intermediate12 min readPerlPart IIProject Structure
Part II · Module 17 · Chapter 17.1 · Anchoring a Script to the Project Root
1. The Same Script, Three Directories
use strict;
use warnings;
use File::Temp qw(tempdir);
use File::Spec;
use File::Path qw(make_path);
# a small project tree: bin/, rtl/, and a sibling directory to stand in
my $root = File::Spec->catdir(tempdir(CLEANUP => 1), "proj");
make_path(File::Spec->catdir($root, $_)) for qw(bin rtl elsewhere);
my $probe = File::Spec->catfile($root, "bin", "show.pl");
open my $w, ">", $probe or die "cannot write: $!\n";
print $w <<'PROBE';
use strict;
use warnings;
use Cwd qw(getcwd abs_path);
use FindBin;
use File::Basename qw(dirname);
# every path is masked back to <proj> so the output is reproducible
my $mask = sub { my $p = shift // "undef"; $p =~ s{.*/proj}{<proj>}; return $p };
printf " cwd = %s\n", $mask->(getcwd());
printf " \$0 = %s\n", $mask->($0);
printf " dirname(\$0) = %s\n", $mask->(dirname($0));
printf " abs_path(\$0) = %s\n", $mask->(abs_path($0));
printf " FindBin::Bin = %s\n", $mask->($FindBin::Bin);
printf " abs_path(FindBin::Bin/'..') = %s\n", $mask->(abs_path("$FindBin::Bin/.."));
PROBE
close $w;
my @how = (
[ "from the project root", $root, "perl bin/show.pl" ],
[ "from a sibling, relative", File::Spec->catdir($root, "elsewhere"), "perl ../bin/show.pl" ],
[ "from /, absolute path", File::Spec->rootdir(), "perl '$probe'" ],
);
for my $h (@how) {
my ($label, $where, $cmd) = @$h;
# the label must not leak the temp path, or the output is not reproducible
(my $shown = $cmd) =~ s{\Q$root\E}{<proj>}g;
print "$label ($shown)\n";
print `cd '$where' && $cmd 2>&1`;
print "\n";
}from the project root (perl bin/show.pl)
cwd = <proj>
$0 = bin/show.pl
dirname($0) = bin
abs_path($0) = <proj>/bin/show.pl
FindBin::Bin = <proj>/bin
abs_path(FindBin::Bin/'..') = <proj>
from a sibling, relative (perl ../bin/show.pl)
cwd = <proj>/elsewhere
$0 = ../bin/show.pl
dirname($0) = ../bin
abs_path($0) = <proj>/bin/show.pl
FindBin::Bin = <proj>/bin
abs_path(FindBin::Bin/'..') = <proj>
from /, absolute path (perl '<proj>/bin/show.pl')
cwd = /
$0 = <proj>/bin/show.pl
dirname($0) = <proj>/bin
abs_path($0) = <proj>/bin/show.pl
FindBin::Bin = <proj>/bin
abs_path(FindBin::Bin/'..') = <proj>Read down the three blocks and compare each row against itself.
cwd differs, obviously — that is what we changed.
$0 differs in all three. It is whatever was typed: bin/show.pl, then ../bin/show.pl, then the full absolute path. Perl does not normalise it.
dirname($0) differs in all three — bin, ../bin, and an absolute directory. This is the one that catches people, because dirname($0) . "/../rtl" looks like it anchors to the project and does not.
FindBin::Bin is identical in all three. Always the directory the script lives in, as an absolute path.
abs_path($0) is also identical, and so is abs_path("$FindBin::Bin/.."), which is the project root.
So the rule is: the script's own location is stable; the caller's location is not. Anchor to the former.
2. What Is and Is Not Stable
A short table of what you can rely on:
| Thing | Stable across callers? | What it is |
|---|---|---|
getcwd() | no | where the caller happened to be |
$0 | no | the text used to invoke the script |
dirname($0) | no | a directory relative to the caller |
$FindBin::Bin | yes | the script's directory, absolute |
$FindBin::Script | yes | the script's filename |
abs_path($0) | yes | the script's full path |
$ENV{PWD} | no | the shell's idea of cwd, and may be stale |
Two notes on the table.
$ENV{PWD} is worse than getcwd(), not better. It is set by the shell and is not updated by a chdir inside your script, so it can disagree with reality. Chapter 15.5 covered reading the environment; this is one of the variables not to trust.
FindBin is a core module, so it costs nothing to use — Chapter 12.9's list. It works out the script's directory at compile time from $0, the current directory and PATH.
3. The Recipe
Four lines at the top of every flow script:
use FindBin;
use Cwd qw(abs_path);
use File::Spec;
my $ROOT = abs_path("$FindBin::Bin/.."); # bin/ is one level under the rootThen every path in the script is built from $ROOT:
my $rtl = File::Spec->catdir($ROOT, "rtl");
my $runs = File::Spec->catdir($ROOT, "runs");
my $filelist= File::Spec->catfile($ROOT, "rtl", "files.f");Three things that recipe is doing:
abs_path collapses the .. so the root is a clean absolute path rather than /proj/bin/... That matters when the path appears in a log or a generated filelist that somebody has to read.
File::Spec->catdir and catfile join the parts instead of interpolating slashes. On Unix the difference is invisible; the habit is worth having because it also handles a trailing slash in $ROOT correctly.
Nothing reads the current directory. The script works identically from anywhere.
4. The Cases That Break It
Four situations where anchoring needs more thought.
The script is a symbolic link. FindBin::Bin resolves to the directory of the link, not the target, which is usually what you want for a shared bin linked into several projects — each project's link gives that project's root. If you want the target instead, FindBin offers $FindBin::RealBin. Knowing which you want is the whole decision.
abs_path resolves symlinks, and that can surprise you. On this machine /var is a link to /private/var, so a path under /var comes back rewritten. The consequence is practical: never compare a resolved path against an unresolved one. If you are going to compare, resolve both sides — and Chapter 17.6 depends on exactly this, because a cleanup check that compares a resolved target against an unresolved root will reject paths it should accept.
perl -e has no script. $FindBin::Bin falls back to the current directory, because there is no file to locate. One-liners cannot anchor, which is one more reason Chapter 15.6 said to promote a one-liner to a file as soon as it matters.
A Makefile rule that changes directory. cd runs/$@ && $(PERL) ../../bin/run.pl leaves the script anchored correctly — that is the point — but anything the rule does with relative paths is now relative to the run directory. Mixing the two is where confusion comes from. The cleaner pattern is to pass the directory in and let the script chdir itself:
my $rundir = $opt{rundir} or die usage();
chdir $rundir or die "cannot chdir to $rundir: $!\n";Note the or die with $! on the chdir. An unchecked chdir that fails leaves the script running in the wrong directory and doing real work there, which is Chapter 13.5's unchecked-operation problem with a much larger blast radius.
5. What To Remember
- A flow script is called from at least three places: a shell, a Makefile, and a scheduler.
- Measured:
$0anddirname($0)differed in all three runs;FindBin::Binandabs_path($0)were identical in all three. dirname($0) . "/../rtl"looks like anchoring and is not.- The script's own location is stable; the caller's is not. Anchor to the former.
- Do not trust
$ENV{PWD}— the shell sets it and achdirdoes not update it. FindBinis core, so using it costs nothing.- The recipe is four lines:
use FindBin,use Cwd qw(abs_path),use File::Spec, thenmy $ROOT = abs_path("$FindBin::Bin/.."). - Build every path from
$ROOTwithcatdirandcatfile, and read the current directory never. - Allow an override with
$ENV{PROJ_ROOT} //—//not||— and validate it with-dbefore use. - A symlinked script gives the link's directory;
$FindBin::RealBingives the target's. Choose deliberately. abs_pathresolves symlinks, so never compare a resolved path against an unresolved one — 17.6 depends on this.perl -ecannot anchor — there is no script to locate.- Check every
chdirwithor die "...: $!", or the script does real work in the wrong place. - Do not assume
bin/is one level below the root. Pass the depth with a comment, or walk upward looking for a marker file. - A marker search needs a termination check — at
/,..stops changing.
Chapter 17.2 names the directories a script will be anchoring to. A real project has a small number of recognisable tree shapes, and knowing them is what lets one script work on several projects without a hard-coded path for each.
Where this fits
Part of the Perl curriculum.
