Find Broken Symlinks with Python: Separate Missing Targets, Permission Errors, and Loops
A link still appears in a directory listing, but Python raises FileNotFoundError: moving data or removing an old release can leave a link pointing at its former target. This tutorial builds a read-only standard-library tool that lists dangling links and distinguishes missing targets from checks that could not finish.
It targets beginner-to-intermediate readers who can run terminal commands. If the failure happens only in a scheduled job, also check Linux cron paths and working directories to confirm that the job is inspecting the same directory.
1. Method: inspect the link, then query its target
- Walk real subdirectories with
os.walk(), setfollowlinks=Falseto avoid descending into directory symlinks, and collect directory listing errors throughonerror. - Use
Path.lstat()to identify the entry itself as a symlink, then obtain its stored target string withos.readlink(). - Query the target with
Path.stat(), which follows links. Classify a missing target path, or a file where a directory component is required, as broken. Record permission failures and link loops separately.
A relative target starts from the link’s parent directory, regardless of the script’s working directory. An existing external target is valid too, but the tool will not descend into an external directory through its symlink. The Python large-file ranking tool skips symlinks; this tool checks their targets instead.
This verifies target resolution, without validating contents, application formats, or readability. Use a quiet local project directory: the scan is not an atomic snapshot and cannot prevent directories from being replaced during traversal. Mount points inside real subdirectories may still be traversed.
2. Prepare the environment and repeatable sample
Use Linux, Python 3.9 or newer, and bash. No third-party packages are required. These commands were verified on Linux with Python 3.11.4.
- Create a separate directory in one bash session. Choose another directory name when repeating the exercise:
python3 --version
mkdir symlink-audit-demo
cd symlink-audit-demo
python3 - <<'PY_DATA'
from pathlib import Path
root = Path("sample")
(root / "nested").mkdir(parents=True)
(root / "data.txt").write_text("ready\n", encoding="utf-8")
(root / "good-file").symlink_to("data.txt")
(root / "good-dir").symlink_to("nested", target_is_directory=True)
(root / "missing-file").symlink_to("gone.txt")
(root / "missing-dir").symlink_to("gone-dir", target_is_directory=True)
PY_DATA
- Save the next section as
check_symlinks.pyin the currentsymlink-audit-demodirectory, or download the complete script. The sample has four links: two valid targets, one missing file, and one missing directory.
3. Complete implementation: JSON and exit codes
"""Read-only symlink audit for a quiet local directory. Python 3.9+."""
import argparse
import json
import os
from pathlib import Path
import stat
def audit(root):
broken = []
errors = []
symlinks = 0
def record_error(path, exc):
errors.append({"path": str(path), "errno": exc.errno,
"message": str(exc)})
def walk_error(exc):
record_error(exc.filename or root, exc)
for folder, dirs, names in os.walk(root, onerror=walk_error,
followlinks=False):
dirs.sort()
for name in sorted(dirs + names):
path = Path(folder) / name
try:
mode = path.lstat().st_mode
if not stat.S_ISLNK(mode):
continue
symlinks += 1
target = os.readlink(path)
except OSError as exc:
record_error(path, exc)
continue
try:
path.stat()
except (FileNotFoundError, NotADirectoryError):
broken.append({"path": str(path.relative_to(root)),
"target": target})
except OSError as exc:
record_error(path, exc)
return {"root": str(root), "symlinks": symlinks,
"broken": sorted(broken, key=lambda item: item["path"]),
"errors": sorted(errors, key=lambda item: item["path"]),
"complete": not errors}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("directory", type=Path, help="directory to inspect")
args = parser.parse_args(argv)
try:
root = args.directory.resolve(strict=True)
if not stat.S_ISDIR(root.stat().st_mode):
parser.error("directory must refer to a directory")
except (OSError, RuntimeError) as exc:
parser.error(str(exc))
report = audit(root)
print(json.dumps(report, indent=2, sort_keys=True))
if report["errors"]:
return 2
return 1 if report["broken"] else 0
if __name__ == "__main__":
raise SystemExit(main())
The root resolves to its physical path first, so the supplied root itself may be a valid directory symlink. Inspecting both dirs and names includes directory links. Broken-link paths are relative to the root; error paths retain their absolute location. JSON escapes special characters, so spaces or newlines in names do not become extra records.
| Exit code | Meaning | Next action |
|---|---|---|
0 |
Scan completed with no broken links | Continue the next task. |
1 |
Scan completed and found broken links | Review and restore targets, or review link paths separately. |
2 |
Argument, root, or scanning error | Read stderr or the report’s errors, fix the cause, and rerun. |
Scanning errors take precedence and return 2, even when broken links were also found. complete: true means no scanning errors were recorded; broken links may still exist. Invalid arguments or roots produce stderr diagnostics without JSON. All broken links and errors remain in memory, making this suitable for troubleshooting project directories.
4. Validate missing targets and recovery
- Run the tool and capture its exit status. Save the report outside the inspected directory:
python3 check_symlinks.py --help
if python3 check_symlinks.py sample > report.json; then
scan_rc=0
else
scan_rc=$?
fi
printf 'exit=%s\n' "$scan_rc"
python3 - <<'PY_CHECK'
import json
from pathlib import Path
report = json.loads(Path("report.json").read_text())
assert report["symlinks"] == 4
assert report["complete"] is True
assert report["errors"] == []
assert report["broken"] == [
{"path": "missing-dir", "target": "gone-dir"},
{"path": "missing-file", "target": "gone.txt"},
]
print(f"symlinks={report['symlinks']} broken={len(report['broken'])} complete={report['complete']}")
PY_CHECK
The output should end with:
exit=1
symlinks=4 broken=2 complete=True
- Restore the two missing targets in this demo directory and rerun:
printf 'restored\n' > sample/gone.txt
mkdir sample/gone-dir
if python3 check_symlinks.py sample > repaired.json; then
scan_rc=0
else
scan_rc=$?
fi
printf 'exit=%s\n' "$scan_rc"
Expect exit=0, with empty broken and errors lists in repaired.json. The links themselves were neither removed nor recreated. In a real project, establish whether a target should be restored first; creating empty files merely to clear a warning can hide a problem.
5. Validate link loops, then integrate with automation
- Add a pair of looping links to the sample after restoring its missing targets:
ln -s loop-b sample/loop-a
ln -s loop-a sample/loop-b
if python3 check_symlinks.py sample > loop-report.json; then
scan_rc=0
else
scan_rc=$?
fi
printf 'exit=%s\n' "$scan_rc"
python3 - <<'PY_LOOP'
import errno
import json
from pathlib import Path
report = json.loads(Path("loop-report.json").read_text())
assert report["complete"] is False
assert report["broken"] == []
assert len(report["errors"]) == 2
assert all(item["errno"] == errno.ELOOP for item in report["errors"])
print("loop errors=2 complete=False")
PY_LOOP
Expect exit=2 and loop errors=2 complete=False. A resolution loop must not look like a directory with no broken links. Additional checks covered empty directories, invalid roots, relative and absolute targets, Unicode names, a non-directory target component, and a real PermissionError in an unprivileged child process.
- For your own batch job, first prepare a writable report directory and replace the interpreter, script, data, and report paths below with your actual absolute paths. Do not add these example paths to a scheduler unchanged:
if /usr/bin/python3 /srv/tools/check_symlinks.py /srv/project/data > /srv/reports/symlinks.json; then
scan_rc=0
else
scan_rc=$?
fi
printf 'symlink audit exit=%s\n' "$scan_rc"
exit "$scan_rc"
This wrapper returns the tool’s status to its caller. The if also captures expected nonzero results when set -e is enabled. Redirection overwrites an earlier report; use a different report name for each run if you need history.
6. Troubleshooting and direct fixes
| Symptom | Action |
|---|---|
| Moving a target leaves a broken link | Interpret target from the link’s parent directory. Restore the correct target or rebuild the link in a separate maintenance step, then rerun. |
errors contains Permission denied |
Recheck directory read permission and search permission on each path component as the actual job account. Adjust permissions to the project’s needs; do not classify this as a missing target. |
errors contains Too many levels of symbolic links |
Inspect the link chain, fix the loop or excessive chain length, and scan again. |
| The target exists but the application cannot read it | This tool does not test content access. Check target permissions and format as the account running the application. |
| Broken links inside a directory symlink are absent | Only that directory link’s target is checked. Run the tool again with the target directory as the root to inspect its contents. |
7. Summary
Separating a link from its target and handling broken links and scan errors through exit codes makes missing-file problems repeatable to diagnose. The script only reads metadata and never deletes or repairs links automatically. To check whether ordinary data files have changed, continue with the SHA-256 dataset manifest tutorial.
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/en/python-broken-symlinks.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
相关文章
- Fix Python SQLite database is locked: Timeouts and Short Transactions
- Check Scheduled Output Freshness with Python: File Age, Empty Files, and Exit Codes
- Find Large Files with Python: Rank a Directory with os.walk and heapq
- Python Too Many Open Files: Find File Handle Leaks on Linux
- Stream gzip Logs with Python and Detect Truncation and CRC Errors