excel-codegen 0.9.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 (82) hide show
  1. excel_codegen/__init__.py +160 -0
  2. excel_codegen/__main__.py +8 -0
  3. excel_codegen/cli.py +1250 -0
  4. excel_codegen/derived.py +465 -0
  5. excel_codegen/example_pack.py +107 -0
  6. excel_codegen/examples/README.md +69 -0
  7. excel_codegen/examples/abs_fpi/ABS_FPI_load_cases.xlsx +0 -0
  8. excel_codegen/examples/abs_fpi/ABS_FPI_load_cases_render.bat +22 -0
  9. excel_codegen/examples/abs_fpi/ABS_FPI_load_cases_render.sh +23 -0
  10. excel_codegen/examples/abs_fpi/README.md +121 -0
  11. excel_codegen/examples/abs_fpi/abs_fpi.yaml +310 -0
  12. excel_codegen/examples/abs_fpi/abs_fpi_external.xlsx +0 -0
  13. excel_codegen/examples/abs_fpi/abs_fpi_external.yaml +162 -0
  14. excel_codegen/examples/abs_fpi/abs_fpi_external_render.bat +22 -0
  15. excel_codegen/examples/abs_fpi/abs_fpi_external_render.sh +23 -0
  16. excel_codegen/examples/abs_fpi/abs_fpi_internal.xlsx +0 -0
  17. excel_codegen/examples/abs_fpi/abs_fpi_internal.yaml +266 -0
  18. excel_codegen/examples/abs_fpi/abs_fpi_internal_render.bat +22 -0
  19. excel_codegen/examples/abs_fpi/abs_fpi_internal_render.sh +23 -0
  20. excel_codegen/examples/abs_fpi/compose.yaml +20 -0
  21. excel_codegen/examples/abs_fpi/generated/COT1-d20.559-mu60.js +124 -0
  22. excel_codegen/examples/abs_fpi/generated/COT1-d20.559-mu60_summary.md +30 -0
  23. excel_codegen/examples/abs_fpi/generated/EXT-T15-mu0-kf-1.js +91 -0
  24. excel_codegen/examples/abs_fpi/generated/EXT-T15-mu0-kf-1_summary.md +23 -0
  25. excel_codegen/examples/abs_fpi/generated/EXT-T15-mu90-kf+1.js +91 -0
  26. excel_codegen/examples/abs_fpi/generated/EXT-T15-mu90-kf+1_summary.md +23 -0
  27. excel_codegen/examples/abs_fpi/generated/EXT-T20.559-mu90-kf+1.js +91 -0
  28. excel_codegen/examples/abs_fpi/generated/EXT-T20.559-mu90-kf+1_summary.md +23 -0
  29. excel_codegen/examples/abs_fpi/generated/WBT6-d15.059-mu0.js +124 -0
  30. excel_codegen/examples/abs_fpi/generated/WBT6-d15.059-mu0_summary.md +30 -0
  31. excel_codegen/examples/abs_fpi/generated/WBT6-d8-mu90.js +124 -0
  32. excel_codegen/examples/abs_fpi/generated/WBT6-d8-mu90_summary.md +30 -0
  33. excel_codegen/examples/abs_fpi/generated/WBT7-d20.559-mu90.js +124 -0
  34. excel_codegen/examples/abs_fpi/generated/WBT7-d20.559-mu90_summary.md +30 -0
  35. excel_codegen/examples/abs_fpi/templates/ext_body.js.j2 +91 -0
  36. excel_codegen/examples/abs_fpi/templates/ext_summary.md.j2 +23 -0
  37. excel_codegen/examples/abs_fpi/templates/int_body.js.j2 +124 -0
  38. excel_codegen/examples/abs_fpi/templates/int_summary.md.j2 +30 -0
  39. excel_codegen/examples/basic/example.yaml +97 -0
  40. excel_codegen/examples/basic/example_formula.yaml +86 -0
  41. excel_codegen/examples/basic/generated/uart_init_Case1.c +6 -0
  42. excel_codegen/examples/basic/generated/uart_init_Case2.c +6 -0
  43. excel_codegen/examples/basic/generated/uart_summary_Case1.md +4 -0
  44. excel_codegen/examples/basic/generated/uart_summary_Case2.md +4 -0
  45. excel_codegen/examples/basic/generated_formula/uart_init_Case1.c +5 -0
  46. excel_codegen/examples/basic/generated_formula/uart_init_Case2.c +5 -0
  47. excel_codegen/examples/basic/generated_formula/uart_pins_Case1.csv +1 -0
  48. excel_codegen/examples/basic/generated_formula/uart_pins_Case2.csv +1 -0
  49. excel_codegen/examples/basic/template.xlsx +0 -0
  50. excel_codegen/examples/basic/template_formula.xlsx +0 -0
  51. excel_codegen/examples/basic/template_formula_render.bat +22 -0
  52. excel_codegen/examples/basic/template_formula_render.sh +23 -0
  53. excel_codegen/examples/basic/template_render.bat +22 -0
  54. excel_codegen/examples/basic/template_render.sh +23 -0
  55. excel_codegen/examples/nastran/generated/case_control.deck +18 -0
  56. excel_codegen/examples/nastran/generated/cc_01_LC1.inc +8 -0
  57. excel_codegen/examples/nastran/generated/cc_02_LC2.inc +8 -0
  58. excel_codegen/examples/nastran/generated/cc_03_LC3.inc +8 -0
  59. excel_codegen/examples/nastran/generated/cc_04_COMB4.inc +8 -0
  60. excel_codegen/examples/nastran/generated/cc_05_LC5.inc +8 -0
  61. excel_codegen/examples/nastran/generated/cc_summary_01_LC1.md +1 -0
  62. excel_codegen/examples/nastran/generated/cc_summary_02_LC2.md +1 -0
  63. excel_codegen/examples/nastran/generated/cc_summary_03_LC3.md +1 -0
  64. excel_codegen/examples/nastran/generated/cc_summary_04_COMB4.md +1 -0
  65. excel_codegen/examples/nastran/generated/cc_summary_05_LC5.md +1 -0
  66. excel_codegen/examples/nastran/nastran_case_control.xlsx +0 -0
  67. excel_codegen/examples/nastran/nastran_case_control.yaml +141 -0
  68. excel_codegen/examples/nastran/nastran_case_control_render.bat +22 -0
  69. excel_codegen/examples/nastran/nastran_case_control_render.sh +23 -0
  70. excel_codegen/excel_io.py +1885 -0
  71. excel_codegen/formula.py +674 -0
  72. excel_codegen/formula_eval.py +685 -0
  73. excel_codegen/jinja_env.py +92 -0
  74. excel_codegen/models.py +1008 -0
  75. excel_codegen/renderer.py +558 -0
  76. excel_codegen/utils.py +225 -0
  77. excel_codegen-0.9.0.dist-info/METADATA +179 -0
  78. excel_codegen-0.9.0.dist-info/RECORD +82 -0
  79. excel_codegen-0.9.0.dist-info/WHEEL +5 -0
  80. excel_codegen-0.9.0.dist-info/entry_points.txt +2 -0
  81. excel_codegen-0.9.0.dist-info/licenses/LICENSE +21 -0
  82. excel_codegen-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1008 @@
