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.
@@ -0,0 +1,6 @@
1
+ """docxtpl-lint: static linter for docxtpl (python-docx-template) Word templates."""
2
+
3
+ from .core import Issue, Location, lint_path
4
+
5
+ __all__ = ["Issue", "Location", "lint_path"]
6
+ __version__ = "1.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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