FovesConfig 0.1.1__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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: FovesConfig
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: 四叶的配置管理工具 — 基于 Pydantic 的配置加载器,上下文管理器自动读写
5
5
  Project-URL: Repository, https://github.com/Foves7017/FovesLib
6
6
  Author: Foves7017
@@ -78,10 +78,45 @@ cfg = loader.readonly()
78
78
  print(cfg.host)
79
79
  ```
80
80
 
81
+ ### 覆盖写入
82
+
83
+ 不想进上下文、只想把一份新配置整体写回去时,用 `overwrite()`:调用即落盘,并把这个实例返回给你。
84
+ 一个典型用法(也是我创建这个方法的原因)就是在配置初始化时创建文件。
85
+
86
+ ```Python
87
+ loader = ConfigLoader("config.json", AppConfig)
88
+ cfg = loader.overwrite(AppConfig(port=9090))
89
+ ```
90
+
91
+ - **写入时机**:调用时立刻写,不需要等 `with` 块退出;父目录不存在会自动递归创建。
92
+ - **不会先读取**:所以磁盘上是坏掉的 JSON 也能被直接覆盖。
93
+ - **不做校验**:入参必须是你在 `ConfigLoader` 上声明的那个模型实例。传 `dict`、`None` 之类会直接抛 `AttributeError`(它只会去调 `model_dump_json`);传成别的模型也会被原样写出去。
94
+ - **和 `readonly()` 的关系**:`readonly()` 每次调用都会重新从磁盘读取,因此**以磁盘上最后一次写入的内容为准**。`overwrite()` 之后再 `readonly()`,拿到的是重新反序列化出来的新对象,不是你传进去的那个引用。
95
+
81
96
  ### 读写行为
82
97
 
83
98
  - **读取**:进入上下文管理器时,若文件存在则 `model_validate_json` 反序列化,否则用 `model_cls()` 构造默认实例。
84
99
  - **写入**:退出上下文管理器时自动创建父目录,以 `indent=2`、`ensure_ascii=False` 写出 JSON。
100
+ - **写入实现**:落盘逻辑集中在内部的 `_write()`,上下文退出和 `overwrite()` 共用同一套行为,两者格式完全一致。它写的是「同目录临时文件 → `os.replace`」,细节见下面的「并发与原子写」。
101
+ - **异常路径**:`__exit__` 不会吞掉异常,也不会因为异常就跳过写入——块内抛异常时配置依然会写回磁盘。
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` **不提供跨进程锁。**
85
120
 
86
121
  ### 其他
87
122
  如果你发现自己需要手动调用 save() 或 exit(),请先考虑是否应该使用上下文管理器。大多数修改配置的场景都更适合:
@@ -91,4 +126,6 @@ with ConfigLoader(...) as config:
91
126
  ...
