guarantee-based-coding 0.2.0__py3-none-any.whl

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.
Files changed (48) hide show
  1. gbc/__init__.py +21 -0
  2. gbc/app/__init__.py +14 -0
  3. gbc/app/assets.py +38 -0
  4. gbc/app/config/__init__.py +14 -0
  5. gbc/app/config/backups.py +28 -0
  6. gbc/app/config/base.py +29 -0
  7. gbc/app/config/executor.py +132 -0
  8. gbc/app/config/project.py +35 -0
  9. gbc/app/core/__init__.py +14 -0
  10. gbc/app/core/env.py +64 -0
  11. gbc/app/core/executor.py +116 -0
  12. gbc/app/core/guarantee.py +338 -0
  13. gbc/app/i18n/__init__.py +43 -0
  14. gbc/app/i18n/lang.py +88 -0
  15. gbc/app/i18n/translate.py +80 -0
  16. gbc/app/intent/__init__.py +20 -0
  17. gbc/app/intent/base.py +308 -0
  18. gbc/app/intent/cli.py +124 -0
  19. gbc/app/intent/editor.py +93 -0
  20. gbc/app/interface/__init__.py +14 -0
  21. gbc/app/interface/base.py +851 -0
  22. gbc/app/interface/cli.py +585 -0
  23. gbc/app/interface/mcp.py +616 -0
  24. gbc/app/models/__init__.py +14 -0
  25. gbc/app/models/errors.py +179 -0
  26. gbc/app/models/meta.py +92 -0
  27. gbc/app/models/verify.py +63 -0
  28. gbc/app/utils/__init__.py +14 -0
  29. gbc/app/utils/file_utils.py +24 -0
  30. gbc/app/utils/gbc_md.py +121 -0
  31. gbc/app/utils/json_model_operator.py +85 -0
  32. gbc/app/utils/safe_file_writer.py +158 -0
  33. gbc/assets/editor/index.html +299 -0
  34. gbc/assets/i18n/catalog/en.json +52 -0
  35. gbc/assets/i18n/catalog/zh.json +52 -0
  36. gbc/assets/i18n/texts/rules.en.md +30 -0
  37. gbc/assets/i18n/texts/rules.zh.md +24 -0
  38. gbc/assets/i18n/texts/setup.en.md +74 -0
  39. gbc/assets/i18n/texts/setup.zh.md +69 -0
  40. gbc/assets/skills/README.md +16 -0
  41. gbc/assets/skills/gbc-cli/SKILL.md +143 -0
  42. gbc/entry.py +126 -0
  43. guarantee_based_coding-0.2.0.dist-info/METADATA +108 -0
  44. guarantee_based_coding-0.2.0.dist-info/RECORD +48 -0
  45. guarantee_based_coding-0.2.0.dist-info/WHEEL +5 -0
  46. guarantee_based_coding-0.2.0.dist-info/entry_points.txt +2 -0
  47. guarantee_based_coding-0.2.0.dist-info/licenses/LICENSE +202 -0
  48. guarantee_based_coding-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,179 @@
