春江暮客

春江暮客的个人学习分享网站

用 Python 清理旧日志:先预览、再删除,跳过链接与子目录

2026-10-28 技术
用 Python 清理旧日志:先预览、再删除,跳过链接与子目录

日志归档目录越积越大,直接写一条删除命令,又担心把正在使用的日志或软链接目标删掉。本文面向初学者到中级读者,用 Python 标准库做一个默认只预览、显式 --apply 才删除的小工具,先在隔离样例上验证,再用于自己管理的归档目录。

这里清理的是已写完、已确认不再使用的独立 .log 文件,不是活动日志轮转。如果还不知道哪些文件占空间,先看 Python 大文件排行;不要把教程命令直接指向业务日志目录。

1. 方法:明确范围,再计算保留期限

  1. 只扫描指定目录的直接子项,不递归;只匹配区分大小写的 .log 后缀,跳过隐藏名称、目录、软链接和多硬链接文件。
  2. 启动时固定一次当前时间,以 1 天 = 86400 秒计算截止点;只有 mtime 严格早于截止点的文件才入选。它不是“早于某个本地日历日期”,也不是按创建时间判断。
  3. 先完成扫描,再预览或执行。--days 必填,范围 1–36500;没有零天清理或默认删除。
  4. 删除前重新读取元数据,发现文件身份或状态变化就停止;执行成功的每个文件分别记录,最后输出汇总。

Python os.scandir 文档介绍了目录项扫描;entry.stat(follow_symlinks=False)读取链接本身的信息,代码用普通文件类型和链接数继续筛选。扫描只读元数据,不加载日志正文,但候选清单会占用与入选数量相关的内存。

**使用边界:**目录及其父目录必须可信,扫描和删除期间停止写入、轮转、移动和替换文件的任务。二次检查不是原子操作,仍存在检查与删除之间的竞态,不能用于其他用户可随意修改的目录。删除不可撤销;真实使用前先完成独立备份,预览报告不是备份。

2. 准备:创建可丢弃的样例

需要已有的 Python 3.11 或更新版本,无第三方依赖。示例在 Linux、Python 3.11.4 上验证,命令适用于 bash 和 zsh。新建目录后,后续操作保持在同一个终端、同一个目录;重复实验时换一个目录名。

  1. 创建 9 天前的日志、新日志、普通文本、软链接和一个名称以 .log 结尾的目录:
python3 --version
mkdir old-logs-demo
cd old-logs-demo
mkdir logs
python3 - <<'PY_DATA'
import os
from pathlib import Path
import time
now = time.time_ns()
old = now - 9 * 86400 * 1_000_000_000
for name in ("old.log", "fresh.log", "keep.txt"):
    path = Path("logs") / name
    path.write_bytes(b"old\n")
    stamp = now if name == "fresh.log" else old
    os.utime(path, ns=(stamp, stamp))
Path("logs/link.log").symlink_to("keep.txt")
Path("logs/broken.log").symlink_to("absent")
Path("logs/nested.log").mkdir()
PY_DATA
  1. 将下一节保存为 cleanup_logs.py,也可下载 完整清理脚本。不要在当前目录里创建叫 stat.py、json.py 或 time.py 的文件,以免遮蔽标准库。

3. 实现:预览与执行使用同一套筛选规则

"""Preview or delete old completed .log files in one trusted directory."""
import argparse
import json
import os
from pathlib import Path
import stat
import sys
import time

DAY_NS = 86400 * 1_000_000_000


def positive_days(text):
    try:
        value = int(text)
    except ValueError:
        raise argparse.ArgumentTypeError("days must be an integer") from None
    if not 1 <= value <= 36500:
        raise argparse.ArgumentTypeError("days must be 1..36500")
    return value


def signature(info):
    return (info.st_dev, info.st_ino, info.st_size,
            info.st_mtime_ns, info.st_ctime_ns, info.st_nlink)


def select_logs(root, days, now_ns):
    cutoff = now_ns - days * DAY_NS
    selected = []
    with os.scandir(root) as entries:
        for entry in entries:
            if entry.name.startswith(".") or not entry.name.endswith(".log"):
                continue
            info = entry.stat(follow_symlinks=False)
            if (stat.S_ISREG(info.st_mode) and info.st_nlink == 1
                    and info.st_mtime_ns < cutoff):
                selected.append((root / entry.name, info))
    return sorted(selected, key=lambda item: item[0].name)


def emit(record):
    print(json.dumps(record, ensure_ascii=True), flush=True)


def apply_one(path, previous):
    current = path.lstat()
    if (not stat.S_ISREG(current.st_mode)
            or signature(current) != signature(previous)):
        raise ValueError(f"changed since scan: {path.name!a}")
    path.unlink()


