farm_paths.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. # -*- coding: utf-8 -*-
  2. """farm_paths.py — Windows→Mac/Linux 路径规范化助手 (零改迁移用).
  3. 老的 per-farm 脚本写死了 Windows 盘符路径 (F:\\datang\\..., D:\\wind-analytics\\...,
  4. D:\\data\\...). 本机无盘符。本模块把这些 win 路径按"有序前缀映射表"翻译成本机 Path,
  5. 让脚本只需把 `r"F:\\..."` 包一层 `winpath(...)` 即可,不必逐个硬改、不必批量 sed。
  6. 为什么不能盲目把 `X:\\` → `/Volumes/T9/`:
  7. - `D:\\wind-analytics\\...` 是**仓库**本身 → ~/wind-analytics (不在 T9)
  8. - `D:\\data\\...` 是旧 WIND_DATA_ROOT → /Volumes/T9/data
  9. - `F:\\...` 才是 T9 盘根 → /Volumes/T9/...
  10. - `D:\\BaiduNetdiskDownload`、`C:\\Users` 等本机无对应物 → 必须报错,不能静默乱指。
  11. 映射目标可被环境变量覆盖 (.env 即可):
  12. WIND_T9_ROOT 默认 /Volumes/T9 (F:\\ 盘根)
  13. WIND_DATA_ROOT 默认 /Volumes/T9/data (D:\\data 替身)
  14. WIND_REPO_ROOT 默认 = 本仓库根(由本文件位置推出)
  15. WIND_PATH_STRICT 默认 1 = 遇未知盘符前缀抛错;设 0 = 仅告警并尽力按 T9 兜底。
  16. Windows 上 (os.name=='nt') 本模块为 no-op,原样返回 → 旧机继续可跑,跨平台安全。
  17. 用法:
  18. from src.sop.farm_paths import winpath
  19. SEC = winpath(r"F:\\datang\\景泰兴泉风电场\\数据转换及清洗\\sec2") # -> Path(/Volumes/T9/datang/...)
  20. files = sorted((SEC).glob("DJ*.csv"))
  21. 自检: python src/sop/farm_paths.py
  22. """
  23. from __future__ import annotations
  24. try:
  25. from app_common.app_common_guanlan.api import install_root as _install_root
  26. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  27. from pathlib import Path as _P
  28. def _install_root(_f): return _P(_f).resolve().parents[4]
  29. import os
  30. import re
  31. import warnings
  32. from pathlib import Path
  33. # 仓库根 = 安装根(P6 起按标记查找;原按 src/sop/farm_paths.py 的 parents[2] 推算)
  34. _REPO_ROOT_DEFAULT = _install_root(__file__)
  35. T9_ROOT = os.environ.get("WIND_T9_ROOT", "/Volumes/T9")
  36. DATA_ROOT = os.environ.get("WIND_DATA_ROOT", str(Path(T9_ROOT) / "data"))
  37. REPO_ROOT = os.environ.get("WIND_REPO_ROOT", str(_REPO_ROOT_DEFAULT))
  38. STRICT = os.environ.get("WIND_PATH_STRICT", "1") not in ("0", "false", "False", "")
  39. # 有序前缀映射 (win 前缀用小写正斜杠形式; 最具体的在前 —— D:/wind-analytics 必须先于 D:/ 命中)
  40. PREFIX_MAP: list[tuple[str, str]] = [
  41. ("d:/wind-analytics", REPO_ROOT), # 仓库自身 -> ~/wind-analytics
  42. ("d:/data", DATA_ROOT), # 旧 WIND_DATA_ROOT -> /Volumes/T9/data
  43. ("f:/data", DATA_ROOT), # T9 上的 data (与 D:\data 同一替身)
  44. ("f:/", T9_ROOT), # T9 盘根 -> /Volumes/T9
  45. ]
  46. _DRIVE_RE = re.compile(r"^([A-Za-z]):[\\/]?")
  47. def winpath(p) -> Path:
  48. """把可能含 Windows 盘符的路径翻译成本机 Path。无盘符/已是 POSIX 路径则原样返回。"""
  49. s = str(p)
  50. # Windows 原生运行: 不翻译,原样 (旧机兼容)
  51. if os.name == "nt":
  52. return Path(s)
  53. # 无盘符前缀 (相对路径 / 已是 /Volumes/... 的 POSIX 路径) -> 原样
  54. if not _DRIVE_RE.match(s):
  55. return Path(s)
  56. norm = s.replace("\\", "/") # 反斜杠 -> 正斜杠
  57. low = norm.lower()
  58. for prefix, target in PREFIX_MAP:
  59. # 前缀以 '/' 结尾 (如 'f:/') 用 startswith; 否则需整段相等或后随 '/'
  60. if prefix.endswith("/"):
  61. hit = low.startswith(prefix)
  62. cut = len(prefix)
  63. else:
  64. hit = low == prefix or low.startswith(prefix + "/")
  65. cut = len(prefix)
  66. if hit:
  67. tail = norm[cut:].lstrip("/")
  68. return Path(target) / tail if tail else Path(target)
  69. # 未知盘符前缀: 本机无对应物 (D:\BaiduNetdiskDownload, C:\Users, 裸 D:\ ...)
  70. drive = _DRIVE_RE.match(s).group(1).upper()
  71. known = ", ".join(pfx for pfx, _ in PREFIX_MAP)
  72. msg = (f"farm_paths: 无法映射的 Windows 路径根 {drive!r}: {s!r}\n"
  73. f" 已知映射: {known}\n"
  74. f" 这是旧机本地路径,T9 上无对应物。请确认数据是否已搬到 T9,"
  75. f"或扩充 PREFIX_MAP / 设 WIND_PATH_STRICT=0 兜底。")
  76. if STRICT:
  77. raise ValueError(msg)
  78. warnings.warn(msg, stacklevel=2)
  79. # 非严格兜底: 把任意盘符当 T9 根 (大概率仍不对,仅为不中断流程)
  80. tail = norm[_DRIVE_RE.match(norm).end():].lstrip("/")
  81. return Path(T9_ROOT) / tail
  82. def winstr(p) -> str:
  83. """winpath 的字符串版 (给 glob.glob / os.path.join 等吃 str 的旧代码)。"""
  84. return str(winpath(p))
  85. if __name__ == "__main__":
  86. # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
  87. # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
  88. # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
  89. import sys as _sys
  90. for _s in (_sys.stdout, _sys.stderr):
  91. try: _s.reconfigure(errors='replace')
  92. except Exception: pass
  93. # 零依赖自检
  94. ok = True
  95. cases = {
  96. r"F:\datang\景泰兴泉风电场\sec2": f"{T9_ROOT}/datang/景泰兴泉风电场/sec2",
  97. r"F:\data\foo.csv": f"{DATA_ROOT}/foo.csv",
  98. r"D:\data\bar": f"{DATA_ROOT}/bar",
  99. r"D:\wind-analytics\outputs\x": f"{REPO_ROOT}/outputs/x",
  100. r"F:\\zhangyaoxian\\a": f"{T9_ROOT}/zhangyaoxian/a", # 双反斜杠
  101. "F:/datang/b": f"{T9_ROOT}/datang/b", # 已是正斜杠
  102. "outputs/rel/path": "outputs/rel/path", # 相对路径原样
  103. "/Volumes/T9/already/posix": "/Volumes/T9/already/posix", # POSIX 原样
  104. }
  105. for win, exp in cases.items():
  106. got = str(winpath(win))
  107. flag = "PASS" if got == exp else "FAIL"
  108. if got != exp:
  109. ok = False
  110. print(f" {flag} {win!r:48} -> {got}")
  111. # 未知根应报错 (STRICT)
  112. try:
  113. winpath(r"D:\BaiduNetdiskDownload\x")
  114. print(" FAIL 未知盘符未报错"); ok = False
  115. except ValueError:
  116. print(" PASS 未知盘符 D:\\BaiduNetdiskDownload 正确报错")
  117. # 映射根存在性提示 (不计入 PASS/FAIL)
  118. print(f"\n T9_ROOT={T9_ROOT} exists={Path(T9_ROOT).exists()}")
  119. print(f" DATA_ROOT={DATA_ROOT} exists={Path(DATA_ROOT).exists()}")
  120. print(f" REPO_ROOT={REPO_ROOT} exists={Path(REPO_ROOT).exists()}")
  121. print("\n" + ("✅ farm_paths 自检全过" if ok else "❌ farm_paths 自检有 FAIL"))
  122. raise SystemExit(0 if ok else 1)