docxtpl-lint 1.1.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.
- docxtpl_lint/__init__.py +6 -0
- docxtpl_lint/__main__.py +3 -0
- docxtpl_lint/cli.py +411 -0
- docxtpl_lint/config.py +77 -0
- docxtpl_lint/contract.py +298 -0
- docxtpl_lint/core.py +866 -0
- docxtpl_lint/fixes.py +187 -0
- docxtpl_lint/messages.py +203 -0
- docxtpl_lint/reader.py +123 -0
- docxtpl_lint/verify.py +70 -0
- docxtpl_lint-1.1.0.dist-info/METADATA +257 -0
- docxtpl_lint-1.1.0.dist-info/RECORD +15 -0
- docxtpl_lint-1.1.0.dist-info/WHEEL +4 -0
- docxtpl_lint-1.1.0.dist-info/entry_points.txt +2 -0
- docxtpl_lint-1.1.0.dist-info/licenses/LICENSE +21 -0
docxtpl_lint/__init__.py
ADDED
docxtpl_lint/__main__.py
ADDED
docxtpl_lint/cli.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 = no errors, 1 = errors found (or warnings with
|
|
4
|
+
``--warnings-as-errors``), 2 = usage / IO error.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
import zipfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .config import ConfigError, find_config, load_config
|
|
17
|
+
from .core import Report, lint_path, load_data
|
|
18
|
+
from .messages import RULES, help_url
|
|
19
|
+
|
|
20
|
+
JSON_SCHEMA_VERSION = 1
|
|
21
|
+
|
|
22
|
+
_COLOR = {"error": "\033[31m", "warning": "\033[33m", "dim": "\033[2m", "bold": "\033[1m", "reset": "\033[0m"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _c(name: str, text: str, enable: bool) -> str:
|
|
26
|
+
return f"{_COLOR[name]}{text}{_COLOR['reset']}" if enable else text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_text(
|
|
30
|
+
reports: list[Report], color: bool = False, show_vars: bool = False, lang: str = "en", context: int = 0
|
|
31
|
+
) -> str:
|
|
32
|
+
lines: list[str] = []
|
|
33
|
+
total = {"error": 0, "warning": 0}
|
|
34
|
+
for rep in reports:
|
|
35
|
+
for it in rep.issues:
|
|
36
|
+
total[it.severity] += 1
|
|
37
|
+
loc = it.location
|
|
38
|
+
lines.append(
|
|
39
|
+
f"{_c('bold', rep.file, color)}:{loc.human(lang)}:{loc.column}: "
|
|
40
|
+
f"{_c(it.severity, it.severity, color)} {_c('bold', it.rule, color)} {it.message_for(lang)}"
|
|
41
|
+
)
|
|
42
|
+
if it.snippet:
|
|
43
|
+
lines.append(" " + _c("dim", it.snippet, color))
|
|
44
|
+
for para in rep.context(loc, context):
|
|
45
|
+
mark = ">" if para.index == loc.paragraph else " "
|
|
46
|
+
cell = " > ".join(f"T{c.table}R{c.row}C{c.col}" for c in para.cells)
|
|
47
|
+
text = para.text if len(para.text) <= 100 else para.text[:97] + "..."
|
|
48
|
+
lines.append(_c("dim", f" {mark} ¶{para.index:<4}{cell:<10}| {text}", color))
|
|
49
|
+
if show_vars and rep.variables:
|
|
50
|
+
lines.append(f"{rep.file}: " + ("变量:" if lang == "zh" else "variables:"))
|
|
51
|
+
lines.extend(f" - {v.replace('[]', '[*]')}" for v in rep.variables)
|
|
52
|
+
n = len(reports)
|
|
53
|
+
if total["error"] or total["warning"]:
|
|
54
|
+
if lang == "zh":
|
|
55
|
+
lines.append(f"\n✖ {n} 个文件中有 {total['error']} 个错误、{total['warning']} 个警告")
|
|
56
|
+
else:
|
|
57
|
+
lines.append(f"\n✖ {total['error']} error(s), {total['warning']} warning(s) in {n} file(s)")
|
|
58
|
+
else:
|
|
59
|
+
lines.append(f"✔ {n} 个文件全部通过" if lang == "zh" else f"✔ {n} file(s) clean")
|
|
60
|
+
return "\n".join(lines)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render_json(reports: list[Report], lang: str = "en") -> str:
|
|
64
|
+
return json.dumps(
|
|
65
|
+
{
|
|
66
|
+
"schema_version": JSON_SCHEMA_VERSION,
|
|
67
|
+
"tool": "docxtpl-lint",
|
|
68
|
+
"version": __version__,
|
|
69
|
+
"files": [
|
|
70
|
+
{"file": r.file, "issues": [i.to_dict(lang) for i in r.issues], "variables": r.variables}
|
|
71
|
+
for r in reports
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
ensure_ascii=False,
|
|
75
|
+
indent=2,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _gh_escape(s: str) -> str:
|
|
80
|
+
return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _gh_prop(s: str) -> str:
|
|
84
|
+
return _gh_escape(s).replace(":", "%3A").replace(",", "%2C")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_github(reports: list[Report], lang: str = "en") -> str:
|
|
88
|
+
"""GitHub Actions workflow commands: annotations in the PR / run summary.
|
|
89
|
+
A .docx has no lines, so the location is put in the title and message."""
|
|
90
|
+
out = []
|
|
91
|
+
for rep in reports:
|
|
92
|
+
for it in rep.issues:
|
|
93
|
+
title = f"{it.rule} {it.location.human(lang)}"
|
|
94
|
+
out.append(
|
|
95
|
+
f"::{it.severity} file={_gh_prop(rep.file)},title={_gh_prop(title)}::"
|
|
96
|
+
f"{_gh_escape(it.location.human(lang) + ': ' + it.message_for(lang))}"
|
|
97
|
+
)
|
|
98
|
+
return "\n".join(out)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def render_sarif(reports: list[Report], lang: str = "en") -> str:
|
|
102
|
+
"""SARIF 2.1.0. A .docx has no lines, so the paragraph number is used as the
|
|
103
|
+
line and the table-cell path is put in the message."""
|
|
104
|
+
results = []
|
|
105
|
+
for rep in reports:
|
|
106
|
+
for it in rep.issues:
|
|
107
|
+
loc = it.location
|
|
108
|
+
results.append(
|
|
109
|
+
{
|
|
110
|
+
"ruleId": it.rule,
|
|
111
|
+
"level": it.severity,
|
|
112
|
+
"message": {"text": f"[{loc.human(lang)}] {it.message_for(lang)}"},
|
|
113
|
+
"locations": [
|
|
114
|
+
{
|
|
115
|
+
"physicalLocation": {
|
|
116
|
+
"artifactLocation": {"uri": rep.file},
|
|
117
|
+
"region": {"startLine": max(loc.paragraph, 1)},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
driver = {
|
|
124
|
+
"name": "docxtpl-lint",
|
|
125
|
+
"version": __version__,
|
|
126
|
+
"informationUri": "https://github.com/yq6666-66/docxtpl-lint",
|
|
127
|
+
"rules": [
|
|
128
|
+
{"id": r.id, "shortDescription": {"text": r.summary}, "helpUri": help_url(r.id)} for r in RULES.values()
|
|
129
|
+
],
|
|
130
|
+
}
|
|
131
|
+
return json.dumps(
|
|
132
|
+
{
|
|
133
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
134
|
+
"version": "2.1.0",
|
|
135
|
+
"runs": [{"tool": {"driver": driver}, "results": results}],
|
|
136
|
+
},
|
|
137
|
+
ensure_ascii=False,
|
|
138
|
+
indent=2,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
143
|
+
ap = argparse.ArgumentParser(
|
|
144
|
+
prog="docxtpl-lint",
|
|
145
|
+
description="Static linter for docxtpl (python-docx-template) Word templates.",
|
|
146
|
+
epilog="subcommands: infer (data contract -> JSON Schema + sample), verify (render with docxtpl and find "
|
|
147
|
+
"silently lost output), diff (contract changes between two template versions), fix (suggested "
|
|
148
|
+
"fixes as diffs). Run `docxtpl-lint <subcommand> -h`.",
|
|
149
|
+
)
|
|
150
|
+
ap.add_argument("files", nargs="*", help=".docx templates")
|
|
151
|
+
ap.add_argument(
|
|
152
|
+
"--data",
|
|
153
|
+
action="append",
|
|
154
|
+
help="JSON file with a sample context; enables DTL301/DTL303. Repeatable: a path is reported "
|
|
155
|
+
"only if it is absent from all given --data files",
|
|
156
|
+
)
|
|
157
|
+
ap.add_argument(
|
|
158
|
+
"--schema",
|
|
159
|
+
help="JSON Schema used as the data contract (e.g. from `docxtpl-lint infer`); enables DTL304/DTL305",
|
|
160
|
+
)
|
|
161
|
+
ap.add_argument("--report-unused-data", action="store_true", help="also report unused top-level data keys (DTL302)")
|
|
162
|
+
ap.add_argument("--format", choices=["text", "json", "github", "sarif"], default="text")
|
|
163
|
+
ap.add_argument("--lang", choices=["en", "zh"], default="en", help="message language (default: en)")
|
|
164
|
+
ap.add_argument("--select", help="comma separated rule ids/prefixes to enable, e.g. DTL1,DTL206")
|
|
165
|
+
ap.add_argument("--ignore", help="comma separated rule ids/prefixes to disable")
|
|
166
|
+
ap.add_argument("--allow-cross-paragraph", action="store_true", help="accept tags spanning paragraphs (no DTL002)")
|
|
167
|
+
ap.add_argument("--warnings-as-errors", action="store_true", help="exit 1 on warnings too")
|
|
168
|
+
ap.add_argument("--list-vars", action="store_true", help="print the data paths each template uses")
|
|
169
|
+
ap.add_argument("--list-rules", action="store_true", help="print all rules and exit")
|
|
170
|
+
ap.add_argument("--explain", metavar="RULE", help="explain one rule (e.g. DTL209) and exit")
|
|
171
|
+
ap.add_argument(
|
|
172
|
+
"--context",
|
|
173
|
+
type=int,
|
|
174
|
+
metavar="N",
|
|
175
|
+
help="show N paragraphs around each issue (text format; default: 1 in a terminal, 0 otherwise)",
|
|
176
|
+
)
|
|
177
|
+
ap.add_argument("--config", help="pyproject.toml to read [tool.docxtpl-lint] from (default: nearest one)")
|
|
178
|
+
ap.add_argument("--no-config", action="store_true", help="ignore [tool.docxtpl-lint] settings")
|
|
179
|
+
ap.add_argument("--no-color", action="store_true")
|
|
180
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
181
|
+
return ap
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
SUBCOMMANDS = ("infer", "verify", "diff", "fix")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _reconf() -> None:
|
|
188
|
+
for stream in (sys.stdout, sys.stderr):
|
|
189
|
+
if hasattr(stream, "reconfigure"):
|
|
190
|
+
stream.reconfigure(errors="replace")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _write(text: str, out: str | None) -> None:
|
|
194
|
+
if out:
|
|
195
|
+
Path(out).write_text(text + "\n", encoding="utf-8")
|
|
196
|
+
else:
|
|
197
|
+
print(text)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main_infer(argv: list[str]) -> int:
|
|
201
|
+
from .contract import infer_tree, sample, to_schema
|
|
202
|
+
|
|
203
|
+
ap = argparse.ArgumentParser(
|
|
204
|
+
prog="docxtpl-lint infer",
|
|
205
|
+
description="Infer the data contract (JSON Schema) of one or more templates; several templates are merged "
|
|
206
|
+
"and each property lists the templates that use it (x-docxtpl-templates).",
|
|
207
|
+
)
|
|
208
|
+
ap.add_argument("files", nargs="+")
|
|
209
|
+
ap.add_argument("-o", "--output", help="write the JSON Schema here (default: stdout)")
|
|
210
|
+
ap.add_argument("--sample", help="also write sample data that satisfies the schema")
|
|
211
|
+
ap.add_argument("--items", type=int, default=2, help="list items in the sample (default 2)")
|
|
212
|
+
a = ap.parse_args(argv)
|
|
213
|
+
tree = infer_tree(a.files)
|
|
214
|
+
_write(json.dumps(to_schema(tree), indent=2, ensure_ascii=False), a.output)
|
|
215
|
+
if a.sample:
|
|
216
|
+
_write(json.dumps(sample(tree, a.items), indent=2, ensure_ascii=False), a.sample)
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def main_verify(argv: list[str]) -> int:
|
|
221
|
+
from .verify import verify_path
|
|
222
|
+
|
|
223
|
+
ap = argparse.ArgumentParser(
|
|
224
|
+
prog="docxtpl-lint verify",
|
|
225
|
+
description="Render each template with real docxtpl and sentinel data and report output that is silently "
|
|
226
|
+
"lost (DTL401) or a failing render (DTL402). Needs docxtpl.",
|
|
227
|
+
)
|
|
228
|
+
ap.add_argument("files", nargs="+")
|
|
229
|
+
ap.add_argument("--data", help="render with this JSON instead of inferred sentinel data (only DTL402)")
|
|
230
|
+
ap.add_argument("--format", choices=["text", "json"], default="text")
|
|
231
|
+
ap.add_argument("--lang", choices=["en", "zh"], default="en")
|
|
232
|
+
a = ap.parse_args(argv)
|
|
233
|
+
data = load_data(a.data) if a.data else None
|
|
234
|
+
try:
|
|
235
|
+
reports = [verify_path(f, data) for f in a.files]
|
|
236
|
+
except RuntimeError as exc:
|
|
237
|
+
print(f"docxtpl-lint: {exc}", file=sys.stderr)
|
|
238
|
+
return 2
|
|
239
|
+
print(render_json(reports, a.lang) if a.format == "json" else render_text(reports, False, False, a.lang, 1))
|
|
240
|
+
return 1 if any(r.issues for r in reports) else 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main_diff(argv: list[str]) -> int:
|
|
244
|
+
from .contract import diff, infer_tree
|
|
245
|
+
|
|
246
|
+
ap = argparse.ArgumentParser(
|
|
247
|
+
prog="docxtpl-lint diff",
|
|
248
|
+
description="Compare the data contracts of two template versions (breaking = data producers must change).",
|
|
249
|
+
)
|
|
250
|
+
ap.add_argument("old")
|
|
251
|
+
ap.add_argument("new")
|
|
252
|
+
ap.add_argument("--format", choices=["text", "json"], default="text")
|
|
253
|
+
ap.add_argument("--fail-on-breaking", action="store_true", help="exit 1 if there is a breaking change")
|
|
254
|
+
a = ap.parse_args(argv)
|
|
255
|
+
changes = diff(infer_tree([a.old]), infer_tree([a.new]))
|
|
256
|
+
if a.format == "json":
|
|
257
|
+
print(json.dumps([c.to_dict() for c in changes], indent=2, ensure_ascii=False))
|
|
258
|
+
elif not changes:
|
|
259
|
+
print("data contract unchanged")
|
|
260
|
+
else:
|
|
261
|
+
for c in changes:
|
|
262
|
+
mark = "BREAKING" if c.breaking else "ok "
|
|
263
|
+
sign = {"added": "+", "removed": "-", "type": "~", "optional": "~"}[c.kind]
|
|
264
|
+
detail = f" ({c.detail})" if c.detail else ""
|
|
265
|
+
print(f"{mark} {sign} {c.path.replace('[]', '[*]')}{detail}")
|
|
266
|
+
nb = sum(c.breaking for c in changes)
|
|
267
|
+
print(f"\n{len(changes)} change(s), {nb} breaking")
|
|
268
|
+
return 1 if a.fail_on_breaking and any(c.breaking for c in changes) else 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def main_fix(argv: list[str]) -> int:
|
|
272
|
+
from .fixes import suggest
|
|
273
|
+
|
|
274
|
+
ap = argparse.ArgumentParser(
|
|
275
|
+
prog="docxtpl-lint fix",
|
|
276
|
+
description="Print fix suggestions as unified diffs of the paragraph text. The .docx is never modified.",
|
|
277
|
+
)
|
|
278
|
+
ap.add_argument("files", nargs="+")
|
|
279
|
+
ap.add_argument("--data", action="append", help="sample data, enables `did you mean` for DTL301")
|
|
280
|
+
ap.add_argument("--format", choices=["diff", "json"], default="diff")
|
|
281
|
+
ap.add_argument("--lang", choices=["en", "zh"], default="en")
|
|
282
|
+
a = ap.parse_args(argv)
|
|
283
|
+
contexts = [load_data(d) for d in a.data or []]
|
|
284
|
+
allfx = []
|
|
285
|
+
for f in a.files:
|
|
286
|
+
allfx += suggest(lint_path(f, contexts=contexts), contexts)
|
|
287
|
+
if a.format == "json":
|
|
288
|
+
print(json.dumps([x.to_dict() for x in allfx], indent=2, ensure_ascii=False))
|
|
289
|
+
elif allfx:
|
|
290
|
+
print("\n\n".join(x.diff(a.lang) for x in allfx))
|
|
291
|
+
else:
|
|
292
|
+
print("no fix suggestions")
|
|
293
|
+
return 0
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def main(argv: list[str] | None = None) -> int:
|
|
297
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
298
|
+
if argv and argv[0] in SUBCOMMANDS:
|
|
299
|
+
_reconf()
|
|
300
|
+
sub = {"infer": main_infer, "verify": main_verify, "diff": main_diff, "fix": main_fix}[argv[0]]
|
|
301
|
+
try:
|
|
302
|
+
return sub(argv[1:])
|
|
303
|
+
except (OSError, zipfile.BadZipFile, KeyError, ValueError) as exc:
|
|
304
|
+
# documented exit code 2 = unreadable input, never a traceback
|
|
305
|
+
print(f"docxtpl-lint {argv[0]}: cannot read input (is it a .docx / JSON file?): {exc}", file=sys.stderr)
|
|
306
|
+
return 2
|
|
307
|
+
ap = build_parser()
|
|
308
|
+
pre, _ = ap.parse_known_args(argv)
|
|
309
|
+
if not pre.no_config:
|
|
310
|
+
cfg_path = Path(pre.config) if pre.config else find_config()
|
|
311
|
+
if cfg_path is not None:
|
|
312
|
+
try:
|
|
313
|
+
cfg = load_config(cfg_path)
|
|
314
|
+
except ConfigError as exc:
|
|
315
|
+
print(f"docxtpl-lint: {exc}", file=sys.stderr)
|
|
316
|
+
return 2
|
|
317
|
+
cfg_data = cfg.pop("data", None)
|
|
318
|
+
ap.set_defaults(**cfg)
|
|
319
|
+
args = ap.parse_args(argv)
|
|
320
|
+
if args.data is None and cfg_data:
|
|
321
|
+
args.data = cfg_data
|
|
322
|
+
else:
|
|
323
|
+
args = ap.parse_args(argv)
|
|
324
|
+
else:
|
|
325
|
+
args = ap.parse_args(argv)
|
|
326
|
+
for stream in (sys.stdout, sys.stderr):
|
|
327
|
+
# e.g. Windows cp1252 consoles cannot print ✔ / ¶ / Chinese messages
|
|
328
|
+
if hasattr(stream, "reconfigure"):
|
|
329
|
+
stream.reconfigure(errors="replace")
|
|
330
|
+
|
|
331
|
+
if args.explain:
|
|
332
|
+
r = RULES.get(args.explain.upper())
|
|
333
|
+
if r is None:
|
|
334
|
+
print(f"docxtpl-lint: unknown rule {args.explain}", file=sys.stderr)
|
|
335
|
+
return 2
|
|
336
|
+
msg = r.zh if args.lang == "zh" else r.en
|
|
337
|
+
print(f"{r.id} ({r.severity}): {r.summary}\n\n message: {msg}\n\n details, examples and docxtpl behaviour: "
|
|
338
|
+
f"{help_url(r.id)}") # fmt: skip
|
|
339
|
+
return 0
|
|
340
|
+
if args.list_rules:
|
|
341
|
+
for r in RULES.values():
|
|
342
|
+
print(f"{r.id} {r.severity:<7} {r.summary}")
|
|
343
|
+
return 0
|
|
344
|
+
if not args.files:
|
|
345
|
+
ap.print_usage(sys.stderr)
|
|
346
|
+
print("docxtpl-lint: error: no files given", file=sys.stderr)
|
|
347
|
+
return 2
|
|
348
|
+
|
|
349
|
+
contexts = []
|
|
350
|
+
for d in args.data or []:
|
|
351
|
+
try:
|
|
352
|
+
contexts.append(load_data(d))
|
|
353
|
+
except (OSError, ValueError) as exc:
|
|
354
|
+
print(f"docxtpl-lint: cannot read --data: {exc}", file=sys.stderr)
|
|
355
|
+
return 2
|
|
356
|
+
|
|
357
|
+
schema = None
|
|
358
|
+
if args.schema:
|
|
359
|
+
from .contract import load_schema
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
schema = load_schema(args.schema)
|
|
363
|
+
except (OSError, ValueError) as exc:
|
|
364
|
+
print(f"docxtpl-lint: cannot read --schema: {exc}", file=sys.stderr)
|
|
365
|
+
return 2
|
|
366
|
+
|
|
367
|
+
sel = [s.strip() for s in args.select.split(",")] if args.select else None
|
|
368
|
+
ign = [s.strip() for s in args.ignore.split(",")] if args.ignore else []
|
|
369
|
+
|
|
370
|
+
reports: list[Report] = []
|
|
371
|
+
for f in args.files:
|
|
372
|
+
if not Path(f).is_file():
|
|
373
|
+
print(f"docxtpl-lint: file not found: {f}", file=sys.stderr)
|
|
374
|
+
return 2
|
|
375
|
+
try:
|
|
376
|
+
rep = lint_path(
|
|
377
|
+
f,
|
|
378
|
+
contexts=contexts,
|
|
379
|
+
allow_cross_paragraph=args.allow_cross_paragraph,
|
|
380
|
+
report_unused_data=args.report_unused_data,
|
|
381
|
+
schema=schema,
|
|
382
|
+
)
|
|
383
|
+
except Exception as exc:
|
|
384
|
+
print(f"docxtpl-lint: cannot read {f} (is it a .docx?): {exc}", file=sys.stderr)
|
|
385
|
+
return 2
|
|
386
|
+
rep.issues = [
|
|
387
|
+
i
|
|
388
|
+
for i in rep.issues
|
|
389
|
+
if (sel is None or any(i.rule.startswith(s) for s in sel)) and not any(i.rule.startswith(s) for s in ign)
|
|
390
|
+
]
|
|
391
|
+
reports.append(rep)
|
|
392
|
+
|
|
393
|
+
if args.format == "json":
|
|
394
|
+
print(render_json(reports, args.lang))
|
|
395
|
+
elif args.format == "github":
|
|
396
|
+
out = render_github(reports, args.lang)
|
|
397
|
+
if out:
|
|
398
|
+
print(out)
|
|
399
|
+
elif args.format == "sarif":
|
|
400
|
+
print(render_sarif(reports, args.lang))
|
|
401
|
+
else:
|
|
402
|
+
color = not args.no_color and sys.stdout.isatty()
|
|
403
|
+
context = args.context if args.context is not None else (1 if sys.stdout.isatty() else 0)
|
|
404
|
+
print(render_text(reports, color, args.list_vars, args.lang, context))
|
|
405
|
+
|
|
406
|
+
bad = {"error", "warning"} if args.warnings_as_errors else {"error"}
|
|
407
|
+
return 1 if any(i.severity in bad for r in reports for i in r.issues) else 0
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if __name__ == "__main__":
|
|
411
|
+
raise SystemExit(main())
|
docxtpl_lint/config.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""``[tool.docxtpl-lint]`` in pyproject.toml.
|
|
2
|
+
|
|
3
|
+
The nearest ``pyproject.toml`` (current directory upwards) that has a
|
|
4
|
+
``[tool.docxtpl-lint]`` table is used. Command line options override it.
|
|
5
|
+
Unknown keys are an error, so a typo never silently disables a check.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
[tool.docxtpl-lint]
|
|
10
|
+
ignore = ["DTL002"]
|
|
11
|
+
data = ["tests/sample_context.json"] # relative to pyproject.toml
|
|
12
|
+
warnings-as-errors = true
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
if sys.version_info >= (3, 11):
|
|
22
|
+
import tomllib
|
|
23
|
+
else: # pragma: no cover
|
|
24
|
+
import tomli as tomllib
|
|
25
|
+
|
|
26
|
+
KEYS = {
|
|
27
|
+
"select": (list, str),
|
|
28
|
+
"ignore": (list, str),
|
|
29
|
+
"data": (list, str),
|
|
30
|
+
"allow-cross-paragraph": (bool,),
|
|
31
|
+
"report-unused-data": (bool,),
|
|
32
|
+
"warnings-as-errors": (bool,),
|
|
33
|
+
"lang": (str,),
|
|
34
|
+
"context": (int,),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConfigError(ValueError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_config(start: Path | None = None) -> Path | None:
|
|
43
|
+
here = (start or Path.cwd()).resolve()
|
|
44
|
+
for d in (here, *here.parents):
|
|
45
|
+
f = d / "pyproject.toml"
|
|
46
|
+
if f.is_file():
|
|
47
|
+
try:
|
|
48
|
+
with f.open("rb") as fh:
|
|
49
|
+
if "docxtpl-lint" in tomllib.load(fh).get("tool", {}):
|
|
50
|
+
return f
|
|
51
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
52
|
+
continue
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_config(path: Path) -> dict[str, Any]:
|
|
57
|
+
"""Return options with argparse-style names (``allow_cross_paragraph``)."""
|
|
58
|
+
try:
|
|
59
|
+
with path.open("rb") as fh:
|
|
60
|
+
raw = tomllib.load(fh).get("tool", {}).get("docxtpl-lint", {})
|
|
61
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
62
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
|
63
|
+
out: dict[str, Any] = {}
|
|
64
|
+
for key, value in raw.items():
|
|
65
|
+
if key not in KEYS:
|
|
66
|
+
raise ConfigError(f"{path}: unknown key [tool.docxtpl-lint] {key!r}; allowed: {', '.join(KEYS)}")
|
|
67
|
+
if (isinstance(value, bool) and bool not in KEYS[key]) or not isinstance(value, KEYS[key]):
|
|
68
|
+
raise ConfigError(f"{path}: [tool.docxtpl-lint] {key} has the wrong type")
|
|
69
|
+
if key in ("select", "ignore") and isinstance(value, list):
|
|
70
|
+
value = ",".join(value)
|
|
71
|
+
if key == "data":
|
|
72
|
+
items = [value] if isinstance(value, str) else value
|
|
73
|
+
value = [str((path.parent / v).resolve()) for v in items]
|
|
74
|
+
if key == "lang" and value not in ("en", "zh"):
|
|
75
|
+
raise ConfigError(f"{path}: [tool.docxtpl-lint] lang must be 'en' or 'zh'")
|
|
76
|
+
out[key.replace("-", "_")] = value
|
|
77
|
+
return out
|