FovesConfig 0.1.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ import os
2
+ import shutil
3
+ import sys
4
+ import tempfile
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Type
8
+
9
+ from pydantic import BaseModel
10
+
11
+ # 落盘重试:Windows 上目标文件正被别的进程读时 os.replace 会抛 PermissionError,
12
+ # 并发读压力下实测 81% 的写入都要重试,所以这里必须等,不能失败就走。
13
+ _WRITE_ATTEMPTS = 100 # 最大写入重试次数
14
+ _WRITE_RETRY_DELAY = 0.005 # 重试延时
15
+ _WRITE_WARN_EVERY = 10 # 每 10 次重试警告
16
+ # 读取重试:读者也可能正好撞上别人 replace 的瞬间。
17
+ _READ_ATTEMPTS = 6 # 最大重试
18
+ _READ_RETRY_DELAY = 0.005 # 重试延时
19
+
20
+
21
+ def _default_file_mode() -> int:
22
+ """按当前 umask 算出新建文件该有的权限位(mkstemp 建出来的是 0600,太紧)。"""
23
+ umask = os.umask(0)
24
+ os.umask(umask)
25
+ return 0o666 & ~umask
26
+
27
+
28
+ def _atomic_write_text(path: Path, text: str) -> None:
29
+ """先写同目录临时文件再 os.replace 顶掉目标:读者要么看到旧内容,要么看到新内容。"""
30
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
31
+ tmp = Path(tmp_name)
32
+ try:
33
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as file:
34
+ file.write(text)
35
+ file.flush()
36
+ os.fsync(file.fileno())
37
+ # 换了新 inode 会丢掉原文件的权限位,这里对齐回去。
38
+ try:
39
+ if path.exists():
40
+ shutil.copymode(path, tmp)
41
+ else:
42
+ os.chmod(tmp, _default_file_mode())
43
+ except OSError:
44
+ pass
45
+ last_error: PermissionError | None = None
46
+ for attempt in range(1, _WRITE_ATTEMPTS + 1):
47
+ try:
48
+ os.replace(tmp, path)
49
+ return
50
+ except PermissionError as error:
51
+ last_error = error
52
+ if attempt % _WRITE_WARN_EVERY == 0:
53
+ print(
54
+ f'[FovesConfig] 写入 {path} 第 {attempt} 次重试未成功(文件被占用?):{error}',
55
+ file=sys.stderr,
56
+ )
57
+ time.sleep(_WRITE_RETRY_DELAY)
58
+ raise last_error or RuntimeError(f'写入 {path} 失败')
59
+ except BaseException:
60
+ try:
61
+ tmp.unlink()
62
+ except OSError:
63
+ pass
64
+ raise
65
+
66
+
67
+ def _read_text(path: Path) -> str:
68
+ """读取文本,撞上别人正在替换文件的瞬间就稍等重试。"""
69
+ last_error: PermissionError | None = None
70
+ for _ in range(_READ_ATTEMPTS):
71
+ try:
72
+ return path.read_text("UTF8")
73
+ except PermissionError as error:
74
+ last_error = error
75
+ time.sleep(_READ_RETRY_DELAY)
76
+ raise last_error or RuntimeError(f'读取 {path} 失败')
77
+
78
+
79
+ class ConfigLoader[T: BaseModel]:
80
+ def __init__(self, path: str|Path, model: Type[T]):
81
+ self.path = Path(path)
82
+ self.model_cls = model
83
+
84
+ def _read(self):
85
+ if self.path.exists():
86
+ self.model = self.model_cls.model_validate_json(_read_text(self.path))
87
+ else:
88
+ self.model = self.model_cls()
89
+
90
+ def _write(self):
91
+ self.path.parent.mkdir(parents=True, exist_ok=True)
92
+ _atomic_write_text(
93
+ self.path,
94
+ self.model.model_dump_json(indent=2, ensure_ascii=False),
95
+ )
96
+
97
+ def readonly(self) -> T:
98
+ self._read()
99
+ return self.model
100
+
101
+ def overwrite(self, model: T) -> T:
102
+ self.model = model
103
+ self._write()
104
+ return self.model
105
+
106
+ def __enter__(self) -> T:
107
+ self._read()
108
+ return self.model
109
+
110
+ def __exit__(self, exc_type, exc, tb):
111
+ self._write()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: FovesConfig
3
- Version: 0.1.2
3
+ Version: 0.3.0
4
4
  Summary: 四叶的配置管理工具 — 基于 Pydantic 的配置加载器,上下文管理器自动读写