1
+ """Pydantic 数据模型:YAML 配置结构 + 运行期数据(CaseData / RenderResult)。
2
+
3
+ 配置文件结构::
4
+
5
+ version: 1
6
+ excel:
7
+ output: "template.xlsx"
8
+ template_sheet: "Template"
9
+ sheets:
10
+ global: "Global Parameter"
11
+ local: "Local Parameter"
12
+ outputs: ["Output"]
13
+ variables:
14
+ global: [...]
15
+ local: [...]
16
+ templates: [...]
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from collections.abc import Mapping
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any, Literal
26
+
27
+ import yaml
28
+ from pydantic import (
29
+ BaseModel,
30
+ ConfigDict,
31
+ Field,
32
+ ValidationError,
33
+ field_validator,
34
+ model_validator,
35
+ )
36
+
37
+ from .utils import (
38
+ ConfigError,
39
+ VarValue,
40
+ column_index_to_letter,
41
+ is_identifier,
42
+ parse_cell,
43
+ split_lines,
44
+ to_text,
45
+ )
46
+
47
+ __all__ = [
48
+ "FIRST_CASE_COLUMN",
49
+ "CaseData",
50
+ "Direction",
51
+ "ExcelConfig",
52
+ "ExcelSheets",
53
+ "ProjectConfig",
54
+ "RenderResult",
55
+ "TemplateDef",
56
+ "VarType",
57
+ "VariableDef",
58
+ "VariablesConfig",
59
+ "format_validation_error",
60
+ "load_config",
61
+ ]
62
+
63
+ #: Local Parameter 工作表中第一个 Case 列(E 列),A-D 为变量名/描述/Prefix/Suffix。
64
+ FIRST_CASE_COLUMN: int = 5
65
+
66
+ Direction = Literal["horizontal", "vertical"]
67
+ VarType = Literal["auto", "string", "int", "float", "bool", "raw"]
68
+ #: ``excel`` = 把模板编译成**Excel 公式**写进输出表(**默认**):改参数由 Excel 自己重算,
69
+ #: 工作簿脱离命令行也独立可用;
70
+ #: ``snapshot`` = 脚本渲染后把**文本**写进输出表,改参数必须重跑命令。
71
+ #: 模板要用 ``{% for %}`` / 过滤器 / 多行 ``{% if %}`` / ``{% include %}`` 时才需要。
72
+ Engine = Literal["snapshot", "excel"]
73
+
74
+ #: 渲染上下文里由程序注入、不允许作为变量名使用的保留名。
75
+ RESERVED_NAMES: frozenset[str] = frozenset({"case_name", "template_name"})
76
+
77
+
78
+ def _duplicates(items: list[str]) -> list[str]:
79
+ seen: set[str] = set()
80
+ dupes: list[str] = []
81
+ for item in items:
82
+ if item in seen and item not in dupes:
83
+ dupes.append(item)
84
+ seen.add(item)
85
+ return dupes
86
+
87
+
88
+ def _number_text(value: float) -> str:
89
+ """数字的提示文本:整数不显示小数点(``20.0`` → ``20``)。"""
90
+ return to_text(int(value)) if float(value).is_integer() else to_text(value)
91
+
92
+
93
+ def _as_number(value: Any) -> float | None:
94
+ """把取值当数字读;读不出来返回 ``None``(bool 不算数字)。"""
95
+ if isinstance(value, bool):
96
+ return None
97
+ if isinstance(value, (int, float)):
98
+ return float(value)
99
+ text = to_text(value).strip()
100
+ if not text:
101
+ return None
102
+ try:
103
+ return float(text)
104
+ except ValueError:
105
+ return None
106
+
107
+
108
+ def _valid_sheet_name(value: Any) -> str:
109
+ """校验并规范化一个 Excel 工作表名。"""
110
+ name = to_text(value).strip()
111
+ if not name:
112
+ raise ValueError("工作表名不能为空")
113
+ if len(name) > 31 or any(char in name for char in "[]:*?/\\"):
114
+ raise ValueError(f"工作表名 {name!r} 非法(Excel 限制:<=31 字符且不能含 []:*?/\\)")
115
+ return name
116
+
117
+
118
+ def format_validation_error(exc: ValidationError) -> str:
119
+ """把 pydantic 的校验错误整理成多行、便于阅读的中文提示。"""
120
+ lines: list[str] = []
121
+ for error in exc.errors():
122
+ location = ".".join(str(part) for part in error.get("loc", ())) or "<root>"
123
+ lines.append(f" - {location}: {error.get('msg', '非法取值')}")
124
+ return "\n".join(lines)
125
+
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # 变量定义
129
+ # --------------------------------------------------------------------------- #
130
+ class VariableDef(BaseModel):
131
+ """一个变量的定义(名称、描述、默认值、前缀、后缀、类型)。"""
132
+
133
+ model_config = ConfigDict(extra="forbid")
134
+
135
+ name: str
136
+ description: str = ""
137
+ #: 单位(纯文档,只写进 Excel 批注与 `validate` 的清单;**不会**出现在生成的文本里)。
138
+ #: 单位要进生成结果请用 ``suffix``(``suffix: " m"`` → ``340 m``)。
139
+ unit: str = ""
140
+ default: Any = ""
141
+ prefix: str = ""
142
+ suffix: str = ""
143
+ type: VarType = "auto"
144
+ #: 派生参数:一段 Jinja2 **表达式**,只能引用 ``global`` 与**同一个 Case** 的 ``local``。
145
+ #: 例如 ``derived: "k_c * h_di"``。派生参数不用在 Excel 里填值(那一格由工具写成公式或算好的值)。
146
+ derived: str | None = None
147
+
148
+ #: 取值约束(可选,只对"填写型"变量有效;派生参数的值是算出来的,不能加)。
149
+ #: ``min`` / ``max``:数值上下限。``type`` 为 string / bool / raw 时不允许。
150
+ min: float | None = None
151
+ max: float | None = None
152
+ #: 允许的取值集合,按**文本**比较(``1`` 与 ``1.0`` 视为同一个值)。
153
+ #: 会同时写成 Excel 的下拉列表。
154
+ choices: list[Any] | None = None
155
+ #: 整串匹配的正则(``re.fullmatch``)。
156
+ pattern: str | None = None
157
+
158
+ @field_validator("name")
159
+ @classmethod
160
+ def _check_name(cls, value: str) -> str:
161
+ name = to_text(value).strip()
162
+ if not name:
163
+ raise ValueError("变量名不能为空")
164
+ if not is_identifier(name):
165
+ raise ValueError(
166
+ f"变量名 {name!r} 不是合法标识符(需匹配 [A-Za-z_][A-Za-z0-9_]*),因为要在 Jinja2 模板中直接引用"
167
+ )
168
+ if name in RESERVED_NAMES:
169
+ raise ValueError(f"变量名 {name!r} 是保留字(渲染时由程序注入),请改名")
170
+ return name
171
+
172
+ @field_validator("description", "unit", "prefix", "suffix", mode="before")
173
+ @classmethod
174
+ def _to_str(cls, value: Any) -> str:
175
+ return "" if value is None else to_text(value)
176
+
177
+ @field_validator("derived", mode="before")
178
+ @classmethod
179
+ def _derived_to_str(cls, value: Any) -> str | None:
180
+ if value is None:
181
+ return None
182
+ text = to_text(value).strip()
183
+ return text or None
184
+
185
+ @field_validator("min", "max", mode="before")
186
+ @classmethod
187
+ def _number_or_none(cls, value: Any) -> float | None:
188
+ if value is None:
189
+ return None
190
+ if isinstance(value, bool):
191
+ raise ValueError("min / max 必须是数字,不能是 true/false")
192
+ if isinstance(value, (int, float)):
193
+ return float(value)
194
+ text = to_text(value).strip()
195
+ if not text:
196
+ return None
197
+ try:
198
+ return float(text)
199
+ except ValueError:
200
+ raise ValueError(f"min / max 必须是数字,得到 {value!r}") from None
201
+
202
+ @field_validator("choices", mode="before")
203
+ @classmethod
204
+ def _check_choices(cls, value: Any) -> list[Any] | None:
205
+ if value is None:
206
+ return None
207
+ if isinstance(value, (str, bytes)) or not isinstance(value, (list, tuple)):
208
+ raise ValueError("choices 必须是列表,例如 choices: [EXT, INT]")
209
+ items = list(value)
210
+ if not items:
211
+ raise ValueError("choices 不能是空列表(不想要约束就删掉这个字段)")
212
+ texts = [to_text(item) for item in items]
213
+ dupes = sorted({text for text in texts if texts.count(text) > 1})
214
+ if dupes:
215
+ raise ValueError(f"choices 里有重复取值: {', '.join(dupes)}")
216
+ return items
217
+
218
+ @field_validator("pattern", mode="before")
219
+ @classmethod
220
+ def _check_pattern(cls, value: Any) -> str | None:
221
+ if value is None:
222
+ return None
223
+ text = to_text(value).strip()
224
+ if not text:
225
+ return None
226
+ try:
227
+ re.compile(text)
228
+ except re.error as exc:
229
+ raise ValueError(f"pattern 不是合法的正则: {exc}") from None
230
+ return text
231
+
232
+ @model_validator(mode="after")
233
+ def _check_derived(self) -> VariableDef:
234
+ if self.derived and to_text(self.default) != "":
235
+ raise ValueError(
236
+ f"变量 {self.name!r} 同时写了 derived 与 default —— 派生参数的值由表达式算出来,"
237
+ "不能同时给它一个填写值;请删掉其中一个"
238
+ )
239
+ return self
240
+
241
+ @model_validator(mode="after")
242
+ def _check_constraints(self) -> VariableDef:
243
+ constrained = self.min is not None or self.max is not None or self.choices or self.pattern
244
+ if not constrained:
245
+ return self
246
+ if self.is_derived:
247
+ raise ValueError(
248
+ f"变量 {self.name!r} 是派生参数(值由表达式算出来),不能加 min / max / choices / pattern —— "
249
+ "请在表达式里约束(例如 max(x, 0)),或把它改成填写型变量"
250
+ )
251
+ if self.min is not None and self.max is not None and self.min > self.max:
252
+ raise ValueError(f"变量 {self.name!r} 的 min({_number_text(self.min)}) 大于 max({_number_text(self.max)})")
253
+ if (self.min is not None or self.max is not None) and self.type in ("string", "bool", "raw"):
254
+ raise ValueError(
255
+ f"变量 {self.name!r} 的 type 是 {self.type!r},不能加 min / max —— "
256
+ "数值范围只对 type: int / float / auto 有意义"
257
+ )
258
+ # 默认值是 YAML 自己写的,违反约束属于配置错误,立刻指出(空默认值允许:表示"必须去表里填")
259
+ if to_text(self.default) != "":
260
+ problem = self.value_problem(self.default)
261
+ if problem:
262
+ raise ValueError(f"变量 {self.name!r} 的 default {problem}")
263
+ return self
264
+
265
+ @property
266
+ def is_derived(self) -> bool:
267
+ return bool(self.derived)
268
+
269
+ @property
270
+ def has_constraints(self) -> bool:
271
+ return bool(self.min is not None or self.max is not None or self.choices or self.pattern)
272
+
273
+ @property
274
+ def constraint_text(self) -> str:
275
+ """给人和给 Excel 提示用的一句话约束描述。"""
276
+ parts: list[str] = []
277
+ if self.choices:
278
+ parts.append("可选: " + " / ".join(to_text(item) for item in self.choices))
279
+ if self.pattern:
280
+ parts.append(f"格式: {self.pattern}")
281
+ if self.min is not None and self.max is not None:
282
+ parts.append(f"范围: {_number_text(self.min)} ~ {_number_text(self.max)}")
283
+ elif self.min is not None:
284
+ parts.append(f"范围: >= {_number_text(self.min)}")
285
+ elif self.max is not None:
286
+ parts.append(f"范围: <= {_number_text(self.max)}")
287
+ return ";".join(parts)
288
+
289
+ def value_problem(self, value: Any) -> str | None:
290
+ """检查一个取值是否满足约束;返回问题描述,没问题返回 ``None``。
291
+
292
+ 空值(``""``)在声明了约束时**算不合格** —— ``choices`` 之类的约束意味着"必须有个合法取值"。
293
+ """
294
+ text = to_text(value)
295
+ if self.choices:
296
+ allowed = [to_text(item) for item in self.choices]
297
+ if text not in allowed:
298
+ shown = text if text != "" else "(空)"
299
+ return f"取值 {shown} 不在允许列表 {'/'.join(allowed)} 里"
300
+ if self.pattern is not None and re.fullmatch(self.pattern, text) is None:
301
+ shown = text if text != "" else "(空)"
302
+ return f"取值 {shown} 不匹配格式 {self.pattern}"
303
+ if self.min is not None or self.max is not None:
304
+ number = _as_number(value)
305
+ if number is None:
306
+ shown = text if text != "" else "(空)"
307
+ return f"取值 {shown} 不是数字,但该变量声明了 min/max"
308
+ if self.min is not None and number < self.min:
309
+ return f"取值 {_number_text(number)} 小于下限 {_number_text(self.min)}"
310
+ if self.max is not None and number > self.max:
311
+ return f"取值 {_number_text(number)} 大于上限 {_number_text(self.max)}"
312
+ return None
313
+
314
+
315
+ class GroupConfig(BaseModel):
316
+ """第三层作用域:**成员表**(船 → 工况 → 舱/设备)。
317
+
318
+ 没有它的时候,一个被多个工况引用的舱只能把参数**按工况摊平**(同名舱在每个 Case 列里
319
+ 各写一遍,改一个舱的尺寸要改 N 列)。有了它,舱的参数只写一遍,Case 用一个"指针变量"
320
+ (``key``)指向自己用哪个成员。
321
+
322
+ Excel 布局与 Global / Local 都不同:**一行一个成员,B 列起一个变量一列**
323
+ (B 列表头是变量名)—— 这正是工程师写"舱容表"的习惯,也让公式模式能用与
324
+ global / local 同一形态的 ``INDEX/MATCH`` 定位。
325
+ """
326
+
327
+ model_config = ConfigDict(extra="forbid")
328
+
329
+ #: 成员表的工作表名。
330
+ sheet: str = "Group Data"
331
+ #: 哪个 **local** 变量存"这个 Case 用哪个成员"(成员表里 A 列的名字之一)。
332
+ key: str
333
+ #: init 时先建这几行(可留空:之后再自己在表里插行也行)。
334
+ members: list[str] = Field(default_factory=list)
335
+ #: 成员自己的参数。**不支持 derived**(派生的依赖图只覆盖 global / local)。
336
+ variables: list[VariableDef] = Field(default_factory=list)
337
+
338
+ @field_validator("sheet")
339
+ @classmethod
340
+ def _check_sheet(cls, value: str) -> str:
341
+ return _valid_sheet_name(value)
342
+
343
+ @field_validator("key")
344
+ @classmethod
345
+ def _check_key(cls, value: str) -> str:
346
+ text = to_text(value).strip()
347
+ if not text:
348
+ raise ValueError("variables.group.key 不能为空(它要指向一个 local 变量名)")
349
+ return text
350
+
351
+ @field_validator("members", mode="before")
352
+ @classmethod
353
+ def _check_members(cls, value: Any) -> list[str]:
354
+ if value is None:
355
+ return []
356
+ if isinstance(value, (str, bytes)) or not isinstance(value, (list, tuple)):
357
+ raise ValueError("variables.group.members 必须是字符串列表(也可以留空,之后在表里插行)")
358
+ names = [to_text(item).strip() for item in value]
359
+ if any(not name for name in names):
360
+ raise ValueError("variables.group.members 里有空名字")
361
+ dupes = _duplicates(names)
362
+ if dupes:
363
+ raise ValueError(f"variables.group.members 名字重复: {', '.join(dupes)}")
364
+ return names
365
+
366
+ @model_validator(mode="after")
367
+ def _check_group(self) -> GroupConfig:
368
+ if not self.variables:
369
+ raise ValueError("variables.group.variables 不能为空 —— 不想用第三层作用域就删掉整个 group 段")
370
+ dupes = _duplicates([item.name for item in self.variables])
371
+ if dupes:
372
+ raise ValueError(f"group 变量名重复: {', '.join(dupes)}")
373
+ for variable in self.variables:
374
+ if variable.is_derived:
375
+ raise ValueError(
376
+ f"group 变量 {variable.name!r} 用了 derived —— 派生参数的依赖图目前只覆盖 "
377
+ "global / local;请把它改成填写型,或挪到 local 里"
378
+ )
379
+ return self
380
+
381
+ @property
382
+ def names(self) -> list[str]:
383
+ return [item.name for item in self.variables]
384
+
385
+
386
+ class VariablesConfig(BaseModel):
387
+ """全局变量 + 局部变量 + (可选)成员表。"""
388
+
389
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
390
+
391
+ global_: list[VariableDef] = Field(default_factory=list, alias="global")
392
+ local: list[VariableDef] = Field(default_factory=list)
393
+ #: 第三层作用域:成员表(舱 / 设备)。见 :class:`GroupConfig`。
394
+ group: GroupConfig | None = None
395
+
396
+ @model_validator(mode="after")
397
+ def _check_group_key(self) -> VariablesConfig:
398
+ if self.group is None:
399
+ return self
400
+ if self.group.key not in self.local_names:
401
+ raise ValueError(
402
+ f"variables.group.key {self.group.key!r} 不是 local 变量 —— "
403
+ f"它必须是「每个 Case 一列」的那种变量(当前 local: {', '.join(self.local_names) or '(空)'})"
404
+ )
405
+ if self.group.sheet in {self.group.key}:
406
+ raise ValueError("variables.group.sheet 与变量名冲突")
407
+ clashes = sorted(set(self.group_names) & (set(self.global_names) | set(self.local_names)))
408
+ if clashes:
409
+ raise ValueError(
410
+ f"group 变量与 global / local 重名: {', '.join(clashes)} —— "
411
+ "同名会让人分不清用的是哪一个,请改名(第三层作用域的名字必须独立)"
412
+ )
413
+ return self
414
+
415
+ @property
416
+ def global_names(self) -> list[str]:
417
+ return [item.name for item in self.global_]
418
+
419
+ @property
420
+ def local_names(self) -> list[str]:
421
+ return [item.name for item in self.local]
422
+
423
+ @property
424
+ def group_variables(self) -> list[VariableDef]:
425
+ return list(self.group.variables) if self.group else []
426
+
427
+ @property
428
+ def group_names(self) -> list[str]:
429
+ return self.group.names if self.group else []
430
+
431
+ @property
432
+ def has_group(self) -> bool:
433
+ return self.group is not None
434
+
435
+
436
+ # --------------------------------------------------------------------------- #
437
+ # Excel 结构
438
+ # --------------------------------------------------------------------------- #
439
+ class ExcelSheets(BaseModel):
440
+ """工作表名称映射。"""
441
+
442
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
443
+
444
+ global_: str = Field("Global Parameter", alias="global")
445
+ local: str = "Local Parameter"
446
+ outputs: list[str] = Field(default_factory=lambda: ["Output"])
447
+
448
+ @field_validator("global_", "local")
449
+ @classmethod
450
+ def _check_sheet_name(cls, value: str) -> str:
451
+ return _valid_sheet_name(value)
452
+
453
+ @field_validator("outputs")
454
+ @classmethod
455
+ def _check_outputs(cls, value: list[str]) -> list[str]:
456
+ names = [to_text(item).strip() for item in value]
457
+ if not names or any(not item for item in names):
458
+ raise ValueError("excel.sheets.outputs 至少需要一个非空的工作表名")
459
+ dupes = _duplicates(names)
460
+ if dupes:
461
+ raise ValueError(f"excel.sheets.outputs 中工作表名重复: {', '.join(dupes)}")
462
+ return names
463
+
464
+
465
+ class ExcelConfig(BaseModel):
466
+ """Excel 模板文件的整体配置。"""
467
+
468
+ model_config = ConfigDict(extra="forbid")
469
+
470
+ output: str = "template.xlsx"
471
+ template_sheet: str | None = "Template"
472
+ #: 工作簿里的"使用说明"表(放在第一张)。写清三步、命令、生成时间与指纹;
473
+ #: 由 create_template 生成、write_results 刷新。设为 null 则不生成。
474
+ howto_sheet: str | None = "HOWTO"
475
+ # pydantic 的 default_factory 接受类本身;mypy 对它的签名判断过严,这里显式放行
476
+ sheets: ExcelSheets = Field(default_factory=ExcelSheets) # type: ignore[arg-type]
477
+ #: **Local 表的工况排布**:``horizontal``(默认)= 一个工况一列;``vertical`` = 一个工况一行。
478
+ #: 与每个模板自己的 ``direction``(**输出**排布)**互相独立** —— 输入竖着填、输出横着写都可以。
479
+ #: 一行一个工况方便"整块粘贴":有些软件里工况控制语句就是按行给的(见指南 §19)。
480
+ local_direction: Direction = "horizontal"
481
+
482
+ @field_validator("output")
483
+ @classmethod
484
+ def _check_output(cls, value: str) -> str:
485
+ name = to_text(value).strip()
486
+ if not name:
487
+ raise ValueError("excel.output 不能为空")
488
+ return name
489
+
490
+ @field_validator("template_sheet", "howto_sheet")
491
+ @classmethod
492
+ def _check_optional_sheet_name(cls, value: str | None) -> str | None:
493
+ if value is None or to_text(value).strip() == "":
494
+ return None
495
+ return _valid_sheet_name(value)
496
+
497
+
498
+ # --------------------------------------------------------------------------- #
499
+ # 模板定义
500
+ # --------------------------------------------------------------------------- #
501
+ class TemplateDef(BaseModel):
502
+ """一个输出模板:源码 + 输出位置 + 布局方向。"""
503
+
504
+ model_config = ConfigDict(extra="forbid")
505
+
506
+ name: str
507
+ description: str = ""
508
+ output_sheet: str = "Output"
509
+ start_cell: str = "B2"
510
+ direction: Direction = "horizontal"
511
+ write_case_headers: bool = True
512
+ filename: str | None = None
513
+ extension: str = ".txt"
514
+ code: str | None = None
515
+ template_file: str | None = None
516
+ #: 输出引擎。**默认 ``excel``(写公式)** —— 本工具的用法是"生成一次工作簿,之后就用
517
+ #: Excel 干活":公式模式下改参数由 Excel 自己重算,不用再跑命令,工作簿**独立可用**。
518
+ #: ``snapshot``(写文本快照)只在下面这些情况才需要,且必须**显式**声明:
519
+ #:
520
+ #: * 模板里有 ``{% for %}`` / 过滤器 / 多行 ``{% if %}`` / ``{% include %}``(公式模式表达不了);
521
+ #: * 想让导出的代码文件"改完参数自动同步"(公式模式的值只活在 Excel 里,
522
+ #: ``--outdir`` 仍然要走命令行)。
523
+ engine: Engine = "excel"
524
+ #: 可选:Jinja2 表达式,对每个 Case 的上下文求值;为假则该模板跳过这个 Case。
525
+ #: 例如 ``case_filter: "kind == 'EXT'"``。用于"一本工作簿放两套规则"的场景。
526
+ case_filter: str | None = None
527
+
528
+ #: 声明这个模板(以及它的 ``template_file``)的文件所在目录。
529
+ #: 只有 ``extends`` 合并进来的模板才需要它 —— 那些相对路径要相对**声明它的那个文件**解析,
530
+ #: 而不是相对最终的项目 YAML。``None`` 表示"就是配置文件自己",用 ``config.source_dir``。
531
+ source_dir: Path | None = Field(default=None, exclude=True)
532
+
533
+ @field_validator("name")
534
+ @classmethod
535
+ def _check_name(cls, value: str) -> str:
536
+ name = to_text(value).strip()
537
+ if not name:
538
+ raise ValueError("模板名不能为空")
539
+ return name
540
+
541
+ @field_validator("description", mode="before")
542
+ @classmethod
543
+ def _description_to_str(cls, value: Any) -> str:
544
+ return "" if value is None else to_text(value)
545
+
546
+ @field_validator("case_filter", mode="before")
547
+ @classmethod
548
+ def _case_filter_to_str(cls, value: Any) -> str | None:
549
+ if value is None:
550
+ return None
551
+ text = to_text(value).strip()
552
+ return text or None
553
+
554
+ @field_validator("extension")
555
+ @classmethod
556
+ def _check_extension(cls, value: str) -> str:
557
+ ext = to_text(value).strip()
558
+ if not ext:
559
+ return ""
560
+ return ext if ext.startswith(".") else f".{ext}"
561
+
562
+ @field_validator("start_cell")
563
+ @classmethod
564
+ def _check_start_cell(cls, value: str) -> str:
565
+ try:
566
+ parse_cell(value)
567
+ except ValueError as exc: # pragma: no cover - 信息透传
568
+ raise ValueError(str(exc)) from exc
569
+ return to_text(value).strip().upper()
570
+
571
+ @model_validator(mode="after")
572
+ def _check_source(self) -> TemplateDef:
573
+ if not (self.code and self.code.strip()) and not self.template_file:
574
+ raise ValueError("必须提供 code(内联模板)或 template_file(外部模板文件)之一")
575
+ if self.filename and not self.filename.strip():
576
+ raise ValueError("filename 不能为空字符串(如需默认命名请删除该字段)")
577
+ return self
578
+
579
+ @property
580
+ def source_code(self) -> str:
581
+ """内联模板源码(``template_file`` 场景下为空字符串)。"""
582
+ return self.code or ""
583
+
584
+
585
+ # --------------------------------------------------------------------------- #
586
+ # 顶层配置
587
+ # --------------------------------------------------------------------------- #
588
+ class ProjectConfig(BaseModel):
589
+ """整个项目的配置根对象。"""
590
+
591
+ model_config = ConfigDict(extra="forbid")
592
+
593
+ version: int = 1
594
+ excel: ExcelConfig = Field(default_factory=ExcelConfig)
595
+ variables: VariablesConfig = Field(default_factory=VariablesConfig)
596
+ templates: list[TemplateDef]
597
+
598
+ #: **跨变量校验**:对**每个 Case** 求值的 Jinja 表达式,结果为假就报错(见指南 §3.6)。
599
+ #: 单变量约束(``min`` / ``max`` / ``choices`` / ``pattern``)拦不住"吃水不能超过型深"
600
+ #: 这类**组合**错误,这一层补的就是它。写法与 ``case_filter`` 一样:裸变量是组合值,
601
+ #: **数值比较请写 ``.value``** —— ``asserts: ["draft.value <= d_tank.value"]``。
602
+ asserts: list[str] = Field(default_factory=list)
603
+
604
+ #: 配置文件所在目录(加载时自动填充,用于解析相对路径的 template_file)。
605
+ source_dir: Path | None = Field(default=None, exclude=True)
606
+ #: 配置文件本身的绝对路径(加载时自动填充)。生成的运行脚本与 HOWTO 表要用它。
607
+ config_path: Path | None = Field(default=None, exclude=True)
608
+ #: ``extends`` 指向的其他 YAML(相对本文件解析)。加载时会先合并它们,见 §16。
609
+ #: 加载后这里保留的是**本文件写的原始列表**,方便调用方知道配置由哪些文件组成。
610
+ extends: list[str] = Field(default_factory=list, exclude=True)
611
+ #: 合并 ``extends`` 时的告警(例如同名变量的 ``default`` 不一致),由 CLI 打印出来。
612
+ load_warnings: list[str] = Field(default_factory=list, exclude=True)
613
+ #: 工作簿旁边**是否真的有一键刷新脚本**(由 create_template / write_results 填)。
614
+ #: HOWTO 表据此决定要不要写「懒得开终端就双击那个脚本」。
615
+ scripts_enabled: bool = Field(default=False, exclude=True)
616
+
617
+ @field_validator("asserts", mode="before")
618
+ @classmethod
619
+ def _check_asserts(cls, value: Any) -> list[str]:
620
+ if value is None:
621
+ return []
622
+ if isinstance(value, (str, bytes)) or not isinstance(value, (list, tuple)):
623
+ raise ValueError('asserts 必须是表达式列表,例如 asserts: ["draft.value <= d_tank.value"]')
624
+ out: list[str] = []
625
+ for index, item in enumerate(value, start=1):
626
+ text = to_text(item).strip()
627
+ if not text:
628
+ raise ValueError(f"asserts 第 {index} 条是空的(不想要就删掉它)")
629
+ out.append(text)
630
+ return out
631
+
632
+ @model_validator(mode="after")
633
+ def _validate_config(self) -> ProjectConfig:
634
+ if self.version < 1:
635
+ raise ValueError("version 必须 >= 1")
636
+ if not self.templates:
637
+ raise ValueError("至少需要定义一个 template")
638
+
639
+ template_names = [item.name for item in self.templates]
640
+ dupes = _duplicates(template_names)
641
+ if dupes:
642
+ raise ValueError(f"template 名称重复: {', '.join(dupes)}")
643
+
644
+ for scope, names in (
645
+ ("global", self.variables.global_names),
646
+ ("local", self.variables.local_names),
647
+ ):
648
+ dupes = _duplicates(names)
649
+ if dupes:
650
+ raise ValueError(f"{scope} 变量名重复: {', '.join(dupes)}")
651
+
652
+ outputs = set(self.excel.sheets.outputs)
653
+ for template in self.templates:
654
+ if template.output_sheet not in outputs:
655
+ raise ValueError(
656
+ f"template {template.name!r} 的 output_sheet {template.output_sheet!r} "
657
+ f"未在 excel.sheets.outputs 中声明(当前: {', '.join(self.excel.sheets.outputs)})"
658
+ )
659
+
660
+ used = {self.excel.sheets.global_, self.excel.sheets.local, *outputs}
661
+ if self.group is not None:
662
+ if self.group.sheet in used:
663
+ raise ValueError(
664
+ f"variables.group.sheet {self.group.sheet!r} 与 Global / Local / Output 表名冲突,请改名"
665
+ )
666
+ used.add(self.group.sheet)
667
+ for label, sheet_name in (
668
+ ("excel.template_sheet", self.excel.template_sheet),
669
+ ("excel.howto_sheet", self.excel.howto_sheet),
670
+ ):
671
+ if sheet_name and sheet_name in used:
672
+ raise ValueError(f"{label} {sheet_name!r} 与 Global / Local / Output 表名冲突,请改名或设为 null")
673
+ if self.excel.template_sheet and self.excel.howto_sheet and self.excel.template_sheet == self.excel.howto_sheet:
674
+ raise ValueError(
675
+ f"excel.template_sheet 与 excel.howto_sheet 不能同名(都是 {self.excel.template_sheet!r})"
676
+ )
677
+ return self
678
+
679
+ # -- 便捷访问 ---------------------------------------------------------- #
680
+ @property
681
+ def global_variables(self) -> list[VariableDef]:
682
+ return list(self.variables.global_)
683
+
684
+ @property
685
+ def local_variables(self) -> list[VariableDef]:
686
+ return list(self.variables.local)
687
+
688
+ @property
689
+ def global_names(self) -> list[str]:
690
+ return self.variables.global_names
691
+
692
+ @property
693
+ def local_names(self) -> list[str]:
694
+ return self.variables.local_names
695
+
696
+ @property
697
+ def group(self):
698
+ """第三层作用域的成员表配置(没配就是 ``None``)。"""
699
+ return self.variables.group
700
+
701
+ @property
702
+ def group_variables(self) -> list[VariableDef]:
703
+ return self.variables.group_variables
704
+
705
+ @property
706
+ def group_names(self) -> list[str]:
707
+ return self.variables.group_names
708
+
709
+ @property
710
+ def has_group(self) -> bool:
711
+ return self.variables.has_group
712
+
713
+ @property
714
+ def defined_names(self) -> set[str]:
715
+ """所有已定义变量名(用于校验模板引用的变量是否存在)。"""
716
+ return set(self.global_names) | set(self.local_names) | set(self.group_names) | set(RESERVED_NAMES)
717
+
718
+ def template_by_name(self, name: str) -> TemplateDef | None:
719
+ for template in self.templates:
720
+ if template.name == name:
721
+ return template
722
+ return None
723
+
724
+
725
+ class _StrictLoader(yaml.SafeLoader):
726
+ """SafeLoader + 同一映射里的重复键检测。
727
+
728
+ PyYAML 默认对重复键**不报错**(后者覆盖前者),于是 YAML 里写两个 ``variables:``
729
+ 会让第一个整块静默消失,错误要到渲染期才以"变量缺失"的形式暴露。
730
+ """
731
+
732
+
733
+ def _construct_mapping_strict(loader: yaml.SafeLoader, node: yaml.MappingNode, deep: bool = False) -> dict:
734
+ mapping: dict = {}
735
+ for key_node, value_node in node.value:
736
+ key = loader.construct_object(key_node, deep=deep)
737
+ try:
738
+ duplicated = key in mapping
739
+ except TypeError as exc: # 不可哈希的复杂键
740
+ raise ConfigError(f"YAML 键非法(不可哈希): 第 {key_node.start_mark.line + 1} 行") from exc
741
+ if duplicated:
742
+ raise ConfigError(
743
+ f"YAML 键重复: {key!r}(第 {key_node.start_mark.line + 1} 行)"
744
+ "—— 同一个映射里同一个键只能出现一次(重复会让前一份被静默覆盖)"
745
+ )
746
+ mapping[key] = loader.construct_object(value_node, deep=deep)
747
+ return mapping
748
+
749
+
750
+ _StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping_strict)
751
+
752
+
753
+ def _read_yaml_mapping(path: Path) -> dict:
754
+ """读一个 YAML 文件并要求根节点是映射;失败时抛带位置的 :class:`ConfigError`。"""
755
+ if not path.exists():
756
+ raise ConfigError(f"配置文件不存在: {path}")
757
+ if path.is_dir():
758
+ raise ConfigError(f"配置路径是目录而不是文件: {path}")
759
+ try:
760
+ raw_text = path.read_text(encoding="utf-8")
761
+ except OSError as exc:
762
+ raise ConfigError(f"无法读取配置文件 {path}: {exc}") from exc
763
+ try:
764
+ raw = yaml.load(raw_text, Loader=_StrictLoader)
765
+ except ConfigError:
766
+ raise
767
+ except yaml.YAMLError as exc:
768
+ mark = getattr(exc, "problem_mark", None)
769
+ where = f"(第 {mark.line + 1} 行,第 {mark.column + 1} 列)" if mark else ""
770
+ raise ConfigError(f"YAML 解析失败 {path}{where}:\n {exc}") from exc
771
+ if raw is None:
772
+ raise ConfigError(f"配置文件内容为空: {path}")
773
+ if not isinstance(raw, dict):
774
+ raise ConfigError(f"配置文件根节点必须是映射(mapping),实际是 {type(raw).__name__}: {path}")
775
+ return raw
776
+
777
+
778
+ #: extends 合并时,同名变量之间**必须逐字一致**的字段 —— 它们决定生成出来的文本。
779
+ #: 和 ``abs_fpi/compose.py`` 的 STRICT_FIELDS 是同一套判据,只是把取值约束也纳入了。
780
+ EXTENDS_STRICT_FIELDS: tuple[str, ...] = (
781
+ "prefix",
782
+ "suffix",
783
+ "type",
784
+ "derived",
785
+ "min",
786
+ "max",
787
+ "choices",
788
+ "pattern",
789
+ )
790
+ #: 这些字段不一致只告警(不同规则集的示例工况本来就不同),保留先出现的那个。
791
+ EXTENDS_LOOSE_FIELDS: tuple[str, ...] = ("default", "description")
792
+
793
+
794
+ def _merge_variable(
795
+ existing: dict, incoming: dict, *, scope: str, origin: Path, first_seen: Path, warnings: list[str]
796
+ ) -> None:
797
+ name = incoming.get("name")
798
+ for key in EXTENDS_STRICT_FIELDS:
799
+ before, after = existing.get(key), incoming.get(key)
800
+ if before != after:
801
+ raise ConfigError(
802
+ f"变量 {name!r} 在 {scope} 里被 {first_seen} 与 {origin} 定义成不同的 {key}:"
803
+ f"{before!r} vs {after!r}。这些字段决定生成出来的文本,必须先统一。"
804
+ )
805
+ for key in EXTENDS_LOOSE_FIELDS:
806
+ if existing.get(key) != incoming.get(key):
807
+ warnings.append(
808
+ f"变量 {name!r}({scope})的 {key} 在两处不一致:保留 {first_seen} 的 "
809
+ f"{existing.get(key)!r},忽略 {origin} 的 {incoming.get(key)!r}"
810
+ )
811
+
812
+
813
+ def _merge_into(
814
+ merged: dict,
815
+ origins: dict[str, Path],
816
+ raw: dict,
817
+ *,
818
+ origin: Path,
819
+ is_root: bool,
820
+ warnings: list[str],
821
+ ) -> None:
822
+ """把一个文件的 ``variables`` / ``templates`` 合并进累积结果。"""
823
+ if not is_root and raw.get("excel"):
824
+ warnings.append(f"忽略 {origin} 里的 excel 配置(工作簿布局以根配置文件为准)")
825
+ variables_raw = raw.get("variables")
826
+ if not is_root and isinstance(variables_raw, dict) and variables_raw.get("group"):
827
+ warnings.append(f"忽略 {origin} 里的 variables.group(成员表只有一份,以根配置文件为准)")
828
+
829
+ variables = raw.get("variables") or {}
830
+ if not isinstance(variables, dict):
831
+ variables = {}
832
+ if is_root and isinstance(variables.get("group"), dict):
833
+ merged["variables"]["group"] = dict(variables["group"])
834
+ for scope in ("global", "local"):
835
+ for item in variables.get(scope) or []:
836
+ if not isinstance(item, dict) or "name" not in item:
837
+ merged["variables"][scope].append(item) # 交给 pydantic 报错,信息更统一
838
+ continue
839
+ name = item["name"]
840
+ existing = next(
841
+ (v for v in merged["variables"][scope] if isinstance(v, dict) and v.get("name") == name),
842
+ None,
843
+ )
844
+ if existing is None:
845
+ merged["variables"][scope].append(dict(item))
846
+ origins.setdefault(f"{scope}:{name}", origin)
847
+ continue
848
+ _merge_variable(
849
+ existing,
850
+ item,
851
+ scope=scope,
852
+ origin=origin,
853
+ first_seen=origins.get(f"{scope}:{name}", origin),
854
+ warnings=warnings,
855
+ )
856
+
857
+ # 跨变量校验都保留下来(顺序:先被 extends 的在前),互不覆盖
858
+ merged["asserts"].extend(raw.get("asserts") or [])
859
+
860
+ for template in raw.get("templates") or []:
861
+ if not isinstance(template, dict):
862
+ merged["templates"].append(template)
863
+ continue
864
+ name = template.get("name")
865
+ same = next((t for t in merged["templates"] if isinstance(t, dict) and t.get("name") == name), None)
866
+ if same is not None:
867
+ if yaml.safe_dump(same, sort_keys=True, allow_unicode=True) != yaml.safe_dump(
868
+ template, sort_keys=True, allow_unicode=True
869
+ ):
870
+ raise ConfigError(
871
+ f"模板 {name!r} 在 {origins.get(f'template:{name}', origin)} 与 {origin} "
872
+ "里都定义了,但内容不同 —— 模板名必须唯一,请改名或统一内容"
873
+ )
874
+ continue
875
+ merged["templates"].append(dict(template))
876
+ origins.setdefault(f"template:{name}", origin)
877
+
878
+
879
+ def _load_with_extends(
880
+ config_path: Path, *, chain: tuple[Path, ...] = (), warnings: list[str]
881
+ ) -> tuple[dict, dict[str, Path]]:
882
+ """递归展开 ``extends``,返回合并后的 raw dict 与"每项来自哪个目录"的索引。"""
883
+ resolved = config_path.resolve()
884
+ if resolved in chain:
885
+ loop = " → ".join(str(p) for p in (*chain, resolved))
886
+ raise ConfigError(f"extends 出现循环引用: {loop}")
887
+
888
+ origin_dir = resolved.parent
889
+ raw = _read_yaml_mapping(config_path)
890
+ parents = raw.get("extends") or []
891
+ if not isinstance(parents, list) or not all(isinstance(p, str) for p in parents):
892
+ raise ConfigError(f"extends 必须是文件路径的列表,例如 extends: [rules/a.yaml, rules/b.yaml]: {config_path}")
893
+
894
+ merged: dict = {"variables": {"global": [], "local": []}, "templates": [], "asserts": []}
895
+ origins: dict[str, Path] = {}
896
+
897
+ for relative in parents:
898
+ parent_path = (origin_dir / relative).resolve()
899
+ if not parent_path.exists():
900
+ raise ConfigError(f"extends 指向的文件不存在: {parent_path}(写在 {config_path})")
901
+ parent_raw, parent_origins = _load_with_extends(parent_path, chain=(*chain, resolved), warnings=warnings)
902
+ _merge_into(
903
+ merged,
904
+ origins,
905
+ parent_raw,
906
+ origin=parent_path.parent,
907
+ is_root=False,
908
+ warnings=warnings,
909
+ )
910
+ origins.update(parent_origins)
911
+
912
+ _merge_into(merged, origins, raw, origin=origin_dir, is_root=True, warnings=warnings)
913
+ merged["version"] = raw.get("version", 1)
914
+ merged["excel"] = raw.get("excel", {})
915
+ return merged, origins
916
+
917
+
918
+ def load_config(path: str | Path) -> ProjectConfig:
919
+ """读取并校验 YAML 配置,失败时抛出带清晰提示的 :class:`ConfigError`。
920
+
921
+ 配置里写了 ``extends: [a.yaml, b.yaml]`` 时会先递归合并那些文件(见指南 §16):
922
+ 同名变量的 ``prefix`` / ``suffix`` / ``type`` / 取值约束必须一致,``default`` 与
923
+ ``description`` 不一致只告警;同名模板内容必须一致。
924
+ """
925
+ config_path = Path(path)
926
+ warnings: list[str] = []
927
+
928
+ probe = _read_yaml_mapping(config_path)
929
+
930
+ if isinstance(probe.get("extends"), list) and probe["extends"]:
931
+ raw, origins = _load_with_extends(config_path, warnings=warnings)
932
+ else:
933
+ raw, origins = probe, {}
934
+
935
+ try:
936
+ config = ProjectConfig.model_validate(raw)
937
+ except ValidationError as exc:
938
+ raise ConfigError(f"配置校验失败 {config_path}:\n{format_validation_error(exc)}") from exc
939
+
940
+ config.source_dir = config_path.resolve().parent
941
+ config.config_path = config_path.resolve()
942
+ config.load_warnings = warnings
943
+ config.extends = [str(item) for item in (probe.get("extends") or [])]
944
+ # 每个模板记住"声明它的那个文件在哪",这样 extends 进来的 template_file 相对路径仍然解析得对
945
+ for template in config.templates:
946
+ origin = origins.get(f"template:{template.name}")
947
+ if origin is not None and origin != config.source_dir:
948
+ template.source_dir = origin
949
+ return config
950
+
951
+
952
+ # --------------------------------------------------------------------------- #
953
+ # 运行期数据
954
+ # --------------------------------------------------------------------------- #
955
+ @dataclass(frozen=True)
956
+ class CaseData:
957
+ """Local Parameter 表里一个 Case 的取值。
958
+
959
+ 位置二选一:``horizontal`` 布局一个工况占**一列**(``column``),
960
+ ``vertical`` 布局一个工况占**一行**(``row``)。
961
+ """
962
+
963
+ name: str
964
+ #: 横向布局:这个 Case 的列号。
965
+ column: int | None = None
966
+ values: dict[str, VarValue] = field(default_factory=dict)
967
+ #: 这一列/行在表里是否**至少填过一个**局部变量的值;
968
+ #: 全空时所有变量都会回落 YAML ``default``(新插的空列/空行就此"悄悄"落进某个规则集)。
969
+ explicit_values: bool = True
970
+ #: 纵向布局:这个 Case 的行号。
971
+ row: int | None = None
972
+
973
+ @property
974
+ def index(self) -> int:
975
+ """Case 序号(Case1 -> 1)。"""
976
+ if self.row is not None:
977
+ return self.row - 1
978
+ assert self.column is not None
979
+ return self.column - FIRST_CASE_COLUMN + 1
980
+
981
+ @property
982
+ def where(self) -> str:
983
+ """给人看的定位(横向说"第几列",纵向说"第几行")。"""
984
+ if self.row is not None:
985
+ return f"第 {self.row} 行"
986
+ assert self.column is not None
987
+ return f"第 {column_index_to_letter(self.column)} 列"
988
+
989
+
990
+ @dataclass(frozen=True)
991
+ class RenderResult:
992
+ """某个模板在某个 Case 下的渲染结果。"""
993
+
994
+ template_name: str
995
+ case_name: str
996
+ text: str
997
+ #: 渲染这一份结果时用的上下文(变量 -> :class:`VarValue`)。
998
+ #: 导出文件名(``template.filename``)里可以用到任意参数,比如只用来排序的 ``seq``。
999
+ context: Mapping[str, Any] = field(default_factory=dict)
1000
+
1001
+ @property
1002
+ def lines(self) -> list[str]:
1003
+ """渲染结果的行列表(不含结尾空行),用于写入 Excel。"""
1004
+ return split_lines(self.text)
1005
+
1006
+ @property
1007
+ def line_count(self) -> int:
1008
+ return len(self.lines)