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.
- excel_codegen/__init__.py +160 -0
- excel_codegen/__main__.py +8 -0
- excel_codegen/cli.py +1250 -0
- excel_codegen/derived.py +465 -0
- excel_codegen/example_pack.py +107 -0
- excel_codegen/examples/README.md +69 -0
- excel_codegen/examples/abs_fpi/ABS_FPI_load_cases.xlsx +0 -0
- excel_codegen/examples/abs_fpi/ABS_FPI_load_cases_render.bat +22 -0
- excel_codegen/examples/abs_fpi/ABS_FPI_load_cases_render.sh +23 -0
- excel_codegen/examples/abs_fpi/README.md +121 -0
- excel_codegen/examples/abs_fpi/abs_fpi.yaml +310 -0
- excel_codegen/examples/abs_fpi/abs_fpi_external.xlsx +0 -0
- excel_codegen/examples/abs_fpi/abs_fpi_external.yaml +162 -0
- excel_codegen/examples/abs_fpi/abs_fpi_external_render.bat +22 -0
- excel_codegen/examples/abs_fpi/abs_fpi_external_render.sh +23 -0
- excel_codegen/examples/abs_fpi/abs_fpi_internal.xlsx +0 -0
- excel_codegen/examples/abs_fpi/abs_fpi_internal.yaml +266 -0
- excel_codegen/examples/abs_fpi/abs_fpi_internal_render.bat +22 -0
- excel_codegen/examples/abs_fpi/abs_fpi_internal_render.sh +23 -0
- excel_codegen/examples/abs_fpi/compose.yaml +20 -0
- excel_codegen/examples/abs_fpi/generated/COT1-d20.559-mu60.js +124 -0
- excel_codegen/examples/abs_fpi/generated/COT1-d20.559-mu60_summary.md +30 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T15-mu0-kf-1.js +91 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T15-mu0-kf-1_summary.md +23 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T15-mu90-kf+1.js +91 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T15-mu90-kf+1_summary.md +23 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T20.559-mu90-kf+1.js +91 -0
- excel_codegen/examples/abs_fpi/generated/EXT-T20.559-mu90-kf+1_summary.md +23 -0
- excel_codegen/examples/abs_fpi/generated/WBT6-d15.059-mu0.js +124 -0
- excel_codegen/examples/abs_fpi/generated/WBT6-d15.059-mu0_summary.md +30 -0
- excel_codegen/examples/abs_fpi/generated/WBT6-d8-mu90.js +124 -0
- excel_codegen/examples/abs_fpi/generated/WBT6-d8-mu90_summary.md +30 -0
- excel_codegen/examples/abs_fpi/generated/WBT7-d20.559-mu90.js +124 -0
- excel_codegen/examples/abs_fpi/generated/WBT7-d20.559-mu90_summary.md +30 -0
- excel_codegen/examples/abs_fpi/templates/ext_body.js.j2 +91 -0
- excel_codegen/examples/abs_fpi/templates/ext_summary.md.j2 +23 -0
- excel_codegen/examples/abs_fpi/templates/int_body.js.j2 +124 -0
- excel_codegen/examples/abs_fpi/templates/int_summary.md.j2 +30 -0
- excel_codegen/examples/basic/example.yaml +97 -0
- excel_codegen/examples/basic/example_formula.yaml +86 -0
- excel_codegen/examples/basic/generated/uart_init_Case1.c +6 -0
- excel_codegen/examples/basic/generated/uart_init_Case2.c +6 -0
- excel_codegen/examples/basic/generated/uart_summary_Case1.md +4 -0
- excel_codegen/examples/basic/generated/uart_summary_Case2.md +4 -0
- excel_codegen/examples/basic/generated_formula/uart_init_Case1.c +5 -0
- excel_codegen/examples/basic/generated_formula/uart_init_Case2.c +5 -0
- excel_codegen/examples/basic/generated_formula/uart_pins_Case1.csv +1 -0
- excel_codegen/examples/basic/generated_formula/uart_pins_Case2.csv +1 -0
- excel_codegen/examples/basic/template.xlsx +0 -0
- excel_codegen/examples/basic/template_formula.xlsx +0 -0
- excel_codegen/examples/basic/template_formula_render.bat +22 -0
- excel_codegen/examples/basic/template_formula_render.sh +23 -0
- excel_codegen/examples/basic/template_render.bat +22 -0
- excel_codegen/examples/basic/template_render.sh +23 -0
- excel_codegen/examples/nastran/generated/case_control.deck +18 -0
- excel_codegen/examples/nastran/generated/cc_01_LC1.inc +8 -0
- excel_codegen/examples/nastran/generated/cc_02_LC2.inc +8 -0
- excel_codegen/examples/nastran/generated/cc_03_LC3.inc +8 -0
- excel_codegen/examples/nastran/generated/cc_04_COMB4.inc +8 -0
- excel_codegen/examples/nastran/generated/cc_05_LC5.inc +8 -0
- excel_codegen/examples/nastran/generated/cc_summary_01_LC1.md +1 -0
- excel_codegen/examples/nastran/generated/cc_summary_02_LC2.md +1 -0
- excel_codegen/examples/nastran/generated/cc_summary_03_LC3.md +1 -0
- excel_codegen/examples/nastran/generated/cc_summary_04_COMB4.md +1 -0
- excel_codegen/examples/nastran/generated/cc_summary_05_LC5.md +1 -0
- excel_codegen/examples/nastran/nastran_case_control.xlsx +0 -0
- excel_codegen/examples/nastran/nastran_case_control.yaml +141 -0
- excel_codegen/examples/nastran/nastran_case_control_render.bat +22 -0
- excel_codegen/examples/nastran/nastran_case_control_render.sh +23 -0
- excel_codegen/excel_io.py +1885 -0
- excel_codegen/formula.py +674 -0
- excel_codegen/formula_eval.py +685 -0
- excel_codegen/jinja_env.py +92 -0
- excel_codegen/models.py +1008 -0
- excel_codegen/renderer.py +558 -0
- excel_codegen/utils.py +225 -0
- excel_codegen-0.9.0.dist-info/METADATA +179 -0
- excel_codegen-0.9.0.dist-info/RECORD +82 -0
- excel_codegen-0.9.0.dist-info/WHEEL +5 -0
- excel_codegen-0.9.0.dist-info/entry_points.txt +2 -0
- excel_codegen-0.9.0.dist-info/licenses/LICENSE +21 -0
- excel_codegen-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""渲染核心:组装上下文、渲染模板、导出代码文件。
|
|
2
|
+
|
|
3
|
+
Jinja2 环境与 ``pvs`` / ``wrap`` 过滤器在 :mod:`excel_codegen.jinja_env` 里
|
|
4
|
+
(``derived`` 求值也要用同一套语义),这里只是转出(re-export)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from jinja2 import Environment, TemplateError, TemplateNotFound, TemplateSyntaxError, UndefinedError, meta
|
|
15
|
+
|
|
16
|
+
from .excel_io import (
|
|
17
|
+
check_required_sheets,
|
|
18
|
+
check_value_constraints,
|
|
19
|
+
load_workbook_file,
|
|
20
|
+
read_cases,
|
|
21
|
+
read_global_values,
|
|
22
|
+
read_group_members,
|
|
23
|
+
)
|
|
24
|
+
from .jinja_env import build_environment, pvs, wrap
|
|
25
|
+
from .models import CaseData, ProjectConfig, RenderResult, TemplateDef
|
|
26
|
+
from .utils import ExcelError, RenderError, VarValue, safe_filename, to_text
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"FilterValue",
|
|
30
|
+
"RenderOutput",
|
|
31
|
+
"build_context",
|
|
32
|
+
"build_environment",
|
|
33
|
+
"case_matches",
|
|
34
|
+
"check_asserts",
|
|
35
|
+
"collect_variables",
|
|
36
|
+
"compile_asserts",
|
|
37
|
+
"compile_case_filter",
|
|
38
|
+
"export_files",
|
|
39
|
+
"filter_context",
|
|
40
|
+
"member_of",
|
|
41
|
+
"pvs",
|
|
42
|
+
"render_all",
|
|
43
|
+
"render_template",
|
|
44
|
+
"template_environment",
|
|
45
|
+
"validate_template",
|
|
46
|
+
"wrap",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _template_source(template: TemplateDef, base_dir: str | Path | None) -> str:
|
|
51
|
+
"""返回模板源码;``template_file`` 优先读取外部文件(相对**声明它的那个文件**所在目录)。
|
|
52
|
+
|
|
53
|
+
``extends`` 合并进来的模板会带自己的 ``source_dir`` —— 它的相对路径要相对原文件解析,
|
|
54
|
+
而不是相对最终的项目 YAML。
|
|
55
|
+
"""
|
|
56
|
+
if template.template_file:
|
|
57
|
+
path = Path(template.template_file)
|
|
58
|
+
base = template.source_dir or base_dir
|
|
59
|
+
if base and not path.is_absolute():
|
|
60
|
+
path = Path(base) / path
|
|
61
|
+
if not path.exists():
|
|
62
|
+
raise RenderError(f"模板 {template.name!r} 引用的模板文件不存在: {path}")
|
|
63
|
+
try:
|
|
64
|
+
return path.read_text(encoding="utf-8")
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
raise RenderError(f"无法读取模板文件 {path}: {exc}") from exc
|
|
67
|
+
return template.source_code
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def template_environment(
|
|
71
|
+
template: TemplateDef,
|
|
72
|
+
base_dir: str | Path | None,
|
|
73
|
+
env: Environment | None = None,
|
|
74
|
+
) -> Environment:
|
|
75
|
+
"""给某个模板准备 Jinja 环境。
|
|
76
|
+
|
|
77
|
+
快照模式的模板可以 ``{% include "片段.j2" %}``,片段按**声明模板的那个文件**所在目录去找
|
|
78
|
+
(``extends`` 进来的模板带自己的 ``source_dir``)。给了 ``env`` 就照用,不覆盖调用方的设置。
|
|
79
|
+
"""
|
|
80
|
+
if env is not None:
|
|
81
|
+
return env
|
|
82
|
+
base = template.source_dir or base_dir
|
|
83
|
+
return build_environment(search_path=[base] if base else None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _include_names(ast: Any) -> list[str]:
|
|
87
|
+
"""模板里 ``{% include %}`` 引用的片段名(动态名字取不到,跳过)。"""
|
|
88
|
+
return [name for name in (meta.find_referenced_templates(ast) or []) if isinstance(name, str)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_includes(
|
|
92
|
+
template: TemplateDef,
|
|
93
|
+
ast: Any,
|
|
94
|
+
environment: Environment,
|
|
95
|
+
*,
|
|
96
|
+
base_dir: str | Path | None,
|
|
97
|
+
seen: frozenset[str] = frozenset(),
|
|
98
|
+
) -> list[tuple[str, Any]]:
|
|
99
|
+
"""检查 ``{% include %}`` 的片段都存在,并递归返回 ``[(片段名, 语法树), …]``。"""
|
|
100
|
+
found: list[tuple[str, Any]] = []
|
|
101
|
+
for name in _include_names(ast):
|
|
102
|
+
if name in seen:
|
|
103
|
+
continue
|
|
104
|
+
if environment.loader is None:
|
|
105
|
+
raise RenderError(
|
|
106
|
+
f"模板 {template.name!r} 用了 {{% include {name!r} %}},但当前 Jinja 环境没有搜索路径。"
|
|
107
|
+
"(直接用库时请让 renderer 自己建环境:不要传 env,或者给 build_environment 传 search_path)"
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
source, _, _ = environment.loader.get_source(environment, name)
|
|
111
|
+
except TemplateNotFound as exc:
|
|
112
|
+
where = template.source_dir or base_dir or Path.cwd()
|
|
113
|
+
raise RenderError(
|
|
114
|
+
f"模板 {template.name!r} 的 {{% include {name!r} %}} 找不到片段:"
|
|
115
|
+
f"在 {where} 下没有 {name}(片段路径相对声明模板的那个文件,见指南 §17)"
|
|
116
|
+
) from exc
|
|
117
|
+
try:
|
|
118
|
+
child = environment.parse(source)
|
|
119
|
+
except TemplateSyntaxError as exc:
|
|
120
|
+
raise RenderError(
|
|
121
|
+
f"片段 {name!r}(被模板 {template.name!r} include)语法错误:第 {exc.lineno} 行: {exc.message}"
|
|
122
|
+
) from exc
|
|
123
|
+
found.append((name, child))
|
|
124
|
+
found.extend(_check_includes(template, child, environment, base_dir=base_dir, seen=seen | {name}))
|
|
125
|
+
return found
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def validate_template(
|
|
129
|
+
template: TemplateDef,
|
|
130
|
+
*,
|
|
131
|
+
env: Environment | None = None,
|
|
132
|
+
base_dir: str | Path | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""只做语法检查(解析模板、``{% include %}`` 片段、``case_filter``),不做渲染。"""
|
|
135
|
+
environment = template_environment(template, base_dir, env)
|
|
136
|
+
source = _template_source(template, base_dir)
|
|
137
|
+
try:
|
|
138
|
+
ast = environment.parse(source)
|
|
139
|
+
except TemplateSyntaxError as exc:
|
|
140
|
+
raise RenderError(f"模板 {template.name!r} 语法错误:第 {exc.lineno} 行: {exc.message}") from exc
|
|
141
|
+
_check_includes(template, ast, environment, base_dir=base_dir)
|
|
142
|
+
compile_case_filter(template, env=environment)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def collect_variables(
|
|
146
|
+
template: TemplateDef,
|
|
147
|
+
*,
|
|
148
|
+
env: Environment | None = None,
|
|
149
|
+
base_dir: str | Path | None = None,
|
|
150
|
+
) -> set[str]:
|
|
151
|
+
"""收集模板(含 ``{% include %}`` 片段、``case_filter`` 与 ``filename``)里引用到的顶层变量名。"""
|
|
152
|
+
environment = template_environment(template, base_dir, env)
|
|
153
|
+
names: set[str] = set()
|
|
154
|
+
try:
|
|
155
|
+
ast = environment.parse(_template_source(template, base_dir))
|
|
156
|
+
names |= set(meta.find_undeclared_variables(ast))
|
|
157
|
+
# 片段里用到的变量也算"被引用" —— 否则 validate 会把它们误报成"定义了没人用",
|
|
158
|
+
# 也不会去检查它们有没有定义
|
|
159
|
+
for _, fragment_ast in _check_includes(template, ast, environment, base_dir=base_dir):
|
|
160
|
+
names |= set(meta.find_undeclared_variables(fragment_ast))
|
|
161
|
+
if template.case_filter:
|
|
162
|
+
# case_filter 是一段**表达式**,必须包进 {{ }} 才能按表达式解析
|
|
163
|
+
filter_ast = environment.parse("{{ " + template.case_filter + " }}")
|
|
164
|
+
names |= set(meta.find_undeclared_variables(filter_ast))
|
|
165
|
+
if template.filename:
|
|
166
|
+
# filename 本身就是一小段**模板**(`cc_{{ seq }}_{{ case_name }}.inc`),直接解析。
|
|
167
|
+
# 只写在文件名里的变量同样是"被用到"(否则 validate 会把 seq 误报成"定义了没人用"),
|
|
168
|
+
# 而且它引用到不存在的变量时应该在 validate 阶段就报出来,而不是等到导出文件时。
|
|
169
|
+
names |= set(meta.find_undeclared_variables(environment.parse(template.filename)))
|
|
170
|
+
except TemplateSyntaxError as exc:
|
|
171
|
+
raise RenderError(f"模板 {template.name!r} 语法错误:第 {exc.lineno} 行: {exc.message}") from exc
|
|
172
|
+
return names
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --------------------------------------------------------------------------- #
|
|
176
|
+
# case_filter:per-template 的 Case 过滤
|
|
177
|
+
# --------------------------------------------------------------------------- #
|
|
178
|
+
class FilterValue(str):
|
|
179
|
+
"""``case_filter`` 求值时的变量视图。
|
|
180
|
+
|
|
181
|
+
它**像字符串**(内容等于组合值 ``prefix + value + suffix``,也就是 ``{{ x }}`` 的输出),
|
|
182
|
+
同时又保留 ``.value`` / ``.prefix`` / ``.suffix``,所以下面两种写法都对::
|
|
183
|
+
|
|
184
|
+
case_filter: "kind == 'EXT'" # 组合值(无前后缀时就是值本身)
|
|
185
|
+
case_filter: "draft.value > 20" # 纯值,可做数值比较
|
|
186
|
+
|
|
187
|
+
:class:`~excel_codegen.utils.VarValue` 本身是 dataclass,直接拿它和字符串比较永远不等,
|
|
188
|
+
所以过滤器上下文用的是本类。
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
__slots__ = ("prefix", "suffix", "value")
|
|
192
|
+
|
|
193
|
+
# 属性由 __new__ 填;显式标注是为了让类型检查看得见(__slots__ 本身不带类型信息)
|
|
194
|
+
prefix: str
|
|
195
|
+
suffix: str
|
|
196
|
+
value: Any
|
|
197
|
+
|
|
198
|
+
def __new__(cls, variable: VarValue) -> FilterValue:
|
|
199
|
+
instance = super().__new__(cls, str(variable))
|
|
200
|
+
instance.value = variable.value
|
|
201
|
+
instance.prefix = variable.prefix
|
|
202
|
+
instance.suffix = variable.suffix
|
|
203
|
+
return instance
|
|
204
|
+
|
|
205
|
+
def __repr__(self) -> str: # pragma: no cover - 调试友好
|
|
206
|
+
return f"FilterValue({str(self)!r}, value={self.value!r})"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def filter_context(context: Mapping[str, Any]) -> dict[str, Any]:
|
|
210
|
+
"""把渲染上下文里的 :class:`VarValue` 换成 :class:`FilterValue`(供 case_filter 使用)。"""
|
|
211
|
+
return {key: FilterValue(value) if isinstance(value, VarValue) else value for key, value in context.items()}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def compile_case_filter(template: TemplateDef, *, env: Environment | None = None):
|
|
215
|
+
"""把 ``template.case_filter`` 编译成可调用表达式;未配置时返回 ``None``。"""
|
|
216
|
+
if not template.case_filter:
|
|
217
|
+
return None
|
|
218
|
+
environment = env or build_environment()
|
|
219
|
+
try:
|
|
220
|
+
# undefined_to_none=False:变量缺失时报错,而不是静默判为 False
|
|
221
|
+
return environment.compile_expression(template.case_filter, undefined_to_none=False)
|
|
222
|
+
except TemplateSyntaxError as exc:
|
|
223
|
+
raise RenderError(f"模板 {template.name!r} 的 case_filter 语法错误:第 {exc.lineno} 行: {exc.message}") from exc
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def compile_asserts(config: ProjectConfig, *, env: Environment | None = None) -> list[tuple[str, Any]]:
|
|
227
|
+
"""编译根级 ``asserts``(跨变量校验),返回 ``[(原始表达式, 编译结果), …]``。
|
|
228
|
+
|
|
229
|
+
语法错误在这里就报出来 —— 与 ``case_filter`` 同一套表达式机制,所以 ``x.value``
|
|
230
|
+
那套写法与报错风格都一致。
|
|
231
|
+
"""
|
|
232
|
+
if not config.asserts:
|
|
233
|
+
return []
|
|
234
|
+
environment = env or build_environment()
|
|
235
|
+
compiled: list[tuple[str, Any]] = []
|
|
236
|
+
for index, expression in enumerate(config.asserts, start=1):
|
|
237
|
+
try:
|
|
238
|
+
program = environment.compile_expression(expression, undefined_to_none=False)
|
|
239
|
+
except TemplateSyntaxError as exc:
|
|
240
|
+
raise RenderError(
|
|
241
|
+
f"asserts 第 {index} 条语法错误:第 {exc.lineno} 行: {exc.message}({expression!r})"
|
|
242
|
+
) from exc
|
|
243
|
+
compiled.append((expression, program))
|
|
244
|
+
return compiled
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def check_asserts(
|
|
248
|
+
config: ProjectConfig,
|
|
249
|
+
global_values: Mapping[str, VarValue],
|
|
250
|
+
cases: Sequence[CaseData],
|
|
251
|
+
*,
|
|
252
|
+
members: Mapping[str, Mapping[str, VarValue]] | None = None,
|
|
253
|
+
env: Environment | None = None,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""对每个 Case 求值 ``asserts``;有假就抛 :class:`ExcelError`。
|
|
256
|
+
|
|
257
|
+
单变量约束拦不住"吃水不能超过型深"这类**组合**错误,这一层补的就是它。
|
|
258
|
+
"""
|
|
259
|
+
compiled = compile_asserts(config, env=env)
|
|
260
|
+
if not compiled:
|
|
261
|
+
return
|
|
262
|
+
problems: list[str] = []
|
|
263
|
+
for case in cases:
|
|
264
|
+
# 成员值也要进上下文 —— 否则 asserts 里引用舱/设备的参数会报 undefined
|
|
265
|
+
group_values = members[member_of(config, case, members)] if members else None
|
|
266
|
+
context = build_context(global_values, case.values, case.name, group_values)
|
|
267
|
+
for expression, program in compiled:
|
|
268
|
+
try:
|
|
269
|
+
passed = bool(program(**filter_context(context)))
|
|
270
|
+
except TemplateError as exc:
|
|
271
|
+
raise RenderError(
|
|
272
|
+
f"asserts {expression!r} 在 Case {case.name!r} 求值失败: {exc}"
|
|
273
|
+
"(表达式里引用的变量必须能在 Global / Local 表中取到)"
|
|
274
|
+
) from exc
|
|
275
|
+
except Exception as exc:
|
|
276
|
+
raise RenderError(
|
|
277
|
+
f"asserts {expression!r} 在 Case {case.name!r} 求值失败: {exc}"
|
|
278
|
+
"(数值比较请写 x.value,例如 draft.value <= d_tank.value)"
|
|
279
|
+
) from exc
|
|
280
|
+
if not passed:
|
|
281
|
+
problems.append(f" Case '{case.name}'(Local 表{case.where})不满足:{expression}")
|
|
282
|
+
if problems:
|
|
283
|
+
raise ExcelError(
|
|
284
|
+
f"参数不满足 YAML 里的 asserts(跨变量校验),共 {len(problems)} 处:\n"
|
|
285
|
+
+ "\n".join(problems)
|
|
286
|
+
+ "\n → 改 Excel 里的取值,或调整 YAML 里的 asserts"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def case_matches(
|
|
291
|
+
expression,
|
|
292
|
+
context: Mapping[str, Any],
|
|
293
|
+
*,
|
|
294
|
+
template_name: str,
|
|
295
|
+
) -> bool:
|
|
296
|
+
"""对单个 Case 求值 ``case_filter``。表达式为空/未配置时一律返回 ``True``。"""
|
|
297
|
+
if expression is None:
|
|
298
|
+
return True
|
|
299
|
+
try:
|
|
300
|
+
return bool(expression(**filter_context(context)))
|
|
301
|
+
except TemplateError as exc:
|
|
302
|
+
raise RenderError(
|
|
303
|
+
f"模板 {template_name!r} 的 case_filter 求值失败: {exc}"
|
|
304
|
+
"(表达式里引用的变量必须能在 Global / Local 表中取到)"
|
|
305
|
+
) from exc
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
raise RenderError(
|
|
308
|
+
f"模板 {template_name!r} 的 case_filter 求值失败: {exc}(数值比较请写 x.value,例如 draft.value > 20)"
|
|
309
|
+
) from exc
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# --------------------------------------------------------------------------- #
|
|
313
|
+
# 上下文与渲染
|
|
314
|
+
# --------------------------------------------------------------------------- #
|
|
315
|
+
def build_context(
|
|
316
|
+
global_values: Mapping[str, VarValue],
|
|
317
|
+
local_values: Mapping[str, VarValue],
|
|
318
|
+
case_name: str,
|
|
319
|
+
group_values: Mapping[str, VarValue] | None = None,
|
|
320
|
+
) -> dict[str, Any]:
|
|
321
|
+
"""组装单个 Case 的渲染上下文。
|
|
322
|
+
|
|
323
|
+
优先级从低到高:**成员(group)→ 全局(global)→ 局部(local)→ case_name**。
|
|
324
|
+
成员值放最低是因为它描述的是"这个舱/设备是什么",而 global / local 是"这次计算怎么算"。
|
|
325
|
+
"""
|
|
326
|
+
context: dict[str, Any] = {}
|
|
327
|
+
if group_values:
|
|
328
|
+
context.update(group_values)
|
|
329
|
+
context.update(global_values)
|
|
330
|
+
context.update(local_values)
|
|
331
|
+
context["case_name"] = case_name
|
|
332
|
+
context["template_name"] = "" # 渲染具体模板时会被覆盖
|
|
333
|
+
return context
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def member_of(
|
|
337
|
+
config: ProjectConfig,
|
|
338
|
+
case: CaseData,
|
|
339
|
+
members: Mapping[str, Mapping[str, VarValue]],
|
|
340
|
+
) -> str:
|
|
341
|
+
"""从 Case 的 ``key`` 变量取出成员名,并确认成员表里真有这个成员。"""
|
|
342
|
+
group = config.group
|
|
343
|
+
assert group is not None # 调用方负责只在有成员表时调用
|
|
344
|
+
value = case.values.get(group.key)
|
|
345
|
+
name = to_text(value.text).strip() if value is not None else ""
|
|
346
|
+
if not name:
|
|
347
|
+
raise ExcelError(f"Case {case.name!r} 的 {group.key!r} 是空的 —— 它要指向成员表 {group.sheet!r} 里的一个成员")
|
|
348
|
+
if name not in members:
|
|
349
|
+
available = ", ".join(members) or "(成员表是空的)"
|
|
350
|
+
raise ExcelError(
|
|
351
|
+
f"Case {case.name!r} 的 {group.key!r} = {name!r},但成员表 {group.sheet!r} 里没有这个成员"
|
|
352
|
+
f"(可选: {available})"
|
|
353
|
+
)
|
|
354
|
+
return name
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def render_template(
|
|
358
|
+
template: TemplateDef,
|
|
359
|
+
context: Mapping[str, Any],
|
|
360
|
+
*,
|
|
361
|
+
env: Environment | None = None,
|
|
362
|
+
base_dir: str | Path | None = None,
|
|
363
|
+
) -> str:
|
|
364
|
+
"""渲染单个模板,并把 Jinja2 异常翻译成清晰的中文提示。"""
|
|
365
|
+
environment = template_environment(template, base_dir, env)
|
|
366
|
+
source = _template_source(template, base_dir)
|
|
367
|
+
try:
|
|
368
|
+
compiled = environment.from_string(source)
|
|
369
|
+
except TemplateSyntaxError as exc:
|
|
370
|
+
raise RenderError(f"模板 {template.name!r} 语法错误:第 {exc.lineno} 行: {exc.message}") from exc
|
|
371
|
+
|
|
372
|
+
data = dict(context)
|
|
373
|
+
data["template_name"] = template.name
|
|
374
|
+
try:
|
|
375
|
+
return compiled.render(**data)
|
|
376
|
+
except UndefinedError as exc:
|
|
377
|
+
raise RenderError(
|
|
378
|
+
f"模板 {template.name!r} 变量缺失: {exc.message or exc}"
|
|
379
|
+
"(请检查 YAML 的 variables 与 Excel 中的变量名是否一致)"
|
|
380
|
+
) from exc
|
|
381
|
+
except TemplateError as exc:
|
|
382
|
+
raise RenderError(f"模板 {template.name!r} 渲染失败: {exc}") from exc
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@dataclass
|
|
386
|
+
class RenderOutput:
|
|
387
|
+
"""一次完整渲染的结果集合。"""
|
|
388
|
+
|
|
389
|
+
global_values: dict[str, VarValue]
|
|
390
|
+
cases: list[CaseData]
|
|
391
|
+
results: dict[str, list[RenderResult]] = field(default_factory=dict)
|
|
392
|
+
#: 被 ``case_filter`` 跳过的 Case:``{模板名: [Case 名, ...]}``
|
|
393
|
+
skipped: dict[str, list[str]] = field(default_factory=dict)
|
|
394
|
+
#: 值得提醒但不致命的问题(CLI 会打出来)
|
|
395
|
+
warnings: list[str] = field(default_factory=list)
|
|
396
|
+
|
|
397
|
+
def iter_results(self) -> Iterator[RenderResult]:
|
|
398
|
+
for per_case in self.results.values():
|
|
399
|
+
yield from per_case
|
|
400
|
+
|
|
401
|
+
def total(self) -> int:
|
|
402
|
+
return sum(len(item) for item in self.results.values())
|
|
403
|
+
|
|
404
|
+
def skipped_total(self) -> int:
|
|
405
|
+
return sum(len(item) for item in self.skipped.values())
|
|
406
|
+
|
|
407
|
+
def preview(self, *, max_cases: int | None = None) -> str:
|
|
408
|
+
"""把结果拼成一段纯文本预览(CLI / 调试用)。"""
|
|
409
|
+
blocks: list[str] = []
|
|
410
|
+
for template_name, per_case in self.results.items():
|
|
411
|
+
cases = per_case if max_cases is None else per_case[:max_cases]
|
|
412
|
+
for result in cases:
|
|
413
|
+
blocks.append(f"### {template_name} · {result.case_name}\n{result.text.rstrip()}")
|
|
414
|
+
return "\n\n".join(blocks)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def render_all(
|
|
418
|
+
config: ProjectConfig,
|
|
419
|
+
excel_path: str | Path,
|
|
420
|
+
*,
|
|
421
|
+
env: Environment | None = None,
|
|
422
|
+
only_cases: Sequence[str] | None = None,
|
|
423
|
+
) -> RenderOutput:
|
|
424
|
+
"""读取 Excel 中填写的参数,按 Case 渲染所有模板。
|
|
425
|
+
|
|
426
|
+
``template.case_filter`` 可以只让某个模板作用于部分 Case(例如一本工作簿里
|
|
427
|
+
同时放内外压两套模板,用 ``kind`` 区分)。
|
|
428
|
+
"""
|
|
429
|
+
workbook = load_workbook_file(excel_path)
|
|
430
|
+
read_warnings: list[str] = []
|
|
431
|
+
try:
|
|
432
|
+
check_required_sheets(workbook, config)
|
|
433
|
+
global_values = read_global_values(workbook, config, warnings=read_warnings)
|
|
434
|
+
cases = read_cases(workbook, config, global_values=global_values, warnings=read_warnings)
|
|
435
|
+
members = read_group_members(workbook, config, warnings=read_warnings)
|
|
436
|
+
finally:
|
|
437
|
+
workbook.close()
|
|
438
|
+
|
|
439
|
+
# 取值约束(min / max / choices / pattern):声明了就一定查,别让手滑的数字生成出错误代码
|
|
440
|
+
check_value_constraints(config, global_values, cases, members=members)
|
|
441
|
+
# 跨变量校验(asserts):拦"吃水不能超过型深"这类组合错误
|
|
442
|
+
check_asserts(config, global_values, cases, members=members)
|
|
443
|
+
|
|
444
|
+
if only_cases:
|
|
445
|
+
wanted = [to_text(name).strip() for name in only_cases]
|
|
446
|
+
by_name = {case.name: case for case in cases}
|
|
447
|
+
missing = [name for name in wanted if name not in by_name]
|
|
448
|
+
if missing:
|
|
449
|
+
raise ExcelError(f"Excel 中不存在这些 Case: {', '.join(missing)}(可用: {', '.join(by_name)})")
|
|
450
|
+
cases = [by_name[name] for name in wanted]
|
|
451
|
+
|
|
452
|
+
results: dict[str, list[RenderResult]] = {}
|
|
453
|
+
skipped: dict[str, list[str]] = {}
|
|
454
|
+
environment_cache: dict[str, Environment] = {}
|
|
455
|
+
for template in config.templates:
|
|
456
|
+
# 每个模板用"声明它的那个目录"的环境:{% include %} 的片段相对那里解析
|
|
457
|
+
base = template.source_dir or config.source_dir
|
|
458
|
+
key = str(base or "")
|
|
459
|
+
if key not in environment_cache:
|
|
460
|
+
environment_cache[key] = template_environment(template, config.source_dir, env)
|
|
461
|
+
environment = environment_cache[key]
|
|
462
|
+
expression = compile_case_filter(template, env=environment)
|
|
463
|
+
per_case: list[RenderResult] = []
|
|
464
|
+
skipped_cases: list[str] = []
|
|
465
|
+
for case in cases:
|
|
466
|
+
group_values = members[member_of(config, case, members)] if config.group is not None else None
|
|
467
|
+
context = build_context(global_values, case.values, case.name, group_values)
|
|
468
|
+
if not case_matches(expression, context, template_name=template.name):
|
|
469
|
+
skipped_cases.append(case.name)
|
|
470
|
+
continue
|
|
471
|
+
text = render_template(template, context, env=environment, base_dir=config.source_dir)
|
|
472
|
+
per_case.append(RenderResult(template.name, case.name, text, dict(context)))
|
|
473
|
+
results[template.name] = per_case
|
|
474
|
+
if skipped_cases:
|
|
475
|
+
skipped[template.name] = skipped_cases
|
|
476
|
+
|
|
477
|
+
return RenderOutput(
|
|
478
|
+
global_values=dict(global_values),
|
|
479
|
+
cases=list(cases),
|
|
480
|
+
results=results,
|
|
481
|
+
skipped=skipped,
|
|
482
|
+
warnings=[*_case_warnings(config, cases), *read_warnings],
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _case_warnings(config: ProjectConfig, cases: Sequence[CaseData]) -> list[str]:
|
|
487
|
+
"""全空的 Case 列值得提醒:所有变量回落到 YAML ``default``,
|
|
488
|
+
而 ``case_filter`` 会按 default 把它划进某个规则集 —— 表上却只是一个空列。"""
|
|
489
|
+
warnings: list[str] = []
|
|
490
|
+
blank = [case.name for case in cases if not case.explicit_values]
|
|
491
|
+
if not blank:
|
|
492
|
+
return warnings
|
|
493
|
+
filtered = [t.name for t in config.templates if t.case_filter]
|
|
494
|
+
warnings.append(
|
|
495
|
+
f"这些 Case 列在 Local 表里是空的:{', '.join(blank)} —— "
|
|
496
|
+
"所有局部变量都取自 YAML default"
|
|
497
|
+
+ (
|
|
498
|
+
f";`case_filter` 会按 default 把它们划进某个规则集({', '.join(filtered)}),"
|
|
499
|
+
"新插的空列请先填上归属变量(例如 kind)"
|
|
500
|
+
if filtered
|
|
501
|
+
else ""
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
return warnings
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# --------------------------------------------------------------------------- #
|
|
508
|
+
# 导出代码文件
|
|
509
|
+
# --------------------------------------------------------------------------- #
|
|
510
|
+
def export_files(
|
|
511
|
+
config: ProjectConfig,
|
|
512
|
+
results: Mapping[str, Sequence[RenderResult]],
|
|
513
|
+
outdir: str | Path,
|
|
514
|
+
*,
|
|
515
|
+
env: Environment | None = None,
|
|
516
|
+
overwrite: bool = True,
|
|
517
|
+
) -> list[Path]:
|
|
518
|
+
"""把渲染结果导出为代码文件。
|
|
519
|
+
|
|
520
|
+
文件名由 ``template.filename`` 决定(支持 Jinja2):``{{ case_name }}`` /
|
|
521
|
+
``{{ template_name }}`` / **这个 Case 的任意变量**(例如只用来排序的 ``seq``);
|
|
522
|
+
未配置时默认 ``<模板名>_<Case名><extension>``。
|
|
523
|
+
|
|
524
|
+
变量来自 :attr:`RenderResult.context` —— 手工构造 ``RenderResult`` 时不给它,
|
|
525
|
+
文件名里就只能用 ``case_name`` / ``template_name``。
|
|
526
|
+
"""
|
|
527
|
+
environment = env or build_environment()
|
|
528
|
+
directory = Path(outdir)
|
|
529
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
530
|
+
written: list[Path] = []
|
|
531
|
+
|
|
532
|
+
for template in config.templates:
|
|
533
|
+
per_case = results.get(template.name)
|
|
534
|
+
if not per_case:
|
|
535
|
+
continue
|
|
536
|
+
pattern = template.filename or (template.name + "_{{ case_name }}" + template.extension)
|
|
537
|
+
for result in per_case:
|
|
538
|
+
data = dict(result.context)
|
|
539
|
+
data["case_name"] = result.case_name
|
|
540
|
+
data["template_name"] = template.name
|
|
541
|
+
try:
|
|
542
|
+
raw_name = environment.from_string(pattern).render(**data)
|
|
543
|
+
except TemplateError as exc:
|
|
544
|
+
raise RenderError(f"模板 {template.name!r} 的 filename 渲染失败: {exc}") from exc
|
|
545
|
+
file_name = safe_filename(raw_name)
|
|
546
|
+
if not file_name:
|
|
547
|
+
raise RenderError(f"模板 {template.name!r} 在 Case {result.case_name!r} 下生成了空文件名")
|
|
548
|
+
target = directory / file_name
|
|
549
|
+
if target.exists() and not overwrite:
|
|
550
|
+
raise RenderError(f"目标文件已存在: {target}(需要覆盖请去掉 --no-overwrite)")
|
|
551
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
552
|
+
text = result.text if result.text.endswith("\n") else result.text + "\n"
|
|
553
|
+
try:
|
|
554
|
+
target.write_text(text, encoding="utf-8")
|
|
555
|
+
except OSError as exc:
|
|
556
|
+
raise RenderError(f"无法写入文件 {target}: {exc}") from exc
|
|
557
|
+
written.append(target)
|
|
558
|
+
return written
|