def main():
    parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
    parser.add_argument("directory", type=Path)
    parser.add_argument("--days", required=True, type=positive_days)
    parser.add_argument("--apply", action="store_true",
                        help="delete selected files; default only previews")
    args = parser.parse_args()
    deleted = 0
    try:
        root = args.directory.expanduser().resolve(strict=True)
        if not root.is_dir():
            raise ValueError("directory must be a directory")
        selected = select_logs(root, args.days, time.time_ns())
        for path, info in selected:
            if args.apply:
                apply_one(path, info)
                deleted += 1
            emit({"action": "deleted" if args.apply else "would_delete",
                  "name": path.name, "bytes": info.st_size})
        emit({"mode": "apply" if args.apply else "preview",
              "selected": len(selected), "deleted": deleted})
        return 0
    except (OSError, ValueError) as exc:
        print(f"ERROR: deleted={deleted}; {str(exc)!a}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())

time.time_ns()提供整数形式的系统时间,适合与文件修改时间比较;它不是单调时钟,仍需保证主机时钟正确。未来时间戳不会入选,不能靠这个工具修复时钟偏差。

Path.unlink()移除文件名,不会把文件送进回收站。本工具会在扫描出错时停止且不开始删除;执行阶段若中途失败,之前已经删除的文件不会自动恢复。

4. 验证:预览没有修改原文件

  1. 显示帮助,把预览报告写到 logs/ 外面,再断言内容:
python3 cleanup_logs.py --help
python3 cleanup_logs.py logs --days 7 > preview.jsonl
cat preview.jsonl
python3 - <<'PY_CHECK'
import json
from pathlib import Path
rows = [json.loads(line) for line in Path("preview.jsonl").read_text().splitlines()]
assert rows == [
    {"action": "would_delete", "name": "old.log", "bytes": 4},
    {"mode": "preview", "selected": 1, "deleted": 0},
]
assert Path("logs/old.log").read_bytes() == b"old\n"
print("OK: preview changed no log files")
PY_CHECK

cat preview.jsonl 应输出:

{"action": "would_delete", "name": "old.log", "bytes": 4}
{"mode": "preview", "selected": 1, "deleted": 0}

最后的断言输出 OK: preview changed no log files。bytes 是文件的逻辑长度,不是承诺可释放的磁盘空间;复制保留的旧时间戳也会影响筛选,执行前要核对文件用途。

  1. 仔细检查名称与目录后,只对这个可丢弃样例启用删除:
python3 cleanup_logs.py logs --days 7 --apply > applied.jsonl
cat applied.jsonl
python3 - <<'PY_CHECK'
import json
from pathlib import Path
rows = [json.loads(line) for line in Path("applied.jsonl").read_text().splitlines()]
assert rows == [
    {"action": "deleted", "name": "old.log", "bytes": 4},
    {"mode": "apply", "selected": 1, "deleted": 1},
]
assert not Path("logs/old.log").exists()
for name in ("fresh.log", "keep.txt"):
    assert (Path("logs") / name).read_bytes() == b"old\n"
assert Path("logs/link.log").is_symlink()
assert Path("logs/broken.log").is_symlink()
assert Path("logs/nested.log").is_dir()
print("OK: only old.log was deleted")
PY_CHECK
python3 cleanup_logs.py logs --days 7 --apply

验证脚本输出 OK: only old.log was deleted。最后一次重复运行应只输出:

{"mode": "apply", "selected": 0, "deleted": 0}

5. 自动化检查:退出码与边界测试

退出码 0 表示本次扫描及所选操作完成,空清单也是成功;1 表示文件系统或状态检查失败;2 表示参数错误。执行失败时,stderr 中的 deleted=N 表示本次已经完成的删除数量,不表示事务回滚。

先把预览作为检查步骤,不要把“成功生成报告”直接当成“允许删除”:

if python3 cleanup_logs.py logs --days 7 > next-preview.jsonl; then
    printf 'Preview complete; review next-preview.jsonl before any deletion.\n'
else
    printf 'Preview failed; do not continue.\n' >&2
fi

下载 隔离测试脚本,和 cleanup_logs.py 放在同一目录,运行:

python3 test_cleanup.py

测试只创建和清理自己的临时目录。7 项测试覆盖预览、执行、重复执行、截止点相等、未来时间、隐藏文件、大小写后缀、目录、软链接、硬链接、参数错误、文件变化、部分删除后失败和模拟权限失败;同时断言退出码、输出与保留文件内容。模拟失败不等于验证过每种真实文件系统权限组合。

6. 排错:别靠放宽规则解决问题

现象 原因与处理
预览为零 检查是否为直接子项、后缀是否恰好为 .log,以及最后修改时间;.log.1 和 .log.gz 本来就不匹配。
changed since scan 立即停止;找到写入或轮转任务并暂停,然后重新预览,不要绕过二次检查。
Permission denied 核对目录所有者及父目录搜索、目标目录写入权限;不要直接改成 sudo 删除。
删除后磁盘空间没明显增加 检查打开的文件描述符、其他硬链接及文件系统计量,参考 Linux 删除后空间不释放排查。
想限制活动日志增长 改用 Python 日志轮转,不要用修改时间猜测文件是否已经关闭。

7. 总结

把清理范围、时间规则和执行开关写清楚,先在独立样例里证明预览不改文件、执行只处理入选文件。真实归档目录必须先备份并停止并发修改,再逐项审查预览。这个工具不提供回收站、事务恢复或活动日志轮转。

标签

1024 12306 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency configuration copy cprofile cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 evaluation faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow jailbreak javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance phaser pillow pip postfix preprocessing print prompt-engineering protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml tracemalloc tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode urllib usdt utf-8 uv vhhbert vite webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它