Skip to content

Instantly share code, notes, and snippets.

@moreati
Created September 25, 2024 18:53
Show Gist options
  • Save moreati/8d37001063e90181788c389b0a92fd51 to your computer and use it in GitHub Desktop.
Save moreati/8d37001063e90181788c389b0a92fd51 to your computer and use it in GitHub Desktop.
tracerealpath - Like traceroute, but for symlinks
#!/usr/bin/env python3
# Copyright (c) 2001 - 2023 Python Software Foundation
# Copyright (c) 2024 Alex Willmer <[email protected]>
# License: Python Software Foundation License Version 2
# Derived from https://github.com/python/cpython/blob/v3.12.6/Lib/posixpath.py#L423-L492
"""
Show the route taken through symlinks to a resolved file path
"""
import os
import stat
def trace_realpath(filename:os.PathLike, *, strict:bool=False) -> tuple[os.fspath, list[os.PathLike]]:
"""
Return the canonical path of the specified filename, and a trace of
the route taken, eliminating any symbolic links encountered in the path.
"""
filename = os.fspath(filename)
path, trace, ok = _join_tracepath(filename[:0], filename, strict, seen={}, trace=[])
return os.path.abspath(path), trace
def _join_tracepath(
path: str,
rest: str,
strict: bool,
seen: dict[str, str|None],
trace: list[str],
) -> tuple[str, list[str], bool]:
"""
Join two paths, normalizing and eliminating any symbolic links encountered
in the second path.
"""
trace.append(rest)
if isinstance(path, bytes):
sep = b'/'
curdir = b'.'
pardir = b'..'
else:
sep = '/'
curdir = '.'
pardir = '..'
if os.path.isabs(rest):
rest = rest[1:]
path = sep
while rest:
name, _, rest = rest.partition(sep)
if not name or name == curdir:
# current dir
continue
if name == pardir:
# parent dir
if path:
path, name = os.path.split(path)
if name == pardir:
path = os.path.join(path, pardir, pardir)
else:
path = pardir
continue
newpath = os.path.join(path, name)
try:
st = os.lstat(newpath)
except OSError:
if strict:
raise
is_link = False
else:
is_link = stat.S_ISLNK(st.st_mode)
if not is_link:
path = newpath
continue
# Resolve the symbolic link
if newpath in seen:
# Already seen this path
path = seen[newpath]
if path is not None:
# use cached value
continue
# The symlink is not resolved, so we must have a symlink loop.
if strict:
# Raise OSError(errno.ELOOP)
os.stat(newpath)
else:
# Return already resolved part + rest of the path unchanged.
return os.path.join(newpath, rest), trace, False
seen[newpath] = None # not resolved symlink
path, trace, ok = _join_tracepath(path, os.readlink(newpath), strict, seen, trace)
if not ok:
return os.path.join(path, rest), False
seen[newpath] = path # resolved symlink
return path, trace, True
__all__ = [
trace_realpath.__name__,
]
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
'path', nargs='?', default='.',
help="Path to resolve (default: current working directory)",
)
args = parser.parse_args()
path, trace = trace_realpath(args.path)
print(f"{len(trace)} hops to {path}")
for hop, path in enumerate(trace, start=1):
print(hop, os.path.abspath(path), sep=' ')
@moreati
Copy link
Author

moreati commented Sep 25, 2024

Almost entirely untested, except for a few simple cases.

$ ls -l                               
total 8
lrwxr-xr-x  1 alex  staff  5 Sep 25 19:13 alpha -> bravo
lrwxr-xr-x  1 alex  staff  7 Sep 25 19:13 bravo -> charlie
lrwxr-xr-x  1 alex  staff  5 Sep 25 19:13 charlie -> delta
lrwxr-xr-x  1 alex  staff  4 Sep 25 19:13 delta -> echo
lrwxr-xr-x  1 alex  staff  7 Sep 25 19:13 echo -> foxtrot
-rw-r--r--  1 alex  staff  1 Sep 25 19:13 foxtrot
$ tracerealpath.py alpha
6 hops to /Users/alex/tmp/loads-a-links/foxtrot
1  /Users/alex/tmp/loads-a-links/alpha
2  /Users/alex/tmp/loads-a-links/bravo
3  /Users/alex/tmp/loads-a-links/charlie
4  /Users/alex/tmp/loads-a-links/delta
5  /Users/alex/tmp/loads-a-links/echo
6  /Users/alex/tmp/loads-a-links/foxtrot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment