JavaLYG

Python脚本手动正常、定时运行却找不到文件:用pathlib明确路径基准

同一份 Python 脚本,在项目目录手动运行正常,交给定时任务或服务却报 FileNotFoundError;更隐蔽的是不报错,却读到了另一份同名配置。本文用普通磁盘脚本说明当前工作目录与脚本目录的区别,给出路径探针、完整读取代码和可复制的隔离实验。

一、先查三个位置,不要只盯文件名

报错中的 config.json 往往只是相对路径。它没有携带“项目目录”的约定,读取时要依赖进程当前工作目录,也就是 cwd。启动脚本的 Shell、任务执行器或服务配置可能给出不同 cwd;不能凭“定时任务”四个字就断言它一定在哪个目录启动。

把下面代码保存成普通 .py 文件,用实际启动方式运行。三个输出分别回答:进程现在站哪儿、脚本放哪儿、这次相对配置路径指向哪儿。记录脱敏后的路径信息;不要打印整份配置。

from pathlib import Path

print("当前工作目录:", Path.cwd())
print("脚本文件:", Path(__file__).resolve())
print("相对路径实际指向:", Path("config.json").resolve())

如果第三行不在预期目录,优先修路径约定。把脚本写成绝对路径只是在定位脚本,不会自动把进程 cwd 切到脚本旁边;调整 Python 导入路径同样不会替普通文件读取选择基准。

二、Path 对象不是文件,也不是目录书签

Path("config.json") 保存的是路径描述,不会在构造时读取文件,也不会把当时 cwd 永久绑定进去。同一个相对 Path 对象,如果先后在不同 cwd 下执行 read_text,可能读到不同文件。目录里恰好都有同名配置时,“运行成功”反而最容易让人放松警惕。

absolute() 把相对路径变成绝对路径,但不解析符号链接;resolve() 还会解析符号链接并消除 ..。两者都不是“自动找项目根目录”。对相对路径调用 resolve,采用的是调用当时的工作目录,而不是代码所在目录。

resolve(strict=False) 不保证目标存在;strict=True 会在缺失路径等错误下抛异常。即使解析成功,也没有证明文件格式正确、字段合规、后续业务成功,更不是抵御文件系统竞态的安全边界。

Python路径三种约定:当前工作目录、脚本目录和显式绝对配置路径
普通磁盘脚本示意:先约定文件属于谁,再选择路径基准。配图由 DM-Fox 生成并等比例规范为本地 PNG。

三、先给文件分类,再决定怎么拼路径

脚本随附的固定配置,可以从脚本目录拼接;部署者维护的配置,更适合显式传入绝对路径;命令行用户主动指定的输入文件,也可以约定相对调用者 cwd。三者没有谁天然高级,关键是约定明确、出错可诊断,不要一处按脚本算,另一处又偷偷按 cwd 算。

下面采用“默认脚本旁 config.json,APP_CONFIG 覆盖时只接受绝对路径”的约定。把代码保存为读取配置.py,脚本旁准备 UTF-8 文件,内容可用 {"名称":"演示任务"}。默认分支能直接运行;覆盖分支用你的启动器设置 APP_CONFIG。没有设置与设置为空串是两件事,空串不能悄悄降级成默认值。

四、完整读取示例:定位之后还要验内容

import json
import os
from pathlib import Path

# 约定:默认配置跟随真实脚本目录;覆盖值必须是绝对路径
脚本目录 = Path(__file__).resolve().parent
覆盖值 = os.environ.get("APP_CONFIG")
if 覆盖值 is None:
    配置路径 = 脚本目录 / "config.json"
else:
    if not 覆盖值.strip():
        raise ValueError("APP_CONFIG 不能是空串或全空白")
    配置路径 = Path(覆盖值).expanduser()
    if not 配置路径.is_absolute():
        raise ValueError("APP_CONFIG 必须是绝对路径")

配置路径 = 配置路径.resolve(strict=True)
if not 配置路径.is_file():
    raise ValueError("配置路径必须指向普通文件")
配置 = json.loads(配置路径.read_text(encoding="utf-8"))
if not isinstance(配置, dict):
    raise ValueError("配置必须是 JSON 对象")
名称 = 配置.get("名称")
if not isinstance(名称, str) or not 名称.strip():
    raise ValueError("名称必须是非空字符串")
print("配置校验通过")

expanduser() 是主动支持 ~/ 开头的家目录写法;Path 构造本身不会展开 ~,也不会展开 $HOME 这样的环境变量。绝对路径判断使用当前系统的路径风格:Linux 上不能直接拿 Windows 的盘符路径来充当可访问文件。示例不截去路径首尾空格,避免擅改合法文件名。

