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
excel_codegen/cli.py
ADDED
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
"""命令行接口:``excel-codegen init | render | validate | check``。
|
|
2
|
+
|
|
3
|
+
控制台注意事项:中文 Windows 的 GBK 控制台编不出 ``✓`` / ``✗`` 之类的字符,
|
|
4
|
+
所以本模块只用 ASCII 标记(``OK`` / ``ERROR`` / ``!``),并在导入时给
|
|
5
|
+
stdout/stderr 加上 ``errors="backslashreplace"`` 兜底 —— 成功路径绝不允许
|
|
6
|
+
因为"最后一行字打不出来"而返回非 0 退出码。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.syntax import Syntax
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
|
|
23
|
+
from . import __version__, example_pack
|
|
24
|
+
from .derived import expression_names
|
|
25
|
+
from .derived import validate_config as derived_validate_config
|
|
26
|
+
from .excel_io import (
|
|
27
|
+
case_axis_map,
|
|
28
|
+
check_required_sheets,
|
|
29
|
+
check_value_constraints,
|
|
30
|
+
create_template,
|
|
31
|
+
input_fingerprint,
|
|
32
|
+
load_workbook_file,
|
|
33
|
+
output_fingerprint,
|
|
34
|
+
read_cases,
|
|
35
|
+
read_global_values,
|
|
36
|
+
read_group_members,
|
|
37
|
+
read_metadata,
|
|
38
|
+
template_source,
|
|
39
|
+
write_results,
|
|
40
|
+
)
|
|
41
|
+
from .formula import LONG_FORMULA_WARN, compile_formulas
|
|
42
|
+
from .formula_eval import FormulaEvalError, evaluate_template_values
|
|
43
|
+
from .models import ProjectConfig, RenderResult, load_config
|
|
44
|
+
from .renderer import (
|
|
45
|
+
RenderOutput,
|
|
46
|
+
build_environment,
|
|
47
|
+
check_asserts,
|
|
48
|
+
collect_variables,
|
|
49
|
+
compile_asserts,
|
|
50
|
+
export_files,
|
|
51
|
+
render_all,
|
|
52
|
+
validate_template,
|
|
53
|
+
)
|
|
54
|
+
from .utils import CodeGenError, ExcelError, column_index_to_letter, parse_cell, to_text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _make_streams_forgiving() -> None:
|
|
58
|
+
"""让 stdout/stderr 遇到当前编码表达不了的字符时降级,而不是抛异常。"""
|
|
59
|
+
for stream in (sys.stdout, sys.stderr):
|
|
60
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
61
|
+
if reconfigure is None:
|
|
62
|
+
continue
|
|
63
|
+
# 宿主环境千奇百怪:不支持就静默跳过,别让"打日志"把命令搞崩
|
|
64
|
+
with suppress(ValueError, OSError): # pragma: no cover - 取决于宿主环境
|
|
65
|
+
reconfigure(errors="backslashreplace")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_make_streams_forgiving()
|
|
69
|
+
|
|
70
|
+
app = typer.Typer(
|
|
71
|
+
add_completion=False,
|
|
72
|
+
no_args_is_help=True,
|
|
73
|
+
help="Excel 模板参数填写 + Jinja2 代码生成器(支持 Prefix/Value/Suffix 组合)",
|
|
74
|
+
)
|
|
75
|
+
console = Console()
|
|
76
|
+
error_console = Console(stderr=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- #
|
|
80
|
+
# 公共小工具
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
def _fail(message: object, code: int = 1) -> typer.Exit:
|
|
83
|
+
error_console.print(f"[bold red]ERROR[/] {message}")
|
|
84
|
+
return typer.Exit(code=code)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _warn(message: str) -> None:
|
|
88
|
+
console.print(f"[bold yellow]![/] {message}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load_project(config: Path):
|
|
92
|
+
"""加载配置,并把 ``extends`` 合并过程中的告警打出来。"""
|
|
93
|
+
project = load_config(config)
|
|
94
|
+
for warning in project.load_warnings:
|
|
95
|
+
_warn(warning)
|
|
96
|
+
return project
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _version_callback(value: bool) -> None:
|
|
100
|
+
if value:
|
|
101
|
+
console.print(f"excel-codegen [cyan]{__version__}[/]")
|
|
102
|
+
raise typer.Exit()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_excel_path(config: ProjectConfig, excel: Path | None) -> Path:
|
|
106
|
+
"""--excel 优先;否则使用配置里的 excel.output(相对当前工作目录)。"""
|
|
107
|
+
if excel is not None:
|
|
108
|
+
return Path(excel)
|
|
109
|
+
return Path(config.excel.output)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_cases(value: str) -> int | list[str]:
|
|
113
|
+
"""``--cases`` 既接受数量(``3``)也接受名字列表(``EXT-T20,INT-T15``)。"""
|
|
114
|
+
text = to_text(value).strip()
|
|
115
|
+
if not text:
|
|
116
|
+
raise ValueError("--cases 不能为空")
|
|
117
|
+
if text.lstrip("+-").isdigit():
|
|
118
|
+
count = int(text)
|
|
119
|
+
if count < 1:
|
|
120
|
+
raise ValueError(f"--cases 的数量至少为 1(收到 {count})")
|
|
121
|
+
return count
|
|
122
|
+
names = [item.strip() for item in text.split(",")]
|
|
123
|
+
if any(not name for name in names):
|
|
124
|
+
raise ValueError("--cases 里的 Case 名不能为空,例如 --cases Case1,Case2")
|
|
125
|
+
return names
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _prefill_output(
|
|
129
|
+
project: ProjectConfig,
|
|
130
|
+
target: Path,
|
|
131
|
+
*,
|
|
132
|
+
template_sheet: bool = True,
|
|
133
|
+
howto_sheet: bool = True,
|
|
134
|
+
) -> str:
|
|
135
|
+
"""建完表顺手把输出也写一遍,返回"给用户看的一句话"。
|
|
136
|
+
|
|
137
|
+
为什么要它:本工具的用法是"生成一次工作簿,之后就在 Excel 里干活"。公式模式下
|
|
138
|
+
输出表里是活公式,所以**建完就能用**,不该再让人跑第二条命令。
|
|
139
|
+
|
|
140
|
+
参数还是 YAML 默认值,可能暂时过不了取值约束 / ``asserts``(比如"舱高不能超过型深",
|
|
141
|
+
而默认值恰好不合)。这时**不能**让 ``init`` 失败 —— 骨架照常给,只是跳过预填并说明原因。
|
|
142
|
+
"""
|
|
143
|
+
from .renderer import render_all
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
output = render_all(project, target)
|
|
147
|
+
# `init --no-howto` 时别把 HOWTO 表写回来(write_results 会照着配置建)
|
|
148
|
+
write_results(
|
|
149
|
+
target,
|
|
150
|
+
project,
|
|
151
|
+
output.results,
|
|
152
|
+
command="excel-codegen init",
|
|
153
|
+
update_howto=howto_sheet,
|
|
154
|
+
)
|
|
155
|
+
_drop_suppressed_sheets(project, target, template_sheet=template_sheet, howto_sheet=howto_sheet)
|
|
156
|
+
except CodeGenError as exc:
|
|
157
|
+
first = str(exc).strip().splitlines()[0]
|
|
158
|
+
console.print(
|
|
159
|
+
Panel(
|
|
160
|
+
f"[yellow]跳过预填输出[/]:{first}\n"
|
|
161
|
+
"(多半是默认值还没填全 / 过不了取值约束或 asserts —— 骨架已生成,"
|
|
162
|
+
"填好参数后跑 render --write-excel 即可)",
|
|
163
|
+
border_style="yellow",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
return "骨架(预填被跳过,见上方提示)"
|
|
167
|
+
|
|
168
|
+
formulas = sum(1 for item in project.templates if item.engine == "excel")
|
|
169
|
+
if formulas == len(project.templates):
|
|
170
|
+
return "已写入公式 —— 打开 Excel 改参数即自动重算,不用再跑命令 ✓"
|
|
171
|
+
if formulas == 0:
|
|
172
|
+
return "已写入文本快照 —— 改参数后要重跑 render --write-excel"
|
|
173
|
+
return f"已写入(其中 {formulas} 个模板是公式,其余是快照)"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _drop_suppressed_sheets(
|
|
177
|
+
project: ProjectConfig,
|
|
178
|
+
target: Path,
|
|
179
|
+
*,
|
|
180
|
+
template_sheet: bool,
|
|
181
|
+
howto_sheet: bool,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""``init --no-template-sheet`` / ``--no-howto`` 时,把预填过程中建回来的表删掉。
|
|
184
|
+
|
|
185
|
+
``write_results`` 是照着**配置**记录元信息的(它不知道本次 ``init`` 关掉了哪张表),
|
|
186
|
+
所以这里按命令行开关收尾 —— 否则"我不想生成说明表"会被预填偷偷推翻。
|
|
187
|
+
"""
|
|
188
|
+
unwanted = []
|
|
189
|
+
if not template_sheet and project.excel.template_sheet:
|
|
190
|
+
unwanted.append(project.excel.template_sheet)
|
|
191
|
+
if not howto_sheet and project.excel.howto_sheet:
|
|
192
|
+
unwanted.append(project.excel.howto_sheet)
|
|
193
|
+
if not unwanted:
|
|
194
|
+
return
|
|
195
|
+
workbook = load_workbook_file(target)
|
|
196
|
+
try:
|
|
197
|
+
for name in unwanted:
|
|
198
|
+
if name in workbook.sheetnames:
|
|
199
|
+
workbook.remove(workbook[name])
|
|
200
|
+
workbook.save(target)
|
|
201
|
+
finally:
|
|
202
|
+
workbook.close()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _case_axis_label(project: ProjectConfig) -> str:
|
|
206
|
+
"""工况轴的说法:横向是"Case 列",纵向是"Case 行"。"""
|
|
207
|
+
return "Case 列" if project.excel.local_direction == "horizontal" else "Case 行"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _local_layout_hint(project: ProjectConfig) -> str:
|
|
211
|
+
if project.excel.local_direction == "horizontal":
|
|
212
|
+
return "Case 列:E 起"
|
|
213
|
+
return "Case 行:第 2 行起"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _case_names_of(spec: int | list[str]) -> list[str]:
|
|
217
|
+
return [f"Case{index}" for index in range(1, spec + 1)] if isinstance(spec, int) else list(spec)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _open_excel(project: ProjectConfig, excel: Path | None) -> Path:
|
|
221
|
+
path = _resolve_excel_path(project, excel)
|
|
222
|
+
if not path.exists():
|
|
223
|
+
raise ExcelError(f"Excel 文件不存在: {path}(请先运行 `excel-codegen init` 生成模板并填写参数)")
|
|
224
|
+
return path
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.callback()
|
|
228
|
+
def main(
|
|
229
|
+
version: bool = typer.Option(
|
|
230
|
+
False,
|
|
231
|
+
"--version",
|
|
232
|
+
callback=_version_callback,
|
|
233
|
+
is_eager=True,
|
|
234
|
+
help="显示版本号并退出",
|
|
235
|
+
),
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Excel 模板参数填写 + Jinja2 代码生成器。"""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --------------------------------------------------------------------------- #
|
|
241
|
+
# init
|
|
242
|
+
# --------------------------------------------------------------------------- #
|
|
243
|
+
@app.command("init")
|
|
244
|
+
def init_command(
|
|
245
|
+
config: Path = typer.Option(
|
|
246
|
+
...,
|
|
247
|
+
"--config",
|
|
248
|
+
"-c",
|
|
249
|
+
exists=True,
|
|
250
|
+
dir_okay=False,
|
|
251
|
+
readable=True,
|
|
252
|
+
help="YAML 配置文件路径",
|
|
253
|
+
),
|
|
254
|
+
output: Path | None = typer.Option(
|
|
255
|
+
None, "--output", "-o", help="输出的 Excel 模板路径(默认取配置中的 excel.output)"
|
|
256
|
+
),
|
|
257
|
+
cases: str = typer.Option(
|
|
258
|
+
"2",
|
|
259
|
+
"--cases",
|
|
260
|
+
help="初始 Case 列:数量(如 3)或逗号分隔的名字(如 EXT-T20,INT-T15)",
|
|
261
|
+
),
|
|
262
|
+
force: bool = typer.Option(False, "--force", "-f", help="目标 Excel 已存在时覆盖"),
|
|
263
|
+
template_sheet: bool = typer.Option(
|
|
264
|
+
True,
|
|
265
|
+
"--template-sheet/--no-template-sheet",
|
|
266
|
+
help="是否生成隐藏的 Template 表(保存模板原文)",
|
|
267
|
+
),
|
|
268
|
+
howto_sheet: bool = typer.Option(
|
|
269
|
+
True,
|
|
270
|
+
"--howto/--no-howto",
|
|
271
|
+
help="是否生成 HOWTO 表(放在第一张,写清下一步跑什么)",
|
|
272
|
+
),
|
|
273
|
+
comments: bool = typer.Option(
|
|
274
|
+
True,
|
|
275
|
+
"--comments/--no-comments",
|
|
276
|
+
help="是否给变量名那格加 Excel 批注(描述 / 单位 / 约束 / 前缀后缀 / 派生表达式)",
|
|
277
|
+
),
|
|
278
|
+
scripts: bool = typer.Option(
|
|
279
|
+
True,
|
|
280
|
+
"--scripts/--no-scripts",
|
|
281
|
+
help="是否在工作簿旁边生成一键刷新脚本(<工作簿名>_render.bat / .sh)",
|
|
282
|
+
),
|
|
283
|
+
prerender: bool = typer.Option(
|
|
284
|
+
True,
|
|
285
|
+
"--prerender/--no-prerender",
|
|
286
|
+
help="建完表就先把输出写进去(默认打开:公式模式下打开工作簿即可用)",
|
|
287
|
+
),
|
|
288
|
+
) -> None:
|
|
289
|
+
"""根据 YAML 生成 Excel 参数填写模板。
|
|
290
|
+
|
|
291
|
+
默认(``--prerender``)建完骨架就把输出也写一遍 —— 公式模式下输出表里是活公式,
|
|
292
|
+
于是"生成完就能打开 Excel 干活",不需要再跑一条命令。参数没填全、
|
|
293
|
+
暂时过不了约束/asserts 时会跳过这一步并提示(骨架照常生成)。
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
spec = _parse_cases(cases)
|
|
297
|
+
project = _load_project(config)
|
|
298
|
+
target = create_template(
|
|
299
|
+
project,
|
|
300
|
+
output or Path(project.excel.output),
|
|
301
|
+
cases=spec,
|
|
302
|
+
overwrite=force,
|
|
303
|
+
include_template_sheet=template_sheet,
|
|
304
|
+
include_howto_sheet=howto_sheet,
|
|
305
|
+
include_comments=comments,
|
|
306
|
+
include_scripts=scripts,
|
|
307
|
+
)
|
|
308
|
+
except CodeGenError as exc:
|
|
309
|
+
raise _fail(exc) from exc
|
|
310
|
+
except (ValueError, OSError) as exc:
|
|
311
|
+
raise _fail(exc) from exc
|
|
312
|
+
|
|
313
|
+
prefill_note = "(还没有写输出)"
|
|
314
|
+
if prerender:
|
|
315
|
+
prefill_note = _prefill_output(project, target, template_sheet=template_sheet, howto_sheet=howto_sheet)
|
|
316
|
+
|
|
317
|
+
table = Table(title="Excel 模板已生成", show_header=True, header_style="bold cyan")
|
|
318
|
+
table.add_column("项目", style="bold")
|
|
319
|
+
table.add_column("内容")
|
|
320
|
+
table.add_row("文件", str(target))
|
|
321
|
+
table.add_row("Global 表", project.excel.sheets.global_)
|
|
322
|
+
table.add_row("Local 表", f"{project.excel.sheets.local}({_local_layout_hint(project)})")
|
|
323
|
+
table.add_row("Output 表", ", ".join(project.excel.sheets.outputs))
|
|
324
|
+
table.add_row(_case_axis_label(project), ", ".join(_case_names_of(spec)))
|
|
325
|
+
table.add_row("说明表", project.excel.howto_sheet or "(未生成)")
|
|
326
|
+
table.add_row("模板数", str(len(project.templates)))
|
|
327
|
+
table.add_row("输出内容", prefill_note)
|
|
328
|
+
console.print(table)
|
|
329
|
+
console.print(
|
|
330
|
+
Panel(
|
|
331
|
+
"[bold]下一步[/]\n"
|
|
332
|
+
f"1. 在 [cyan]{project.excel.sheets.global_}[/] 表填写 B 列(Value),D/E 列可覆盖 Prefix/Suffix;\n"
|
|
333
|
+
f"2. 在 [cyan]{project.excel.sheets.local}[/] 表从 E 列开始按 Case 填写,如需更多 Case 直接右拉复制;\n"
|
|
334
|
+
"3. 运行渲染(不加 --write-excel 只预览,不会改动 Excel):\n"
|
|
335
|
+
f" [green]excel-codegen render --config {config} --excel {target} --write-excel[/]\n"
|
|
336
|
+
f" [green]excel-codegen render --config {config} --excel {target} --outdir generated/[/]",
|
|
337
|
+
title="使用说明",
|
|
338
|
+
border_style="green",
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# --------------------------------------------------------------------------- #
|
|
344
|
+
# render
|
|
345
|
+
# --------------------------------------------------------------------------- #
|
|
346
|
+
@app.command("render")
|
|
347
|
+
def render_command(
|
|
348
|
+
config: Path = typer.Option(
|
|
349
|
+
...,
|
|
350
|
+
"--config",
|
|
351
|
+
"-c",
|
|
352
|
+
exists=True,
|
|
353
|
+
dir_okay=False,
|
|
354
|
+
readable=True,
|
|
355
|
+
help="YAML 配置文件路径",
|
|
356
|
+
),
|
|
357
|
+
excel: Path | None = typer.Option(None, "--excel", "-x", help="已填写的 Excel 模板(默认取配置中的 excel.output)"),
|
|
358
|
+
write_excel: bool = typer.Option(False, "--write-excel", "-w", help="把渲染结果写入 Excel 的 Output 表"),
|
|
359
|
+
outdir: Path | None = typer.Option(None, "--outdir", "-d", help="把渲染结果导出为代码文件到该目录"),
|
|
360
|
+
case: list[str] | None = typer.Option(None, "--case", help="只渲染指定 Case(可重复传入)"),
|
|
361
|
+
show: bool = typer.Option(True, "--show/--no-show", help="在终端打印渲染结果"),
|
|
362
|
+
overwrite: bool = typer.Option(True, "--overwrite/--no-overwrite", help="导出文件已存在时是否覆盖"),
|
|
363
|
+
) -> None:
|
|
364
|
+
"""读取填好的 Excel + YAML,渲染模板并输出到 Excel / 代码文件 / 终端。"""
|
|
365
|
+
try:
|
|
366
|
+
project = _load_project(config)
|
|
367
|
+
excel_path = _open_excel(project, excel)
|
|
368
|
+
output = render_all(project, excel_path, only_cases=list(case) if case else None)
|
|
369
|
+
|
|
370
|
+
written_files: list[Path] = []
|
|
371
|
+
output_warnings: list[str] = []
|
|
372
|
+
command_text = (
|
|
373
|
+
f"excel-codegen render -c {config} -x {excel_path}"
|
|
374
|
+
+ (" --write-excel" if write_excel else "")
|
|
375
|
+
+ (f" --outdir {outdir}" if outdir is not None else "")
|
|
376
|
+
)
|
|
377
|
+
if write_excel:
|
|
378
|
+
write_results(
|
|
379
|
+
excel_path,
|
|
380
|
+
project,
|
|
381
|
+
output.results,
|
|
382
|
+
command=command_text,
|
|
383
|
+
warnings=output_warnings,
|
|
384
|
+
)
|
|
385
|
+
if outdir is not None:
|
|
386
|
+
written_files = export_files(project, output.results, outdir, overwrite=overwrite)
|
|
387
|
+
except CodeGenError as exc:
|
|
388
|
+
raise _fail(exc) from exc
|
|
389
|
+
except (ValueError, OSError) as exc:
|
|
390
|
+
raise _fail(exc) from exc
|
|
391
|
+
|
|
392
|
+
if show:
|
|
393
|
+
for template in project.templates:
|
|
394
|
+
for result in output.results.get(template.name, []):
|
|
395
|
+
console.print(
|
|
396
|
+
Panel(
|
|
397
|
+
Syntax(result.text.rstrip("\n") or " ", "text", theme="ansi_dark", word_wrap=True),
|
|
398
|
+
title=f"[bold]{template.name}[/] · [cyan]{result.case_name}[/]",
|
|
399
|
+
subtitle=f"→ {template.output_sheet} @ {template.start_cell} ({template.direction})",
|
|
400
|
+
border_style="cyan",
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
summary = Table(title="渲染摘要", header_style="bold cyan")
|
|
405
|
+
summary.add_column("项目", style="bold")
|
|
406
|
+
summary.add_column("内容")
|
|
407
|
+
summary.add_row("配置文件", str(config))
|
|
408
|
+
summary.add_row("Excel", str(excel_path))
|
|
409
|
+
summary.add_row("Case", ", ".join(item.name for item in output.cases))
|
|
410
|
+
summary.add_row("渲染结果", f"{output.total()} 个({len(project.templates)} 模板 × {len(output.cases)} Case)")
|
|
411
|
+
if output.skipped:
|
|
412
|
+
detail = ";".join(f"{name} 跳过 {', '.join(cases)}" for name, cases in output.skipped.items())
|
|
413
|
+
summary.add_row("case_filter", f"跳过 {output.skipped_total()} 个({detail})")
|
|
414
|
+
# 永远显示这一行:让"成功"与"成功但没动文件"能一眼分开
|
|
415
|
+
summary.add_row("写回 Excel", "是" if write_excel else "否(需要 --write-excel)")
|
|
416
|
+
summary.add_row("导出文件", "\n".join(str(item) for item in written_files) if written_files else "无")
|
|
417
|
+
console.print(summary)
|
|
418
|
+
|
|
419
|
+
if not write_excel and outdir is None:
|
|
420
|
+
_warn("本次只预览:没有写回 Excel,也没有导出文件。加 --write-excel / --outdir 才会落盘。")
|
|
421
|
+
elif not write_excel:
|
|
422
|
+
_warn(f"没有写回 Excel(只导出了文件):Excel 里的 {project.excel.sheets.outputs[0]} 表还是上一次的内容。")
|
|
423
|
+
for warning in [*output.warnings, *output_warnings]:
|
|
424
|
+
_warn(warning)
|
|
425
|
+
console.print("[bold green]OK[/] 渲染完成")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# --------------------------------------------------------------------------- #
|
|
429
|
+
# validate
|
|
430
|
+
# --------------------------------------------------------------------------- #
|
|
431
|
+
@app.command("validate")
|
|
432
|
+
def validate_command(
|
|
433
|
+
config: Path = typer.Option(
|
|
434
|
+
...,
|
|
435
|
+
"--config",
|
|
436
|
+
"-c",
|
|
437
|
+
exists=True,
|
|
438
|
+
dir_okay=False,
|
|
439
|
+
readable=True,
|
|
440
|
+
help="YAML 配置文件路径",
|
|
441
|
+
),
|
|
442
|
+
excel: Path | None = typer.Option(None, "--excel", "-x", help="同时校验填写的 Excel 模板是否存在且结构正确"),
|
|
443
|
+
) -> None:
|
|
444
|
+
"""校验 YAML 配置(以及可选地校验 Excel 结构),不产生任何输出。"""
|
|
445
|
+
warnings: list[str] = []
|
|
446
|
+
try:
|
|
447
|
+
project = _load_project(config)
|
|
448
|
+
environment = build_environment()
|
|
449
|
+
# 派生参数:语法 / 引用范围 / 循环 / 能否翻译成 Excel 公式
|
|
450
|
+
warnings.extend(derived_validate_config(project, env=environment))
|
|
451
|
+
# 跨变量校验:表达式先编译一遍(语法错误在这里就报,别等渲染)
|
|
452
|
+
asserts = compile_asserts(project, env=environment)
|
|
453
|
+
if asserts:
|
|
454
|
+
console.print(f" [dim]asserts(跨变量校验){len(asserts)} 条:[/]")
|
|
455
|
+
for expression, _ in asserts:
|
|
456
|
+
console.print(f" {expression}")
|
|
457
|
+
|
|
458
|
+
template_table = Table(title="模板清单", header_style="bold cyan")
|
|
459
|
+
template_table.add_column("名称", style="bold")
|
|
460
|
+
template_table.add_column("引擎")
|
|
461
|
+
template_table.add_column("输出位置")
|
|
462
|
+
template_table.add_column("导出文件名")
|
|
463
|
+
template_table.add_column("引用变量")
|
|
464
|
+
|
|
465
|
+
used_by_template: dict[str, set[str]] = {}
|
|
466
|
+
formula_templates: list[str] = []
|
|
467
|
+
for template in project.templates:
|
|
468
|
+
# 不传 env:让 renderer 按『声明这个模板的目录』建环境({% include %} 要用)
|
|
469
|
+
validate_template(template, base_dir=project.source_dir)
|
|
470
|
+
used = collect_variables(template, base_dir=project.source_dir)
|
|
471
|
+
used_by_template[template.name] = used
|
|
472
|
+
unknown = sorted(name for name in used if name not in project.defined_names)
|
|
473
|
+
if unknown:
|
|
474
|
+
warnings.append(
|
|
475
|
+
f"模板 {template.name!r} 引用了未在 YAML 中定义的变量: {', '.join(unknown)}"
|
|
476
|
+
"(若这些名字出现在 Excel 表里则仍然可用)"
|
|
477
|
+
)
|
|
478
|
+
if template.engine == "excel":
|
|
479
|
+
formula_templates.append(template.name)
|
|
480
|
+
# 提前编译一遍:超出"纯替换"子集的写法在这里就报出具体行
|
|
481
|
+
compiled = compile_formulas(
|
|
482
|
+
template,
|
|
483
|
+
project,
|
|
484
|
+
case_axes=[1], # 只量长度,轴取哪个都行
|
|
485
|
+
source=template_source(template, project.source_dir),
|
|
486
|
+
)
|
|
487
|
+
longest = max((len(line) for case in compiled for line in case), default=0)
|
|
488
|
+
if longest > LONG_FORMULA_WARN:
|
|
489
|
+
warnings.append(
|
|
490
|
+
f"模板 {template.name!r} 的最长公式 {longest} 字符"
|
|
491
|
+
f"(警告阈值 {LONG_FORMULA_WARN}):一个 {{{{ x }}}} 约展开 300–400 字符,"
|
|
492
|
+
"一行超过 8 个占位符就该考虑拆行"
|
|
493
|
+
)
|
|
494
|
+
template_table.add_row(
|
|
495
|
+
template.name,
|
|
496
|
+
"excel·公式" if template.engine == "excel" else "snapshot·快照",
|
|
497
|
+
f"{template.output_sheet} @ {template.start_cell} ({template.direction})",
|
|
498
|
+
template.filename or f"{template.name}_{{{{ case_name }}}}{template.extension}",
|
|
499
|
+
", ".join(sorted(used)) or "-",
|
|
500
|
+
)
|
|
501
|
+
if template.case_filter:
|
|
502
|
+
console.print(f" [dim]{template.name}:case_filter = {template.case_filter}[/]")
|
|
503
|
+
console.print(template_table)
|
|
504
|
+
|
|
505
|
+
if formula_templates:
|
|
506
|
+
console.print(
|
|
507
|
+
f"[cyan]i[/] 公式模式(engine: excel)的模板: {', '.join(formula_templates)} —— "
|
|
508
|
+
"改参数后 Excel 打开即重算;"
|
|
509
|
+
"但要注意:值与公式都只在 Excel/WPS 里成立,导出代码文件(--outdir)与 CI 检查(check)仍走命令行。"
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
variables_table = Table(title="变量清单", header_style="bold cyan")
|
|
513
|
+
variables_table.add_column("作用域")
|
|
514
|
+
variables_table.add_column("名称", style="bold")
|
|
515
|
+
variables_table.add_column("来源")
|
|
516
|
+
variables_table.add_column("类型")
|
|
517
|
+
variables_table.add_column("默认值")
|
|
518
|
+
variables_table.add_column("Prefix")
|
|
519
|
+
variables_table.add_column("Suffix")
|
|
520
|
+
variables_table.add_column("描述")
|
|
521
|
+
for scope, items in (
|
|
522
|
+
("global", project.global_variables),
|
|
523
|
+
("local", project.local_variables),
|
|
524
|
+
):
|
|
525
|
+
for variable in items:
|
|
526
|
+
variables_table.add_row(
|
|
527
|
+
scope,
|
|
528
|
+
variable.name,
|
|
529
|
+
"计算" if variable.is_derived else "填写",
|
|
530
|
+
variable.type,
|
|
531
|
+
str(variable.default),
|
|
532
|
+
variable.prefix or "-",
|
|
533
|
+
variable.suffix or "-",
|
|
534
|
+
variable.description or "-",
|
|
535
|
+
)
|
|
536
|
+
console.print(variables_table)
|
|
537
|
+
|
|
538
|
+
overlap = sorted(set(project.global_names) & set(project.local_names))
|
|
539
|
+
if overlap:
|
|
540
|
+
warnings.append(f"变量名同时出现在 global 与 local: {', '.join(overlap)}(local 会覆盖 global)")
|
|
541
|
+
|
|
542
|
+
# "定义了却没人用"是"改了参数没生效"的常见来源,单独提示
|
|
543
|
+
# (派生表达式里引用到的名字也算"被用到",否则每个派生链都会误报)
|
|
544
|
+
referenced: set[str] = set()
|
|
545
|
+
for names in used_by_template.values():
|
|
546
|
+
referenced |= names
|
|
547
|
+
for variable in [*project.global_variables, *project.local_variables]:
|
|
548
|
+
if variable.is_derived:
|
|
549
|
+
referenced |= expression_names(variable.derived or "", env=environment)
|
|
550
|
+
unused = [name for name in [*project.global_names, *project.local_names] if name not in referenced]
|
|
551
|
+
if unused:
|
|
552
|
+
warnings.append(
|
|
553
|
+
f"YAML 定义了但没有被任何模板引用的变量: {', '.join(unused)}"
|
|
554
|
+
"(拼写错误?或只是给 Excel 读者看的标注变量)"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if excel is not None:
|
|
558
|
+
excel_path = Path(excel)
|
|
559
|
+
workbook = load_workbook_file(excel_path)
|
|
560
|
+
try:
|
|
561
|
+
check_required_sheets(workbook, project)
|
|
562
|
+
global_values = read_global_values(workbook, project)
|
|
563
|
+
cases = read_cases(workbook, project)
|
|
564
|
+
members = read_group_members(workbook, project)
|
|
565
|
+
metadata = read_metadata(workbook, project)
|
|
566
|
+
finally:
|
|
567
|
+
workbook.close()
|
|
568
|
+
# 取值约束:这一条会让"表里填错了一个数字"在 validate 阶段就暴露
|
|
569
|
+
check_value_constraints(project, global_values, cases, members=members)
|
|
570
|
+
check_asserts(project, global_values, cases, members=members, env=environment)
|
|
571
|
+
excel_table = Table(title="Excel 检查", header_style="bold cyan")
|
|
572
|
+
excel_table.add_column("项目", style="bold")
|
|
573
|
+
excel_table.add_column("内容")
|
|
574
|
+
excel_table.add_row("文件", str(excel_path))
|
|
575
|
+
excel_table.add_row("Global 变量", ", ".join(global_values) or "-")
|
|
576
|
+
excel_table.add_row(
|
|
577
|
+
_case_axis_label(project),
|
|
578
|
+
", ".join(f"{case.name}({case.where})" for case in cases),
|
|
579
|
+
)
|
|
580
|
+
for case in cases:
|
|
581
|
+
excel_table.add_row(
|
|
582
|
+
f"Case {case.name}",
|
|
583
|
+
", ".join(f"{key}={value}" for key, value in case.values.items()) or "-",
|
|
584
|
+
)
|
|
585
|
+
if metadata:
|
|
586
|
+
excel_table.add_row("上次渲染", metadata.get("时间", "-"))
|
|
587
|
+
excel_table.add_row(
|
|
588
|
+
"参数指纹",
|
|
589
|
+
f"{metadata.get('参数指纹', '-')}(当前 {input_fingerprint(global_values, cases)})",
|
|
590
|
+
)
|
|
591
|
+
else:
|
|
592
|
+
excel_table.add_row("上次渲染", "(无记录:还没跑过 render --write-excel)")
|
|
593
|
+
console.print(excel_table)
|
|
594
|
+
except CodeGenError as exc:
|
|
595
|
+
raise _fail(exc) from exc
|
|
596
|
+
except (ValueError, OSError) as exc:
|
|
597
|
+
raise _fail(exc) from exc
|
|
598
|
+
|
|
599
|
+
for warning in warnings:
|
|
600
|
+
_warn(warning)
|
|
601
|
+
console.print("[bold green]OK[/] 配置校验通过")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# --------------------------------------------------------------------------- #
|
|
605
|
+
# check:输出表是否已过期
|
|
606
|
+
# --------------------------------------------------------------------------- #
|
|
607
|
+
def _last_non_empty_row(worksheet, start_row: int, column: int) -> int:
|
|
608
|
+
last = start_row - 1
|
|
609
|
+
for row in range(start_row, worksheet.max_row + 1):
|
|
610
|
+
if worksheet.cell(row=row, column=column).value not in (None, ""):
|
|
611
|
+
last = row
|
|
612
|
+
return last
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _last_non_empty_column(worksheet, row: int, start_column: int) -> int:
|
|
616
|
+
last = start_column - 1
|
|
617
|
+
for column in range(start_column, worksheet.max_column + 1):
|
|
618
|
+
if worksheet.cell(row=row, column=column).value not in (None, ""):
|
|
619
|
+
last = column
|
|
620
|
+
return last
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _read_column(worksheet, column: int, start_row: int) -> list[str]:
|
|
624
|
+
last = _last_non_empty_row(worksheet, start_row, column)
|
|
625
|
+
if last < start_row:
|
|
626
|
+
return []
|
|
627
|
+
return [to_text(worksheet.cell(row=row, column=column).value) for row in range(start_row, last + 1)]
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _read_row(worksheet, row: int, start_column: int) -> list[str]:
|
|
631
|
+
last = _last_non_empty_column(worksheet, row, start_column)
|
|
632
|
+
if last < start_column:
|
|
633
|
+
return []
|
|
634
|
+
return [to_text(worksheet.cell(row=row, column=column).value) for column in range(start_column, last + 1)]
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _expected_lines(
|
|
638
|
+
workbook,
|
|
639
|
+
project: ProjectConfig,
|
|
640
|
+
template,
|
|
641
|
+
results: Sequence[RenderResult],
|
|
642
|
+
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
|
643
|
+
"""每个 Case 期望出现在输出表里的内容,以及"人读的标签"。
|
|
644
|
+
|
|
645
|
+
快照模式 = 渲染文本;公式模式 = 编译出来的公式,标签是对应的模板源行
|
|
646
|
+
(差异信息里贴模板行,比贴几百字符的公式有用得多)。
|
|
647
|
+
|
|
648
|
+
:returns: ``(期望内容, 模板源行)``
|
|
649
|
+
"""
|
|
650
|
+
if template.engine != "excel":
|
|
651
|
+
return {result.case_name: result.lines for result in results}, {}
|
|
652
|
+
|
|
653
|
+
columns = case_axis_map(workbook, project)
|
|
654
|
+
missing = [result.case_name for result in results if result.case_name not in columns]
|
|
655
|
+
if missing:
|
|
656
|
+
raise ExcelError(f"模板 {template.name!r} 使用公式模式,但 Local 表里找不到这些 Case 列: {', '.join(missing)}")
|
|
657
|
+
source = template_source(template, project.source_dir)
|
|
658
|
+
per_case = compile_formulas(
|
|
659
|
+
template,
|
|
660
|
+
project,
|
|
661
|
+
case_axes=[columns[result.case_name] for result in results],
|
|
662
|
+
source=source,
|
|
663
|
+
)
|
|
664
|
+
labels = {case_name: source.splitlines() for case_name in (r.case_name for r in results)}
|
|
665
|
+
return {result.case_name: lines for result, lines in zip(results, per_case, strict=False)}, labels
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _describe_line(index: int, labels: Sequence[str] | None) -> str:
|
|
669
|
+
if labels and index < len(labels):
|
|
670
|
+
return f"(模板第 {index + 1} 行:{labels[index].strip()!r})"
|
|
671
|
+
return ""
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _first_difference(
|
|
675
|
+
actual: list[str],
|
|
676
|
+
expected: list[str],
|
|
677
|
+
*,
|
|
678
|
+
labels: Sequence[str] | None = None,
|
|
679
|
+
what: str = "内容",
|
|
680
|
+
) -> str:
|
|
681
|
+
differences = _differences(actual, expected, labels=labels, what=what, limit=1)
|
|
682
|
+
return differences[0] if differences else f"{what}不同"
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _differences(
|
|
686
|
+
actual: list[str],
|
|
687
|
+
expected: list[str],
|
|
688
|
+
*,
|
|
689
|
+
labels: Sequence[str] | None = None,
|
|
690
|
+
what: str = "内容",
|
|
691
|
+
limit: int = 5,
|
|
692
|
+
) -> list[str]:
|
|
693
|
+
"""列出**所有**不同的行(最多 ``limit`` 条,其余折成一句"还有 N 行")。
|
|
694
|
+
|
|
695
|
+
只报第一处差异在 CI 里很难定位 —— 用户想知道"一共差多少、都差在哪"。
|
|
696
|
+
"""
|
|
697
|
+
found: list[str] = []
|
|
698
|
+
for index in range(min(len(actual), len(expected))):
|
|
699
|
+
if actual[index] == expected[index]:
|
|
700
|
+
continue
|
|
701
|
+
if len(found) < limit:
|
|
702
|
+
if labels is not None:
|
|
703
|
+
found.append(
|
|
704
|
+
f"第 {index + 1} 行不同{_describe_line(index, labels)};"
|
|
705
|
+
f"表里 {_clip(actual[index])},应为 {_clip(expected[index])}"
|
|
706
|
+
)
|
|
707
|
+
else:
|
|
708
|
+
found.append(f"第 {index + 1} 行不同:表里 {actual[index]!r},应为 {expected[index]!r}")
|
|
709
|
+
hidden = sum(1 for index in range(min(len(actual), len(expected))) if actual[index] != expected[index]) - len(found)
|
|
710
|
+
if hidden > 0:
|
|
711
|
+
found.append(f"……还有 {hidden} 行不同(只列了前 {limit} 行)")
|
|
712
|
+
if not found and len(actual) != len(expected):
|
|
713
|
+
found.append(f"行数不同:表里 {len(actual)} 行,应为 {len(expected)} 行")
|
|
714
|
+
if not found:
|
|
715
|
+
found.append(f"{what}不同")
|
|
716
|
+
return found
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _clip(text: str, limit: int = 80) -> str:
|
|
720
|
+
"""把可能几百字符的公式裁到人能看的长度。"""
|
|
721
|
+
shown = text if len(text) <= limit else text[:limit] + "…"
|
|
722
|
+
return repr(shown)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _output_differences(
|
|
726
|
+
workbook,
|
|
727
|
+
project: ProjectConfig,
|
|
728
|
+
fresh: RenderOutput,
|
|
729
|
+
*,
|
|
730
|
+
verify_values: bool = True,
|
|
731
|
+
) -> tuple[list[str], list[str]]:
|
|
732
|
+
"""返回 ``(问题, 提示)``。
|
|
733
|
+
|
|
734
|
+
* 快照模式:逐行比渲染文本。
|
|
735
|
+
* 公式模式:① 比公式文本(过期 → 需要重跑 ``--write-excel``);
|
|
736
|
+
② **把公式在 Python 里算一遍**,与 Python 渲染逐行比对 —— 这一条能抓到
|
|
737
|
+
"列标指错 / 该用 ISBLANK 却用 =''" 这类生成端问题,也能抓到"参数表结构变了"。
|
|
738
|
+
"""
|
|
739
|
+
problems: list[str] = []
|
|
740
|
+
notes: list[str] = []
|
|
741
|
+
for template in project.templates:
|
|
742
|
+
results: list[RenderResult] = list(fresh.results.get(template.name) or [])
|
|
743
|
+
if template.output_sheet not in workbook.sheetnames:
|
|
744
|
+
problems.append(f"{template.name}: 工作表 {template.output_sheet!r} 不存在")
|
|
745
|
+
continue
|
|
746
|
+
worksheet = workbook[template.output_sheet]
|
|
747
|
+
column, row = parse_cell(template.start_cell)
|
|
748
|
+
if not results:
|
|
749
|
+
problems.append(
|
|
750
|
+
f"{template.name}: 当前没有任何 Case 匹配 case_filter,{template.output_sheet} 表里的旧内容无法核对"
|
|
751
|
+
)
|
|
752
|
+
continue
|
|
753
|
+
expected, labels = _expected_lines(workbook, project, template, results)
|
|
754
|
+
|
|
755
|
+
evaluated: dict[str, list[str]] | None = None
|
|
756
|
+
if template.engine == "excel" and verify_values:
|
|
757
|
+
try:
|
|
758
|
+
evaluated = evaluate_template_values(workbook, project, template, [r.case_name for r in results])
|
|
759
|
+
except FormulaEvalError as exc:
|
|
760
|
+
notes.append(f"{template.name}: 公式值校验已跳过 —— {exc}")
|
|
761
|
+
|
|
762
|
+
for index, result in enumerate(results):
|
|
763
|
+
if template.direction == "horizontal":
|
|
764
|
+
at = column + index
|
|
765
|
+
label = f"{template.output_sheet} 第 {column_index_to_letter(at)} 列({result.case_name})"
|
|
766
|
+
actual = _read_column(worksheet, at, row)
|
|
767
|
+
header = (
|
|
768
|
+
to_text(worksheet.cell(row=row - 1, column=at).value)
|
|
769
|
+
if template.write_case_headers and row > 1
|
|
770
|
+
else result.case_name
|
|
771
|
+
)
|
|
772
|
+
else:
|
|
773
|
+
at = row + index
|
|
774
|
+
label = f"{template.output_sheet} 第 {at} 行({result.case_name})"
|
|
775
|
+
actual = _read_row(worksheet, at, column)
|
|
776
|
+
header = (
|
|
777
|
+
to_text(worksheet.cell(row=at, column=column - 1).value)
|
|
778
|
+
if template.write_case_headers and column > 1
|
|
779
|
+
else result.case_name
|
|
780
|
+
)
|
|
781
|
+
if header != result.case_name:
|
|
782
|
+
problems.append(f"{label}:Case 表头是 {header!r},应为 {result.case_name!r}")
|
|
783
|
+
want = expected[result.case_name]
|
|
784
|
+
formulas_match = actual == want
|
|
785
|
+
if not formulas_match:
|
|
786
|
+
detail = ";".join(
|
|
787
|
+
_differences(
|
|
788
|
+
actual,
|
|
789
|
+
want,
|
|
790
|
+
labels=labels.get(result.case_name) if labels else None,
|
|
791
|
+
)
|
|
792
|
+
)
|
|
793
|
+
if template.engine == "excel":
|
|
794
|
+
detail += "(公式模式:比的是公式,重跑 --write-excel 刷新)"
|
|
795
|
+
problems.append(f"{label}:{detail}")
|
|
796
|
+
elif evaluated is not None:
|
|
797
|
+
# 公式文本一致,再把公式算一遍与 Python 渲染对比
|
|
798
|
+
got = evaluated.get(result.case_name, [])
|
|
799
|
+
if got != result.lines:
|
|
800
|
+
problems.append(
|
|
801
|
+
f"{label}:`公式算出来的文本`与 Python 渲染不一致 → "
|
|
802
|
+
+ ";".join(_differences(got, result.lines))
|
|
803
|
+
+ f"(参数表结构改动过?插/删过 {_case_axis_label(project)}?请重跑 --write-excel)"
|
|
804
|
+
)
|
|
805
|
+
return problems, notes
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
@app.command("check")
|
|
809
|
+
def check_command(
|
|
810
|
+
config: Path = typer.Option(
|
|
811
|
+
...,
|
|
812
|
+
"--config",
|
|
813
|
+
"-c",
|
|
814
|
+
exists=True,
|
|
815
|
+
dir_okay=False,
|
|
816
|
+
readable=True,
|
|
817
|
+
help="YAML 配置文件路径",
|
|
818
|
+
),
|
|
819
|
+
excel: Path | None = typer.Option(None, "--excel", "-x", help="要检查的 Excel(默认取配置中的 excel.output)"),
|
|
820
|
+
verify_values: bool = typer.Option(
|
|
821
|
+
True,
|
|
822
|
+
"--values/--no-values",
|
|
823
|
+
help="公式模式:把 Output 表的公式在 Python 里算一遍,与 Python 渲染比对(推荐开)",
|
|
824
|
+
),
|
|
825
|
+
json_output: bool = typer.Option(
|
|
826
|
+
False,
|
|
827
|
+
"--json",
|
|
828
|
+
help="把结果打成 JSON 输出(给 CI / 看板消费),不打表格",
|
|
829
|
+
),
|
|
830
|
+
) -> None:
|
|
831
|
+
"""检查 Excel 里的输出表是否与当前参数一致;过期则退出码 1(可放进 CI)。"""
|
|
832
|
+
try:
|
|
833
|
+
project = _load_project(config)
|
|
834
|
+
excel_path = _open_excel(project, excel)
|
|
835
|
+
fresh = render_all(project, excel_path)
|
|
836
|
+
workbook = load_workbook_file(excel_path)
|
|
837
|
+
try:
|
|
838
|
+
recorded = read_metadata(workbook, project)
|
|
839
|
+
problems, notes = _output_differences(workbook, project, fresh, verify_values=verify_values)
|
|
840
|
+
finally:
|
|
841
|
+
workbook.close()
|
|
842
|
+
now_input = input_fingerprint(fresh.global_values, fresh.cases)
|
|
843
|
+
now_output = output_fingerprint(fresh.results)
|
|
844
|
+
except CodeGenError as exc:
|
|
845
|
+
raise _fail(exc) from exc
|
|
846
|
+
except (ValueError, OSError) as exc:
|
|
847
|
+
raise _fail(exc) from exc
|
|
848
|
+
|
|
849
|
+
for warning in fresh.warnings:
|
|
850
|
+
_warn(warning)
|
|
851
|
+
for note in notes:
|
|
852
|
+
console.print(f"[yellow]![/] {note}")
|
|
853
|
+
|
|
854
|
+
recorded_input = recorded.get("参数指纹", "")
|
|
855
|
+
recorded_output = recorded.get("输出指纹", "")
|
|
856
|
+
snapshot_names = [t.name for t in project.templates if t.engine != "excel"]
|
|
857
|
+
formula_names = [t.name for t in project.templates if t.engine == "excel"]
|
|
858
|
+
drift = bool(recorded_input) and recorded_input != now_input
|
|
859
|
+
drift_note = ""
|
|
860
|
+
if drift:
|
|
861
|
+
drift_note = (
|
|
862
|
+
" ← 参数改过了(快照模板需要重跑)" if snapshot_names else " ← 参数改过了;公式模板会自动重算,无需重跑"
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
if json_output:
|
|
866
|
+
# --json:stdout 上只留 JSON,方便直接喂给 jq / CI 看板
|
|
867
|
+
_print_check_json(
|
|
868
|
+
config=config,
|
|
869
|
+
excel_path=excel_path,
|
|
870
|
+
recorded=recorded,
|
|
871
|
+
now_input=now_input,
|
|
872
|
+
now_output=now_output,
|
|
873
|
+
warnings=[*fresh.warnings, *notes],
|
|
874
|
+
problems=problems,
|
|
875
|
+
)
|
|
876
|
+
if problems:
|
|
877
|
+
raise typer.Exit(code=1)
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
table = Table(title="过期检查", header_style="bold cyan")
|
|
881
|
+
table.add_column("项目", style="bold")
|
|
882
|
+
table.add_column("内容")
|
|
883
|
+
table.add_row("配置文件", str(config))
|
|
884
|
+
table.add_row("Excel", str(excel_path))
|
|
885
|
+
table.add_row("上次渲染时间", recorded.get("时间", "(无记录)"))
|
|
886
|
+
table.add_row(
|
|
887
|
+
"模板引擎",
|
|
888
|
+
"公式: " + (", ".join(formula_names) or "(无)") + " | 快照: " + (", ".join(snapshot_names) or "(无)"),
|
|
889
|
+
)
|
|
890
|
+
table.add_row("参数指纹", f"记录 {recorded_input or '(无)'} / 当前 {now_input}" + drift_note)
|
|
891
|
+
table.add_row("输出指纹", f"记录 {recorded_output or '(无)'} / 当前 {now_output}")
|
|
892
|
+
console.print(table)
|
|
893
|
+
|
|
894
|
+
if not recorded:
|
|
895
|
+
_warn("这个工作簿里没有渲染记录(没跑过 `render --write-excel`?)。下面按内容逐行比对。")
|
|
896
|
+
if formula_names and not snapshot_names:
|
|
897
|
+
console.print(
|
|
898
|
+
"[cyan]i[/] 全部模板都是公式模式:改参数不需要重跑;"
|
|
899
|
+
"check 会同时比「公式是否与当前 YAML 一致」和「公式算出来的文本是否与 Python 渲染一致」。"
|
|
900
|
+
)
|
|
901
|
+
if formula_names and verify_values:
|
|
902
|
+
console.print("[cyan]i[/] 值校验已开启(--no-values 可关闭):公式在 Python 里算了一遍再比对。")
|
|
903
|
+
if problems:
|
|
904
|
+
error_console.print(f"[bold red]ERROR[/] 输出表已过期,共 {len(problems)} 处不一致:")
|
|
905
|
+
for problem in problems:
|
|
906
|
+
error_console.print(f" {problem}")
|
|
907
|
+
error_console.print(f" → 跑一次 `excel-codegen render -c {config} -x {excel_path} --write-excel` 刷新")
|
|
908
|
+
raise typer.Exit(code=1)
|
|
909
|
+
console.print("[bold green]OK[/] 输出表与当前参数一致")
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _print_check_json(
|
|
913
|
+
*,
|
|
914
|
+
config: Path,
|
|
915
|
+
excel_path: Path,
|
|
916
|
+
recorded: dict[str, str],
|
|
917
|
+
now_input: str,
|
|
918
|
+
now_output: str,
|
|
919
|
+
warnings: Sequence[str],
|
|
920
|
+
problems: Sequence[str],
|
|
921
|
+
) -> None:
|
|
922
|
+
"""``check --json``:给 CI / 看板消费的机读结果。
|
|
923
|
+
|
|
924
|
+
退出码与表格模式一致(过期 = 1),所以两种模式可以互换。
|
|
925
|
+
"""
|
|
926
|
+
payload = {
|
|
927
|
+
"ok": not problems,
|
|
928
|
+
"config": str(config),
|
|
929
|
+
"excel": str(excel_path),
|
|
930
|
+
"recorded": {
|
|
931
|
+
"time": recorded.get("时间", ""),
|
|
932
|
+
"input_fingerprint": recorded.get("参数指纹", ""),
|
|
933
|
+
"output_fingerprint": recorded.get("输出指纹", ""),
|
|
934
|
+
},
|
|
935
|
+
"current": {"input_fingerprint": now_input, "output_fingerprint": now_output},
|
|
936
|
+
"drift": bool(recorded.get("参数指纹")) and recorded.get("参数指纹") != now_input,
|
|
937
|
+
"warnings": list(warnings),
|
|
938
|
+
"problems": list(problems),
|
|
939
|
+
}
|
|
940
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
# --------------------------------------------------------------------------- #
|
|
944
|
+
# doctor:一条命令体检环境 / 配置 / 工作簿
|
|
945
|
+
# --------------------------------------------------------------------------- #
|
|
946
|
+
#: doctor 的一行结论:级别(OK / ! / ERROR)、项目、说明
|
|
947
|
+
_Finding = tuple[str, str, str]
|
|
948
|
+
|
|
949
|
+
#: 支持的 Python 下限(与 pyproject.toml 的 requires-python 对应)
|
|
950
|
+
_MIN_PYTHON = (3, 11)
|
|
951
|
+
|
|
952
|
+
#: 运行时依赖(与 pyproject.toml 的 [project.dependencies] 对应)
|
|
953
|
+
_RUNTIME_DEPS = ("openpyxl", "jinja2", "yaml", "pydantic", "typer", "rich")
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _doctor_environment() -> list[_Finding]:
|
|
957
|
+
"""环境这一层:Python 版本、依赖、可选工具、装法。"""
|
|
958
|
+
import importlib.metadata as md
|
|
959
|
+
import shutil
|
|
960
|
+
|
|
961
|
+
findings: list[_Finding] = []
|
|
962
|
+
|
|
963
|
+
version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
964
|
+
# 与 pyproject.toml 的 requires-python 保持一致。写成变量是故意的:
|
|
965
|
+
# doctor 的职责就是"报告环境",不能让 linter 把这个检查当成恒真优化掉。
|
|
966
|
+
minimum = _MIN_PYTHON
|
|
967
|
+
if sys.version_info[:2] >= minimum:
|
|
968
|
+
findings.append(("OK", "Python", f"{version}(本项目要求 >={minimum[0]}.{minimum[1]})"))
|
|
969
|
+
else:
|
|
970
|
+
findings.append(("ERROR", "Python", f"{version} 低于要求的 {minimum[0]}.{minimum[1]} —— 请升级解释器"))
|
|
971
|
+
|
|
972
|
+
missing: list[str] = []
|
|
973
|
+
versions: list[str] = []
|
|
974
|
+
for name in _RUNTIME_DEPS:
|
|
975
|
+
try:
|
|
976
|
+
dist = "pyyaml" if name == "yaml" else name
|
|
977
|
+
versions.append(f"{name} {md.version(dist)}")
|
|
978
|
+
except md.PackageNotFoundError:
|
|
979
|
+
missing.append(name)
|
|
980
|
+
if missing:
|
|
981
|
+
findings.append(("ERROR", "依赖", f"缺这些包:{', '.join(missing)}(跑一次 uv sync 或 pip install -e .)"))
|
|
982
|
+
else:
|
|
983
|
+
findings.append(("OK", "依赖", "、".join(versions)))
|
|
984
|
+
|
|
985
|
+
if shutil.which("uv"):
|
|
986
|
+
findings.append(("OK", "uv", "在(推荐用 uv run …,环境自洽)"))
|
|
987
|
+
else:
|
|
988
|
+
findings.append(("!", "uv", "没找到 —— 用 uv 可以免去 venv / pip 的差异(见 docs/setup.md)"))
|
|
989
|
+
|
|
990
|
+
if shutil.which("git"):
|
|
991
|
+
findings.append(("OK", "git", "在"))
|
|
992
|
+
else:
|
|
993
|
+
findings.append(("!", "git", "没找到 —— 建议用版本管理管住 YAML 与工作簿"))
|
|
994
|
+
|
|
995
|
+
if sys.prefix != getattr(sys, "base_prefix", sys.prefix):
|
|
996
|
+
findings.append(("OK", "运行环境", f"虚拟环境({sys.prefix})"))
|
|
997
|
+
else:
|
|
998
|
+
findings.append(("!", "运行环境", "用的是系统 Python —— 建议装在 venv / uv 环境里"))
|
|
999
|
+
return findings
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _doctor_config(project: ProjectConfig, excel_path: Path | None) -> tuple[list[_Finding], list[str]]:
|
|
1003
|
+
"""配置这一层:结构统计 + 模板/派生/断言检查。返回 (结论, 错误列表)。"""
|
|
1004
|
+
findings: list[_Finding] = []
|
|
1005
|
+
errors: list[str] = []
|
|
1006
|
+
|
|
1007
|
+
origin = f"extends {len(project.extends)} 个文件" if project.extends else "单文件"
|
|
1008
|
+
findings.append(("OK", "YAML", f"加载成功({origin})"))
|
|
1009
|
+
formula = [t.name for t in project.templates if t.engine == "excel"]
|
|
1010
|
+
snapshot = [t.name for t in project.templates if t.engine != "excel"]
|
|
1011
|
+
findings.append(
|
|
1012
|
+
(
|
|
1013
|
+
"OK",
|
|
1014
|
+
"规模",
|
|
1015
|
+
f"{len(project.templates)} 个模板(公式 {len(formula)} / 快照 {len(snapshot)})、"
|
|
1016
|
+
f"{len(project.global_variables)} 个全局变量、{len(project.local_variables)} 个局部变量",
|
|
1017
|
+
)
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
constrained = [v.name for v in (*project.global_variables, *project.local_variables) if v.has_constraints]
|
|
1021
|
+
if constrained or project.asserts:
|
|
1022
|
+
findings.append(
|
|
1023
|
+
(
|
|
1024
|
+
"OK",
|
|
1025
|
+
"校验规则",
|
|
1026
|
+
f"{len(constrained)} 个变量带取值约束、{len(project.asserts)} 条跨变量 asserts",
|
|
1027
|
+
)
|
|
1028
|
+
)
|
|
1029
|
+
else:
|
|
1030
|
+
findings.append(("!", "校验规则", "一条约束都没有 —— 填错值不会被拦住(指南 §3.5 / §3.6)"))
|
|
1031
|
+
|
|
1032
|
+
environment = build_environment()
|
|
1033
|
+
used_all: set[str] = set()
|
|
1034
|
+
for template in project.templates:
|
|
1035
|
+
try:
|
|
1036
|
+
validate_template(template, base_dir=project.source_dir)
|
|
1037
|
+
used_all |= collect_variables(template, base_dir=project.source_dir)
|
|
1038
|
+
except CodeGenError as exc:
|
|
1039
|
+
errors.append(str(exc))
|
|
1040
|
+
findings.append(("ERROR", f"模板 {template.name}", str(exc)))
|
|
1041
|
+
continue
|
|
1042
|
+
if template.engine == "excel":
|
|
1043
|
+
try:
|
|
1044
|
+
compiled = compile_formulas(
|
|
1045
|
+
template,
|
|
1046
|
+
project,
|
|
1047
|
+
case_axes=[1], # 只量长度,轴取哪个都行
|
|
1048
|
+
source=template_source(template, project.source_dir),
|
|
1049
|
+
)
|
|
1050
|
+
longest = max((len(line) for case in compiled for line in case), default=0)
|
|
1051
|
+
if longest > LONG_FORMULA_WARN:
|
|
1052
|
+
findings.append(
|
|
1053
|
+
(
|
|
1054
|
+
"!",
|
|
1055
|
+
f"模板 {template.name}",
|
|
1056
|
+
f"最长公式 {longest} 字符(>{LONG_FORMULA_WARN})—— 一行占位符太多,考虑拆行",
|
|
1057
|
+
)
|
|
1058
|
+
)
|
|
1059
|
+
except CodeGenError as exc:
|
|
1060
|
+
errors.append(str(exc))
|
|
1061
|
+
findings.append(("ERROR", f"模板 {template.name}", str(exc)))
|
|
1062
|
+
|
|
1063
|
+
try:
|
|
1064
|
+
warnings = derived_validate_config(project, env=environment)
|
|
1065
|
+
findings.append(
|
|
1066
|
+
("!" if warnings else "OK", "派生参数", ";".join(warnings) if warnings else "没有派生参数问题")
|
|
1067
|
+
)
|
|
1068
|
+
except CodeGenError as exc:
|
|
1069
|
+
errors.append(str(exc))
|
|
1070
|
+
findings.append(("ERROR", "派生参数", str(exc)))
|
|
1071
|
+
|
|
1072
|
+
try:
|
|
1073
|
+
compile_asserts(project, env=environment)
|
|
1074
|
+
except CodeGenError as exc:
|
|
1075
|
+
errors.append(str(exc))
|
|
1076
|
+
findings.append(("ERROR", "asserts", str(exc)))
|
|
1077
|
+
|
|
1078
|
+
# 只看 YAML 里真的定义了的变量:defined_names 含保留名(case_name / template_name),
|
|
1079
|
+
# 那是"模板里可以引用的名字",不是"定义了没人用"
|
|
1080
|
+
declared = [*project.global_names, *project.local_names]
|
|
1081
|
+
unused = sorted(name for name in declared if name not in used_all)
|
|
1082
|
+
if unused:
|
|
1083
|
+
findings.append(("!", "未使用的变量", f"{', '.join(unused)} —— 定义了但没有模板引用(拼写错误?)"))
|
|
1084
|
+
else:
|
|
1085
|
+
findings.append(("OK", "变量使用", "YAML 里定义的变量都被模板用到了"))
|
|
1086
|
+
return findings, errors
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _doctor_workbook(project: ProjectConfig, excel_path: Path) -> tuple[list[_Finding], list[str]]:
|
|
1090
|
+
"""工作簿这一层:结构、取值、指纹。返回 (结论, 错误列表)。"""
|
|
1091
|
+
findings: list[_Finding] = []
|
|
1092
|
+
errors: list[str] = []
|
|
1093
|
+
if not excel_path.exists():
|
|
1094
|
+
findings.append(("!", "工作簿", f"{excel_path} 不存在 —— 先跑 init 生成骨架"))
|
|
1095
|
+
return findings, errors
|
|
1096
|
+
|
|
1097
|
+
workbook = load_workbook_file(excel_path)
|
|
1098
|
+
try:
|
|
1099
|
+
check_required_sheets(workbook, project)
|
|
1100
|
+
findings.append(("OK", "工作表", "、".join(workbook.sheetnames)))
|
|
1101
|
+
global_values = read_global_values(workbook, project)
|
|
1102
|
+
cases = read_cases(workbook, project, global_values=global_values)
|
|
1103
|
+
axis_label = _case_axis_label(project)
|
|
1104
|
+
findings.append(("OK", axis_label, f"{len(cases)} 个:{', '.join(case.name for case in cases)}"))
|
|
1105
|
+
empty = [case.name for case in cases if not case.explicit_values]
|
|
1106
|
+
if empty:
|
|
1107
|
+
findings.append(
|
|
1108
|
+
(
|
|
1109
|
+
"!",
|
|
1110
|
+
f"空 {axis_label}",
|
|
1111
|
+
f"{', '.join(empty)} 整{'列' if project.excel.local_direction == 'horizontal' else '行'}"
|
|
1112
|
+
"都是空的 —— 所有变量都会回落 default,"
|
|
1113
|
+
"可能悄悄落进某个 case_filter(指南 §9.2)",
|
|
1114
|
+
)
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
for label, checker in (("取值约束", check_value_constraints), ("asserts", check_asserts)):
|
|
1118
|
+
try:
|
|
1119
|
+
checker(project, global_values, cases)
|
|
1120
|
+
findings.append(("OK", label, "全部满足"))
|
|
1121
|
+
except CodeGenError as exc:
|
|
1122
|
+
errors.append(str(exc))
|
|
1123
|
+
findings.append(("ERROR", label, str(exc).splitlines()[0]))
|
|
1124
|
+
|
|
1125
|
+
recorded = read_metadata(workbook, project)
|
|
1126
|
+
now_input = input_fingerprint(global_values, cases)
|
|
1127
|
+
try:
|
|
1128
|
+
now_output = output_fingerprint(render_all(project, excel_path).results)
|
|
1129
|
+
except CodeGenError as exc:
|
|
1130
|
+
# 参数本身有问题(约束 / asserts)时渲染不出来 —— 那已经在上面报过了
|
|
1131
|
+
errors.append(str(exc))
|
|
1132
|
+
now_output = ""
|
|
1133
|
+
finally:
|
|
1134
|
+
workbook.close()
|
|
1135
|
+
|
|
1136
|
+
if not recorded:
|
|
1137
|
+
findings.append(("!", "渲染记录", "没有 —— 还没跑过 render --write-excel"))
|
|
1138
|
+
elif not now_output:
|
|
1139
|
+
findings.append(("!", "渲染记录", "参数不满足校验规则,渲染不出来(先修上面的 ERROR)"))
|
|
1140
|
+
else:
|
|
1141
|
+
same_input = recorded.get("参数指纹") == now_input
|
|
1142
|
+
same_output = recorded.get("输出指纹") == now_output
|
|
1143
|
+
if same_input and same_output:
|
|
1144
|
+
findings.append(("OK", "渲染记录", f"与当前一致(参数指纹 {now_input})"))
|
|
1145
|
+
elif same_input:
|
|
1146
|
+
findings.append(("!", "渲染记录", "参数没变但输出指纹不同 —— 模板或代码改过,重跑一次"))
|
|
1147
|
+
else:
|
|
1148
|
+
findings.append(("!", "渲染记录", "参数改过了 —— 快照模板需要重跑(公式模板会自动重算)"))
|
|
1149
|
+
|
|
1150
|
+
scripts = excel_path.parent / f"{excel_path.stem}_render.sh"
|
|
1151
|
+
findings.append(
|
|
1152
|
+
("OK" if scripts.exists() else "!", "一键脚本", f"{scripts.name} {'在' if scripts.exists() else '不在'}")
|
|
1153
|
+
)
|
|
1154
|
+
return findings, errors
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
@app.command("doctor")
|
|
1158
|
+
def doctor_command(
|
|
1159
|
+
config: Path = typer.Option(
|
|
1160
|
+
..., "--config", "-c", exists=True, dir_okay=False, readable=True, help="YAML 配置文件路径"
|
|
1161
|
+
),
|
|
1162
|
+
excel: Path | None = typer.Option(None, "--excel", "-x", help="顺带体检这个工作簿(默认取配置中的 excel.output)"),
|
|
1163
|
+
) -> None:
|
|
1164
|
+
"""体检环境 / 配置 / 工作簿,把常见坑一次说清。有 ERROR 时退出码 1。"""
|
|
1165
|
+
findings: list[_Finding] = []
|
|
1166
|
+
errors: list[str] = []
|
|
1167
|
+
|
|
1168
|
+
findings.extend(_doctor_environment())
|
|
1169
|
+
|
|
1170
|
+
try:
|
|
1171
|
+
project = _load_project(config)
|
|
1172
|
+
except CodeGenError as exc:
|
|
1173
|
+
findings.append(("ERROR", "YAML", str(exc).splitlines()[0]))
|
|
1174
|
+
_render_doctor(findings)
|
|
1175
|
+
raise typer.Exit(code=1) from exc
|
|
1176
|
+
|
|
1177
|
+
excel_path = Path(excel or project.excel.output)
|
|
1178
|
+
config_findings, config_errors = _doctor_config(project, excel_path)
|
|
1179
|
+
workbook_findings, workbook_errors = _doctor_workbook(project, excel_path)
|
|
1180
|
+
findings.extend(config_findings)
|
|
1181
|
+
findings.extend(workbook_findings)
|
|
1182
|
+
errors.extend(config_errors)
|
|
1183
|
+
errors.extend(workbook_errors)
|
|
1184
|
+
|
|
1185
|
+
for warning in project.load_warnings:
|
|
1186
|
+
findings.append(("!", "extends", warning))
|
|
1187
|
+
|
|
1188
|
+
_render_doctor(findings)
|
|
1189
|
+
for problem in errors:
|
|
1190
|
+
error_console.print(f" {problem}")
|
|
1191
|
+
if errors:
|
|
1192
|
+
raise typer.Exit(code=1)
|
|
1193
|
+
notes = sum(1 for level, _, _ in findings if level == "!")
|
|
1194
|
+
console.print(
|
|
1195
|
+
f"[bold green]OK[/] 体检完成:{sum(1 for level, _, _ in findings if level == 'OK')} 项通过"
|
|
1196
|
+
+ (f",{notes} 项值得留意" if notes else ",没有需要留意的")
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
@app.command("examples")
|
|
1201
|
+
def examples_command(
|
|
1202
|
+
copy_to: Path | None = typer.Option(None, "--copy", "-o", help="把内置示例拷到这个目录(每个示例一个子目录)"),
|
|
1203
|
+
only: str | None = typer.Option(None, "--only", help="只处理某一个示例(basic / nastran / abs_fpi)"),
|
|
1204
|
+
force: bool = typer.Option(False, "--force", help="目标已存在且非空时覆盖"),
|
|
1205
|
+
) -> None:
|
|
1206
|
+
"""列出随包发布的内置示例;加 --copy DIR 就拷出来直接用。
|
|
1207
|
+
|
|
1208
|
+
示例随 wheel / sdist 一起发布 —— 不需要克隆仓库。每个示例都带一本**已经填好样例参数**
|
|
1209
|
+
的工作簿:公式模式下打开就能改、改完自动重算。
|
|
1210
|
+
"""
|
|
1211
|
+
if copy_to is None:
|
|
1212
|
+
console.print(
|
|
1213
|
+
"[bold]内置示例[/](随包发布 —— 不用克隆仓库;"
|
|
1214
|
+
"[cyan]excel-codegen examples --copy ./examples[/] 拷出来用)\n"
|
|
1215
|
+
)
|
|
1216
|
+
for item in example_pack.EXAMPLES:
|
|
1217
|
+
console.print(f" [bold cyan]{item.name}[/] {item.title}")
|
|
1218
|
+
console.print(f" [dim]{item.summary}[/]")
|
|
1219
|
+
console.print(f" 入口 [green]{item.entry}[/]")
|
|
1220
|
+
console.print(f" 工作簿 {item.workbook}\n")
|
|
1221
|
+
return
|
|
1222
|
+
|
|
1223
|
+
try:
|
|
1224
|
+
selected = [example_pack.find(only)] if only else list(example_pack.EXAMPLES)
|
|
1225
|
+
written = example_pack.copy_examples(copy_to, only=only, force=force)
|
|
1226
|
+
except CodeGenError as exc:
|
|
1227
|
+
raise _fail(exc) from exc
|
|
1228
|
+
|
|
1229
|
+
console.print(f"[bold green]OK[/] 已拷贝 {len(written)} 个示例到 [cyan]{copy_to}[/]")
|
|
1230
|
+
for item in selected:
|
|
1231
|
+
console.print(f" {item.name:9s} {item.workbook} [dim]打开就能改参数[/]")
|
|
1232
|
+
|
|
1233
|
+
first = selected[0]
|
|
1234
|
+
console.print(
|
|
1235
|
+
"\n下一步(可选):打开上面那本 .xlsx 填参数;"
|
|
1236
|
+
"要把代码导出成文件时跑\n"
|
|
1237
|
+
f" [cyan]excel-codegen render -c {copy_to / first.entry} "
|
|
1238
|
+
f"-x {copy_to / first.workbook} --outdir out[/]"
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _render_doctor(findings: Sequence[_Finding]) -> None:
|
|
1243
|
+
table = Table(title="体检报告", header_style="bold cyan")
|
|
1244
|
+
table.add_column("", width=6)
|
|
1245
|
+
table.add_column("项目", style="bold")
|
|
1246
|
+
table.add_column("结论")
|
|
1247
|
+
for level, name, detail in findings:
|
|
1248
|
+
mark = {"OK": "[green]OK[/]", "!": "[yellow]![/]"}.get(level, "[red]ERROR[/]")
|
|
1249
|
+
table.add_row(mark, name, detail)
|
|
1250
|
+
console.print(table)
|