1
+ # Copyright 2026 Jesse-x86
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from pathlib import Path
16
+
17
+
18
+ def _t(key: str, **kw) -> str:
19
+ """惰性取本地化消息。在 __str__ 内部调用,避免 models 层模块级依赖 i18n,
20
+ 保持核心数据契约层干净。i18n 不可用时回退到 key。"""
21
+ try:
22
+ from gbc.app.i18n import t
23
+ return t(key, **kw)
24
+ except Exception:
25
+ return key
26
+
27
+
28
+ class GBCError(Exception):
29
+ """
30
+ 所有GBC异常的基类
31
+ """
32
+
33
+ class ConfigError(GBCError):
34
+ """
35
+ 所有配置文件相关的Error
36
+ """
37
+
38
+ class ExecutorError(GBCError):
39
+ """
40
+ 执行器模块的Error
41
+ """
42
+
43
+ class GuaranteeError(GBCError):
44
+ """
45
+ 和保证相关的Error
46
+ """
47
+
48
+ class IllegalOperationError(GBCError):
49
+ """
50
+ 非法操作,操作意图层面就是错误的
51
+ """
52
+
53
+ # ======== Illegal Operation ========
54
+ class IntentDocError(IllegalOperationError):
55
+ """意图文档(gbc.md)领域的非法操作。携带 i18n key + 参数,__str__ 时本地化。"""
56
+ def __init__(self, msg_key: str, **params):
57
+ super().__init__(msg_key)
58
+ self.msg_key = msg_key
59
+ self.params = params
60
+
61
+ def __str__(self):
62
+ return _t(self.msg_key, **self.params)
63
+
64
+
65
+ class IllegalFilePathError(IllegalOperationError):
66
+ """
67
+ 非法操作,操作意图层面就是错误的
68
+ """
69
+ def __init__(self, target_file):
70
+ super().__init__(target_file)
71
+ self.target_file = target_file
72
+
73
+ def __str__(self):
74
+ return _t("exc.illegal_file_path", target=self.target_file)
75
+
76
+ # ======== Config ========
77
+
78
+ class ConfigNotFoundError(ConfigError):
79
+ def __init__(self, target_file):
80
+ super().__init__(target_file)
81
+ self.target_file = target_file
82
+
83
+ def __str__(self):
84
+ return _t("exc.config_not_found", target=self.target_file)
85
+
86
+ class ConfigParseError(ConfigError):
87
+ def __init__(self, target_file, failure_info):
88
+ super().__init__(target_file)
89
+ self.target_file = target_file
90
+ self.failure_info = failure_info
91
+
92
+ def __str__(self):
93
+ return _t("exc.config_parse", target=self.target_file, info=self.failure_info)
94
+
95
+ # ======== Project ========
96
+
97
+ class ProjectNotFoundError(GBCError):
98
+ def __init__(self, target_project):
99
+ super().__init__(target_project)
100
+ self.target_project = target_project
101
+
102
+ def __str__(self):
103
+ return _t("exc.project_not_found", target=self.target_project)
104
+
105
+ # ======== Meta ========
106
+
107
+ class MetaNotFoundError(GBCError):
108
+ def __init__(self, original_file, target_file):
109
+ super().__init__(target_file)
110
+ self.original_file = original_file
111
+ self.target_file = target_file
112
+
113
+ def __str__(self):
114
+ return _t("exc.meta_not_found", target=self.target_file, original=self.original_file)
115
+
116
+ # ======== Guarantee ========
117
+
118
+ class GuaranteeDuplicatedError(GuaranteeError):
119
+ def __init__(self, target_file: str, guarantee_path: str):
120
+ super().__init__(target_file)
121
+ self.target_file = target_file
122
+ self.guarantee_path = guarantee_path
123
+
124
+ def __str__(self):
125
+ return _t("exc.guarantee_duplicated", gid=self.guarantee_path, target=self.target_file)
126
+
127
+ class GuaranteeNotFoundError(GuaranteeError):
128
+ def __init__(self, target_file: str, guarantee_path: str):
129
+ super().__init__(target_file)
130
+ self.target_file = target_file
131
+ self.guarantee_path = guarantee_path
132
+
133
+ def __str__(self):
134
+ return _t("exc.guarantee_not_found", gid=self.guarantee_path, target=self.target_file)
135
+
136
+ class GuaranteeTestFailedError(GuaranteeError):
137
+ def __init__(self, target_file: str, guarantee_path: str, failure_info: str):
138
+ super().__init__(target_file)
139
+ self.target_file = target_file
140
+ self.guarantee_path = guarantee_path
141
+ self.failure_info = failure_info
142
+
143
+ def __str__(self):
144
+ return _t("exc.guarantee_test_failed", gid=self.guarantee_path, target=self.target_file, info=self.failure_info)
145
+
146
+ class GuaranteeHasDependentsError(GuaranteeError):
147
+ """退休保护:拒绝删除仍有 dependents 的保证。
148
+
149
+ 系统不替使用者「反射式」删掉一条还有人依赖的保证——那必然悄悄弄坏下游。
150
+ 必须先沿依赖线把 dependents 修复/迁移掉,dependents 清空后才允许退休。
151
+ """
152
+ def __init__(self, provider: str, guarantee_id: str, dependents: list[str]):
153
+ super().__init__(guarantee_id)
154
+ self.provider = provider
155
+ self.guarantee_id = guarantee_id
156
+ self.dependents = dependents
157
+
158
+ def __str__(self):
159
+ return _t("exc.guarantee_has_dependents", gid=self.guarantee_id,
160
+ provider=self.provider, count=len(self.dependents), dependents=self.dependents)
161
+
162
+ # ======== Executor ========
163
+
164
+ class ExecutorNotFoundError(ExecutorError):
165
+ def __init__(self, config):
166
+ super().__init__(config)
167
+ self.config = config
168
+
169
+ def __str__(self):
170
+ return _t("exc.executor_not_found", name=self.config)
171
+
172
+
173
+ class ExecutorConfigInvalidError(ExecutorError):
174
+ def __init__(self, config):
175
+ super().__init__(config)
176
+ self.config = config
177
+
178
+ def __str__(self):
179
+ return _t("exc.executor_config_invalid", name=self.config)
gbc/app/models/meta.py ADDED
@@ -0,0 +1,92 @@
1
+ # Copyright 2026 Jesse-x86
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ # ============================================================================
18
+ # .gbc 元数据模型
19
+ #
20
+ # 每个代码文件对应一份 .gbc json(FileMeta),它是「双段自包含」的:
21
+ # - provides : 本文件作为 provider 提供的具名保证(保证为一等公民)
22
+ # - depends_on : 本文件作为 consumer 声明的依赖边
23
+ # 两段分别承载依赖图的两个方向,由工具层负责跨文件原子地保持双向一致。
24
+ # ============================================================================
25
+
26
+
27
+ class Guarantee(BaseModel):
28
+ """一条具名行为保证,由某个 provider 文件持有、唯一一份。
29
+
30
+ 身份 = 它在 ``FileMeta.provides`` 中的 key(一个语义化点路径 id,例如
31
+ ``"config.llm.get_model.returns_loaded"``),**不是** 测试路径——改测试
32
+ 文件名不会改变保证的身份;多个消费者共享同一条保证时也只认这个 id。
33
+ """
34
+
35
+ # 承诺了什么行为、以及为何需要它(富描述,给人和 agent 读)
36
+ desc: str
37
+
38
+ # 测试选择器:交给 executor 做 {file} 替换的那个 str(= 旧 guarantee_path)。
39
+ # 与 executor 分离——这里只说「跑哪个测试」,不说「怎么跑」。
40
+ test: str
41
+
42
+ # executor 配置名:决定「怎么跑」这个测试(命令/cwd/env/默认超时)。
43
+ executor: str
44
+
45
+ # 单条保证的超时覆写;-1 表示回退到 executor 的默认超时。
46
+ timeout_override: int = -1
47
+
48
+ # 成本秩 + 自动运行授权等级。0 = 普通,批量必跑;>=1 = 贵/慢(如需 LLM),
49
+ # 批量 verify 中按阈值跳过并响亮报告。数字越大越「别随便跑」,高到一定
50
+ # 程度应由人类决定是否运行。批量只跑 heavy <= 阈值的;register / 点名
51
+ # verify_single 无视它、永远跑。
52
+ heavy: int = 0
53
+
54
+ # 依赖这条保证的消费者文件路径列表(多对一:一条保证可被多个文件依赖)。
55
+ # 这是反向边;非空即代表「还有人靠着它」,退休保护据此拒绝删除。
56
+ dependents: list[str] = Field(default_factory=list)
57
+
58
+ # 临时停用:True 时保证的 id 与全部边(dependents/反向边)原样保留,但「出生即绿」
59
+ # 门禁与批量 verify 都对它**暂缓执行**——不跑、不判失败、进 skipped(reason=disabled)。
60
+ # 用途:① 重构窗口(refactor 期间测试会暂时跑不过,先 disable 守住边,改完再 enable
61
+ # 重跑门禁);② 循环依赖 bootstrap(测试还过不了时先占位注册);③ 暂停在修的保证。
62
+ # 三者本质都是「留住 id+边,但此刻先不强制测试」。disabled 是 born-green 墙上的一个
63
+ # 洞,因此它必须**永远是响的**:check_consistency 始终把 disabled 保证及「依赖了
64
+ # disabled 保证」的边报出来(non-empty),tree 用 ⊘ 标记——藏不住,才不会悄悄烂掉。
65
+ disabled: bool = False
66
+
67
+
68
+ class Dependency(BaseModel):
69
+ """本文件(作为 consumer)声明的一条依赖边。
70
+
71
+ ``symbol`` 写成 ``"<provider 文件>:<符号名>"``,既标明依赖了谁的哪个符号,
72
+ 也隐含了 provider 文件(取 ``:`` 前半段)。
73
+
74
+ ``guarantees`` 列出本文件依赖的、该 provider 上的具名保证 id:
75
+ - 空列表 = symbol 级「免费依赖」:只依赖符号存在/签名,不依赖具体行为,
76
+ 不需要、也不创建保证与测试。
77
+ - 非空 = 行为级依赖:每个 id 必须对应 provider ``provides`` 里的一条保证,
78
+ 且该保证的 ``dependents`` 里登记了本文件(双向一致)。
79
+ """
80
+
81
+ symbol: str
82
+ guarantees: list[str] = Field(default_factory=list)
83
+
84
+
85
+ class FileMeta(BaseModel):
86
+ """单个代码文件的 .gbc 元数据,对 provider / consumer 两种角色都自包含。"""
87
+
88
+ # 本文件作为 provider 提供的保证:key = 保证 id。
89
+ provides: dict[str, Guarantee] = Field(default_factory=dict)
90
+
91
+ # 本文件作为 consumer 声明的依赖边。
92
+ depends_on: list[Dependency] = Field(default_factory=list)
@@ -0,0 +1,63 @@
1
+ # Copyright 2026 Jesse-x86
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ # ============================================================================
18
+ # 验证结果模型
19
+ #
20
+ # 分两层:
21
+ # - VerifyModel : 单个测试跑完的「原始结果」(由 core/executor 直接产出)。
22
+ # - VerifySummary : 一次批量 verify 的「三桶汇总」,门禁判定从它读出。
23
+ #
24
+ # 门禁是二元的:跑了的测试只有 过(green) / 挂(red);没跑的测试不是「一种结果」
25
+ # 而是「缺席」(heavy 跳过)。所以 skipped 不染红,green = (failed 为空)。
26
+ # ============================================================================
27
+
28
+
29
+ class VerifyModel(BaseModel):
30
+ """单个测试跑完的原始结果。"""
31
+
32
+ return_code: int
33
+ stdout: None | str
34
+ stderr: None | str
35
+
36
+
37
+ class SkippedGuarantee(BaseModel):
38
+ """一条被跳过、未运行的保证(当前只因 heavy 超过运行阈值)。"""
39
+
40
+ id: str
41
+ heavy: int
42
+ reason: str = "heavy"
43
+
44
+
45
+ class VerifySummary(BaseModel):
46
+ """一次(批量)verify 的三桶汇总 + 门禁判定。
47
+
48
+ passed / failed / skipped 三个桶按保证 id 分类;``results`` 保留真正跑过的
49
+ 保证的原始输出,供失败时排查。skipped 必须被响亮报告("X heavy skipped"),
50
+ 让调用方知道「即便全绿,问题也可能出在这些没跑的保证上」。
51
+ """
52
+
53
+ passed: list[str] = Field(default_factory=list)
54
+ failed: list[str] = Field(default_factory=list)
55
+ skipped: list[SkippedGuarantee] = Field(default_factory=list)
56
+
57
+ # 真正跑过的保证 id -> 原始结果(passed/failed 都收,skipped 不收)。
58
+ results: dict[str, VerifyModel] = Field(default_factory=dict)
59
+
60
+ @property
61
+ def green(self) -> bool:
62
+ """门禁是否通过:没有任何失败即为绿(skipped 不影响)。"""
63
+ return not self.failed
@@ -0,0 +1,14 @@
1
+ # Copyright 2026 Jesse-x86
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
@@ -0,0 +1,24 @@
1
+ # Copyright 2026 Jesse-x86
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from pathlib import Path
16
+
17
+ from gbc.app.config.project import get_current_project
18
+
19
+
20
+ def to_gbc_json_path(provider: Path):
21
+ relative_path = provider.relative_to(get_current_project())
22
+ target_dir = get_current_project() / ".gbc" / relative_path.parent
23
+ target_filename = f"gbc.{relative_path.name}.json"
24
+ return target_dir / target_filename
@@ -0,0 +1,121 @@
1
+ # Copyright 2026 Jesse-x86
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Parse / serialize a single GBC `gbc.md` file — the canonical parser.
16
+
17
+ This is the single authoritative parser/serializer for the `gbc.md` format. Both
18
+ `interface.base.render_tree` (engine side) and the standalone intent-editor tool
19
+ build on it; neither re-implements the format. Pure text handling, no IO.
20
+
21
+ Format:
22
+
23
+ # 意图
24
+ <intent text, may span multiple paragraphs>
25
+
26
+ # 内部约束 <- optional H1 block
27
+ <constraints text>
28
+
29
+ # 文件 <- container for child entries (only emitted if any)
30
+ ## game.py <- H2 entry; no trailing "/" => plain file
31
+ <desc>
32
+
33
+ ## maker/ <- trailing "/" => subfolder
34
+ <desc> (a subfolder's desc == that subfolder's own `# 意图`)
35
+
36
+ H1 blocks: 意图 (intent) + optional 内部约束 (constraints) + 文件 (files container).
37
+ The `# 文件` heading exists so child entries no longer visually nest under 内部约束.
38
+ H2 blocks (the `# 文件` children): one per child file or subfolder under this path.
39
+
40
+ Parsing is lenient: any H2 is treated as a child entry regardless of whether a
41
+ `# 文件` heading precedes it, so old-format docs (entries with no `# 文件` section)
42
+ still parse — re-serializing them upgrades them to the new format.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import re
47
+ from dataclasses import dataclass, field
48
+
49
+ INTENT_HEADING = "意图"
50
+ CONSTRAINTS_HEADING = "内部约束"
51
+ FILES_HEADING = "文件"
52
+
53
+ _HEADING_RE = re.compile(r"^(#{1,2})\s+(.*?)\s*$")
54
+
55
+
56
+ @dataclass
57
+ class Entry:
58
+ """An H2 (`##`) child entry: a file or a subfolder."""
59
+ name: str # e.g. "main.py" or "app/" (trailing slash kept for dirs)
60
+ is_dir: bool
61
+ desc: str = "" # for files, the only source; for dirs, mirrors child intent
62
+
63
+
64
+ @dataclass
65
+ class ParsedDoc:
66
+ intent: str = ""
67
+ constraints: str = ""
68
+ entries: list[Entry] = field(default_factory=list)
69
+
70
+
71
+ def parse(text: str) -> ParsedDoc:
72
+ """Parse gbc.md text into intent / constraints / ordered entries."""
73
+ doc = ParsedDoc()
74
+ # current sink: "intent" | "constraints" | entry-index | None (discard)
75
+ body: list[str] = []
76
+ sink: str | int | None = None
77
+
78
+ def flush() -> None:
79
+ content = "\n".join(body).strip()
80
+ if sink == "intent":
81
+ doc.intent = content
82
+ elif sink == "constraints":
83
+ doc.constraints = content
84
+ elif isinstance(sink, int):
85
+ doc.entries[sink].desc = content
86
+
87
+ for line in text.splitlines():
88
+ m = _HEADING_RE.match(line)
89
+ if not m:
90
+ body.append(line)
91
+ continue
92
+ # heading boundary: flush the previous block first
93
+ flush()
94
+ body = []
95
+ level, title = len(m.group(1)), m.group(2).strip()
96
+ if level == 1:
97
+ if title == CONSTRAINTS_HEADING:
98
+ sink = "constraints"
99
+ elif title == FILES_HEADING:
100
+ sink = None # the 文件 container heading has no body; its H2s are entries
101
+ else:
102
+ sink = "intent"
103
+ else: # level 2 -> child entry
104
+ is_dir = title.endswith("/")
105
+ doc.entries.append(Entry(name=title, is_dir=is_dir))
106
+ sink = len(doc.entries) - 1
107
+ flush()
108
+ return doc
109
+
110
+
111
+ def serialize(intent: str, constraints: str, entries: list[Entry]) -> str:
112
+ """Render intent / constraints / entries back into gbc.md text."""
113
+ blocks: list[str] = []
114
+ blocks.append(f"# {INTENT_HEADING}\n{intent.strip()}".rstrip())
115
+ if constraints.strip():
116
+ blocks.append(f"# {CONSTRAINTS_HEADING}\n{constraints.strip()}".rstrip())
117
+ if entries:
118
+ blocks.append(f"# {FILES_HEADING}")
119
+ for e in entries:
120
+ blocks.append(f"## {e.name}\n{e.desc.strip()}".rstrip())
121
+ return "\n\n".join(blocks) + "\n"
@@ -0,0 +1,85 @@
1
+ # Copyright 2026 Jesse-x86
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import json
16
+ from pathlib import Path
17
+ from typing import TypeVar
18
+
19
+ from pydantic import BaseModel, ValidationError
20
+
21
+ from gbc.app.utils.safe_file_writer import SafeFileWriter
22
+
23
+ T = TypeVar("T", bound=BaseModel)
24
+
25
+
26
+ def load_model_from_json(
27
+ filepath: str | Path,
28
+ model_class: type[T]
29
+ ) -> T:
30
+ """
31
+ 从指定的 JSON 文件中读取并还原为 Pydantic 模型。
32
+
33
+ Args:
34
+ filepath: 明确的完整文件路径。
35
+ model_class: 用于解析的 Pydantic 模型类。
36
+
37
+ Returns:
38
+ 实例化后的模型对象。
39
+
40
+ Raises:
41
+ FileNotFoundError: 当指定路径不存在时抛出。
42
+ ValidationError: 当 JSON 内容不符合模型定义时抛出。
43
+ """
44
+ path = Path(filepath)
45
+
46
+ if not path.exists():
47
+ raise FileNotFoundError(f"未找到指定的模型文件: {path}")
48
+
49
+ with open(path, "r", encoding="utf-8") as f:
50
+ try:
51
+ raw_data = json.load(f)
52
+ except json.JSONDecodeError as e:
53
+ raise ValueError(f"解析 JSON 文件失败: {path}, 错误详情: {e}")
54
+
55
+ try:
56
+ return model_class(**raw_data)
57
+ except ValidationError as e:
58
+ # 在这里可以记录日志或者直接抛出
59
+ raise e
60
+
61
+ def save_model_to_json(
62
+ model: BaseModel,
63
+ filepath: str | Path,
64
+ num_backups: int = 0
65
+ ) -> None:
66
+ """
67
+ 将 Pydantic 模型安全地序列化为 JSON 文件。
68
+
69
+ Args:
70
+ model: 要保存的 Pydantic 模型对象。
71
+ filepath: 明确的完整文件路径。
72
+ num_backups: 备份数量,由 SafeFileWriter 处理。
73
+ """
74
+ path = Path(filepath)
75
+
76
+ # 确保父目录存在
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+
79
+ writer = SafeFileWriter(path, num_backups=num_backups)
80
+
81
+ # 获取字典数据
82
+ data = model.model_dump()
83
+
84
+ with writer.open(mode='w', encoding='utf-8') as f:
85
+ json.dump(data, f, ensure_ascii=False, indent=4)