5
5
  Project-URL: Repository, https://github.com/Foves7017/FovesLib
6
6
  Author: Foves7017
@@ -97,9 +97,27 @@ cfg = loader.overwrite(AppConfig(port=9090))
97
97
 
98
98
  - **读取**:进入上下文管理器时,若文件存在则 `model_validate_json` 反序列化,否则用 `model_cls()` 构造默认实例。
99
99
  - **写入**:退出上下文管理器时自动创建父目录,以 `indent=2`、`ensure_ascii=False` 写出 JSON。
100
- - **写入实现**:落盘逻辑集中在内部的 `_write()`,上下文退出和 `overwrite()` 共用同一套行为,两者格式完全一致。
100
+ - **写入实现**:落盘逻辑集中在内部的 `_write()`,上下文退出和 `overwrite()` 共用同一套行为,两者格式完全一致。它写的是「同目录临时文件 → `os.replace`」,细节见下面的「并发与原子写」。
101
101
  - **异常路径**:`__exit__` 不会吞掉异常,也不会因为异常就跳过写入——块内抛异常时配置依然会写回磁盘。
102
102
 
103
+ ### 并发与原子写
104
+
105
+ 写入不是「打开目标文件直接覆盖」,而是「先写同目录的临时文件,再 `os.replace` 顶掉目标」。所以另一个进程或线程在这个瞬间去读,要么读到旧内容,要么读到新内容,不会读到半截 JSON。
106
+
107
+ 代价是 Windows 上 `os.replace` 撞上别人正打开着目标文件时会抛 `PermissionError`,因此读写都带短重试:
108
+
109
+ | | 每次间隔 | 上限 | 最坏耗时 |
110
+ |---|---|---|---|
111
+ | 写入(`os.replace`) | 5 ms | 100 次 | ~500 ms |
112
+ | 读取(`read_text`) | 5 ms | 6 次 | ~30 ms |
113
+
114
+ - 写入每满 10 次还没成功,会往 **`sys.stderr`** 打一条警告:
115
+ `[FovesConfig] 写入 config.json 第 30 次重试未成功(文件被占用?):[WinError 5] ...`
116
+ 故意不写 stdout——stdio 型 MCP 服务的 stdout 是协议通道,多打一行就会把协议打坏。
117
+ - 100 次全失败会抛出原始 `PermissionError`,同时删掉临时文件,不会留下半截目标文件。
118
+ - 临时文件形如 `config.json.a1b2c3.tmp`,与目标同目录(同目录才能保证 `os.replace` 是原子的)。进程被强杀时可能留下一个孤儿临时文件,不影响读取,可手动删。
119
+ - 这套重试只解决「读到半截」。多个进程同时改同一份配置时,后写的会覆盖先写的,`ConfigLoader` **不提供跨进程锁。**
120
+
103
121
  ### 其他
104
122
  如果你发现自己需要手动调用 save() 或 exit(),请先考虑是否应该使用上下文管理器。大多数修改配置的场景都更适合:
105
123
 
@@ -65,9 +65,27 @@ cfg = loader.overwrite(AppConfig(port=9090))
65
65
 
66
66
  - **读取**:进入上下文管理器时,若文件存在则 `model_validate_json` 反序列化,否则用 `model_cls()` 构造默认实例。
67
67
  - **写入**:退出上下文管理器时自动创建父目录,以 `indent=2`、`ensure_ascii=False` 写出 JSON。