本文代码以 CPython 3.14.3 在 Linux 隔离目录执行;read_text 从 Python 3.5 提供,resolve 的 strict 参数从 3.6 提供,不能把后者门槛说成全部 pathlib 功能的门槛。生产环境应使用受支持的版本。

五、换两个 cwd,复现“同名文件读错”

下面是可独立执行的最小实验,不需要下载内部测试文件。它只创建临时目录,分别从“应用”和“其他目录”启动同一个脚本;每次先相对读取,再从脚本目录读取。正常输出里,前者从应用配置变成其他配置,后者两次都是应用配置。

import subprocess
import sys
from pathlib import Path
from tempfile import TemporaryDirectory

# 只在临时目录制造两个同名文件,不动现有项目
with TemporaryDirectory() as 临时目录:
    根 = Path(临时目录)
    应用 = 根 / "应用"
    其他 = 根 / "其他目录"
    应用.mkdir()
    其他.mkdir()
    (应用 / "config.txt").write_text("应用配置", encoding="utf-8")
    (其他 / "config.txt").write_text("其他配置", encoding="utf-8")
    脚本 = 应用 / "probe.py"
    脚本.write_text(
        'from pathlib import Path\n'
        'print(Path("config.txt").read_text(encoding="utf-8"))\n'
        'print((Path(__file__).resolve().parent / "config.txt")'
        '.read_text(encoding="utf-8"))\n', encoding="utf-8")
    for 工作目录 in (应用, 其他):
        回 = subprocess.run([sys.executable, str(脚本)], cwd=工作目录,
                           capture_output=True, text=True, check=True,
                           encoding="utf-8")
        print(工作目录.name, 回.stdout.splitlines())

这比单独打印 exists() 更有说服力:两个文件都存在,但读到的内容不同。实验只证明普通子进程和文件路径行为,没有运行真实 cron、改 systemd 服务或验证你的部署权限;上线时还要用真实服务账号和启动环境补做验收。

六、符号链接和包资源,要单独选约定

Path(__file__).resolve().parent 选的是解析符号链接后的真实脚本目录。若从某个入口链接启动,它不会把入口链接所在目录当默认配置目录。发布目录切换、共享配置、热更新各有约定,不能用一句“永远这样写”盖过去。本文选择真实脚本旁配置,部署配置则通过绝对覆盖值指定。

__file__ 不是所有运行环境的统一入口:交互执行、部分加载器或打包方式可能没有可用的普通磁盘脚本路径。包内资源宜评估 importlib.resources;外置部署配置依旧适合显式路径,不要猜包一定是展开的目录。也别在库函数里随手 os.chdir,它会改变进程 cwd,影响其他相对文件操作。

七、常见失败分支与处理表

现象核对点处理
只在另一启动方式报缺文件cwd 与相对路径实际目标明确脚本目录或绝对覆盖路径
不报错但读错配置同名文件内容、来源约定核对选中的文件,不只看存在性
APP_CONFIG 被拒绝空白、相对路径、系统路径风格给当前系统可访问的绝对路径
解析成功却不能读目录、权限、编码、JSON 格式保留异常类型,按失败层处理
链接入口读取了真实目录配置resolve 的符号链接语义显式配置部署路径,不猜链接位置

路径拼接还存在一个容易漏看的边界:基准 / 绝对路径 时,右侧绝对路径会覆盖前面的基准。固定文件名与不可信用户输入不能混为一谈;即使先检查 is_absolute,也不能因此声称解决了目录穿越或符号链接攻击。本文的配置覆盖值来自受信任部署者,不提供文件访问沙箱。

八、上线验收清单与资料

  • 记录脱敏后的 cwd、脚本来源和选中配置路径,核对路径约定。
  • 从至少两个 cwd 启动同一脚本,检查默认配置是否稳定。
  • 覆盖值分别测试未设置、合法绝对路径、相对路径、空串和缺失文件。
  • 测试目录、非法 JSON、错误字段与编码失败,不能吞异常伪装成功。
  • 用真实运行账号验读权限、业务结果及退出码;路径探针不能代替业务验收。

本地隔离检查还覆盖了符号链接入口、相对 Path 在切换 cwd 后的行为及绝对右侧覆盖基准。这里展示的是路径与配置读取边界,不是实际定时任务上线回执。核心只有一句:脚本位置、工作目录和配置归属是三个概念,先说清楚,再让代码执行。

🔕 评论已关闭