92
127
  ```
93
128
 
94
- 而不是手动管理配置对象的生命周期。
129
+ 而不是手动管理配置对象的生命周期。
130
+
131
+ 如果确实需要“不进上下文也要写盘”的场景(例如程序启动时生成一份默认配置、或把外部来源的配置整体落盘),直接用 `overwrite()` 就够了,不必自己造 `save()` / `exit()`。
@@ -0,0 +1,99 @@
1
+ # FovesConfig
2
+
3
+ 基于 Pydantic 的配置管理工具,上下文管理器自动读写 JSON 配置文件。
4
+
5
+ 可使用 pip 安装或下载源码使用:
6
+ ```BASH
7
+ pip install FovesConfig
8
+ ```
9
+
10
+ 项目主页:
11
+ https://github.com/Foves7017/FovesLib
12
+
13
+ ## ConfigLoader
14
+
15
+ 泛型配置加载器。把你的配置定义成一个 Pydantic 模型,然后用 `ConfigLoader` 包裹它——进入上下文自动读取 JSON,退出时自动写回,省去所有手工序列化的麻烦。
16
+
17
+ ### 基本用法
18
+
19
+ ```Python
20
+ from pydantic import BaseModel
21
+ from FovesConfig import ConfigLoader
22
+
23
+ class AppConfig(BaseModel):
24
+ host: str = "localhost"
25
+ port: int = 8080
26
+ debug: bool = False
27
+
28
+ with ConfigLoader("config.json", AppConfig) as cfg:
29
+ print(cfg.host) # 首次运行会使用默认值
30
+ cfg.port = 9090 # 修改配置
31
+ # 退出上下文时自动写入 config.json
32
+ ```
33
+
34
+ ### 参数
35
+
36
+ `path: str | Path` — 配置文件路径。文件存在则读取,不存在则使用模型默认值。
37
+
38
+ `model: Type[T]` — 一个 Pydantic BaseModel 子类,定义了配置的结构和默认值。
39
+
40
+ ### 只读
41
+ 你可以使用如下的方法只读取磁盘上的配置,在此例中,就算修改 cfg 的值也不会同步到磁盘中。
42
+
43
+ ```Python
44
+ loader = ConfigLoader("config.json", AppConfig)
45
+ cfg = loader.readonly()
46
+ print(cfg.host)
47
+ ```
48
+
49
+ ### 覆盖写入
50
+
51
+ 不想进上下文、只想把一份新配置整体写回去时,用 `overwrite()`:调用即落盘,并把这个实例返回给你。
52
+ 一个典型用法(也是我创建这个方法的原因)就是在配置初始化时创建文件。
53
+
54
+ ```Python
55
+ loader = ConfigLoader("config.json", AppConfig)
56
+ cfg = loader.overwrite(AppConfig(port=9090))
57
+ ```
58
+
59
+ - **写入时机**:调用时立刻写,不需要等 `with` 块退出;父目录不存在会自动递归创建。
60
+ - **不会先读取**:所以磁盘上是坏掉的 JSON 也能被直接覆盖。
61
+ - **不做校验**:入参必须是你在 `ConfigLoader` 上声明的那个模型实例。传 `dict`、`None` 之类会直接抛 `AttributeError`(它只会去调 `model_dump_json`);传成别的模型也会被原样写出去。
62
+ - **和 `readonly()` 的关系**:`readonly()` 每次调用都会重新从磁盘读取,因此**以磁盘上最后一次写入的内容为准**。`overwrite()` 之后再 `readonly()`,拿到的是重新反序列化出来的新对象,不是你传进去的那个引用。
63
+
64
+ ### 读写行为
65
+
66
+ - **读取**:进入上下文管理器时,若文件存在则 `model_validate_json` 反序列化,否则用 `model_cls()` 构造默认实例。
67
+ - **写入**:退出上下文管理器时自动创建父目录,以 `indent=2`、`ensure_ascii=False` 写出 JSON。
68
+ - **写入实现**:落盘逻辑集中在内部的 `_write()`,上下文退出和 `overwrite()` 共用同一套行为,两者格式完全一致。它写的是「同目录临时文件 → `os.replace`」,细节见下面的「并发与原子写」。
69
+ - **异常路径**:`__exit__` 不会吞掉异常,也不会因为异常就跳过写入——块内抛异常时配置依然会写回磁盘。
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
+
89
+ ### 其他
90
+ 如果你发现自己需要手动调用 save() 或 exit(),请先考虑是否应该使用上下文管理器。大多数修改配置的场景都更适合:
91
+
92
+ ```Python
93
+ with ConfigLoader(...) as config:
94
+ ...
95
+ ```
96
+
97
+ 而不是手动管理配置对象的生命周期。
98
+
99
+ 如果确实需要“不进上下文也要写盘”的场景(例如程序启动时生成一份默认配置、或把外部来源的配置整体落盘),直接用 `overwrite()` 就够了,不必自己造 `save()` / `exit()`。
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "FovesConfig"
7
- version = "0.1.1"
7
+ version = "0.3.0"
8
8
  description = "四叶的配置管理工具 — 基于 Pydantic 的配置加载器,上下文管理器自动读写"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -1,29 +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 readonly(self) -> T:
17
- self._read()
18
- return self.model
19
-
20
- def __enter__(self) -> T:
21
- self._read()
22
- return self.model
23
-
24
- def __exit__(self, exc_type, exc, tb):
25
- self.path.parent.mkdir(parents=True, exist_ok=True)
26
- self.path.write_text(
27
- self.model.model_dump_json(indent=2, ensure_ascii=False),
28
- encoding="utf-8",
29
- )
@@ -1,62 +0,0 @@
1
- # FovesConfig
2
-
3
- 基于 Pydantic 的配置管理工具,上下文管理器自动读写 JSON 配置文件。
4
-
5
- 可使用 pip 安装或下载源码使用:
6
- ```BASH
7
- pip install FovesConfig
8
- ```
9
-
10
- 项目主页:
11
- https://github.com/Foves7017/FovesLib
12
-
13
- ## ConfigLoader
14
-
15
- 泛型配置加载器。把你的配置定义成一个 Pydantic 模型,然后用 `ConfigLoader` 包裹它——进入上下文自动读取 JSON,退出时自动写回,省去所有手工序列化的麻烦。
16
-
17
- ### 基本用法
18
-
19
- ```Python
20
- from pydantic import BaseModel
21
- from FovesConfig import ConfigLoader
22
-
23
- class AppConfig(BaseModel):
24
- host: str = "localhost"
25
- port: int = 8080
26
- debug: bool = False
27
-
28
- with ConfigLoader("config.json", AppConfig) as cfg:
29
- print(cfg.host) # 首次运行会使用默认值
30
- cfg.port = 9090 # 修改配置
31
- # 退出上下文时自动写入 config.json
32
- ```
33
-
34
- ### 参数
35
-
36
- `path: str | Path` — 配置文件路径。文件存在则读取,不存在则使用模型默认值。
37
-
38
- `model: Type[T]` — 一个 Pydantic BaseModel 子类,定义了配置的结构和默认值。
39
-
40
- ### 只读
41
- 你可以使用如下的方法只读取磁盘上的配置,在此例中,就算修改 cfg 的值也不会同步到磁盘中。
42
-
43
- ```Python
44
- loader = ConfigLoader("config.json", AppConfig)
45
- cfg = loader.readonly()
46
- print(cfg.host)
47
- ```
48
-
49
- ### 读写行为
50
-
51
- - **读取**:进入上下文管理器时,若文件存在则 `model_validate_json` 反序列化,否则用 `model_cls()` 构造默认实例。
52
- - **写入**:退出上下文管理器时自动创建父目录,以 `indent=2`、`ensure_ascii=False` 写出 JSON。
53
-
54
- ### 其他
55
- 如果你发现自己需要手动调用 save() 或 exit(),请先考虑是否应该使用上下文管理器。大多数修改配置的场景都更适合:
56
-
57
- ```Python
58
- with ConfigLoader(...) as config:
59
- ...
60
- ```
61
-
62
- 而不是手动管理配置对象的生命周期。
File without changes
File without changes