68
- - **写入实现**:落盘逻辑集中在内部的 `_write()`,上下文退出和 `overwrite()` 共用同一套行为,两者格式完全一致。
68
+ - **写入实现**:落盘逻辑集中在内部的 `_write()`,上下文退出和 `overwrite()` 共用同一套行为,两者格式完全一致。它写的是「同目录临时文件 → `os.replace`」,细节见下面的「并发与原子写」。
69
69
  - **异常路径**:`__exit__` 不会吞掉异常,也不会因为异常就跳过写入——块内抛异常时配置依然会写回磁盘。
70
70
 
71
+ ### 并发与原子写
72
+
73
+ 写入不是「打开目标文件直接覆盖」,而是「先写同目录的临时文件,再 `os.replace` 顶掉目标」。所以另一个进程或线程在这个瞬间去读,要么读到旧内容,要么读到新内容,不会读到半截 JSON。
74
+
75
+ 代价是 Windows 上 `os.replace` 撞上别人正打开着目标文件时会抛 `PermissionError`,因此读写都带短重试:
76
+
77
+ | | 每次间隔 | 上限 | 最坏耗时 |
78
+ |---|---|---|---|
79
+ | 写入(`os.replace`) | 5 ms | 100 次 | ~500 ms |
80
+ | 读取(`read_text`) | 5 ms | 6 次 | ~30 ms |
81
+
82
+ - 写入每满 10 次还没成功,会往 **`sys.stderr`** 打一条警告:
83
+ `[FovesConfig] 写入 config.json 第 30 次重试未成功(文件被占用?):[WinError 5] ...`
84
+ 故意不写 stdout——stdio 型 MCP 服务的 stdout 是协议通道,多打一行就会把协议打坏。
85
+ - 100 次全失败会抛出原始 `PermissionError`,同时删掉临时文件,不会留下半截目标文件。
86
+ - 临时文件形如 `config.json.a1b2c3.tmp`,与目标同目录(同目录才能保证 `os.replace` 是原子的)。进程被强杀时可能留下一个孤儿临时文件,不影响读取,可手动删。
87
+ - 这套重试只解决「读到半截」。多个进程同时改同一份配置时,后写的会覆盖先写的,`ConfigLoader` **不提供跨进程锁。**
88
+
71
89
  ### 其他
72
90
  如果你发现自己需要手动调用 save() 或 exit(),请先考虑是否应该使用上下文管理器。大多数修改配置的场景都更适合:
73
91
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "FovesConfig"
7
- version = "0.1.2"
7
+ version = "0.3.0"
8
8
  description = "四叶的配置管理工具 — 基于 Pydantic 的配置加载器,上下文管理器自动读写"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -1,37 +0,0 @@
1
- from typing import Type
2
- from pathlib import Path
3
- from pydantic import BaseModel
4
-
5
- class ConfigLoader[T: BaseModel]:
6
- def __init__(self, path: str|Path, model: Type[T]):
7
- self.path = Path(path)
8
- self.model_cls = model
9
-
10
- def _read(self):
11
- if self.path.exists():
12
- self.model = self.model_cls.model_validate_json(self.path.read_text("UTF8"))
13
- else:
14
- self.model = self.model_cls()
15
-
16
- def _write(self):
17
- self.path.parent.mkdir(parents=True, exist_ok=True)
18
- self.path.write_text(
19
- self.model.model_dump_json(indent=2, ensure_ascii=False),
20
- encoding="utf-8",
21
- )
22
-
23
- def readonly(self) -> T:
24
- self._read()
25
- return self.model
26
-
27
- def overwrite(self, model: T) -> T:
28
- self.model = model
29
- self._write()
30
- return self.model
31
-
32
- def __enter__(self) -> T:
33
- self._read()
34
- return self.model
35
-
36
- def __exit__(self, exc_type, exc, tb):
37
- self._write()
File without changes
File without changes