docx-cli 0.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.
- docx_cli/__init__.py +8 -0
- docx_cli/__main__.py +7 -0
- docx_cli/_templates/hodatajstvo.json +60 -0
- docx_cli/_templates/iskovoe_zayavlenie.json +75 -0
- docx_cli/_templates/otzyv_na_isk.json +69 -0
- docx_cli/_templates/soyu_rayon.json +61 -0
- docx_cli/_templates/vs_sud.json +65 -0
- docx_cli/agent_reader.py +87 -0
- docx_cli/blocks.py +333 -0
- docx_cli/builder.py +116 -0
- docx_cli/cli.py +641 -0
- docx_cli/errors.py +21 -0
- docx_cli/fonts_normalize.py +119 -0
- docx_cli/inject_scan.py +369 -0
- docx_cli/mcp_server.py +380 -0
- docx_cli/md_court.py +665 -0
- docx_cli/profiles.py +272 -0
- docx_cli/spec.py +233 -0
- docx_cli/validate.py +786 -0
- docx_cli-0.1.0.dist-info/METADATA +95 -0
- docx_cli-0.1.0.dist-info/RECORD +25 -0
- docx_cli-0.1.0.dist-info/WHEEL +4 -0
- docx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- docx_cli-0.1.0.dist-info/licenses/LICENSE +199 -0
- docx_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
docx_cli/cli.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""CLI entry point for docx-cli."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from docx_cli import validate
|
|
13
|
+
from docx_cli.builder import build_document
|
|
14
|
+
from docx_cli.errors import DocxCreatorError
|
|
15
|
+
from docx_cli.spec import DocSpec, now_iso
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _envelope(
|
|
19
|
+
ok: bool,
|
|
20
|
+
status: str,
|
|
21
|
+
reason: str | None,
|
|
22
|
+
data: dict[str, Any] | None = None,
|
|
23
|
+
metadata: dict[str, Any] | None = None,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
return {
|
|
26
|
+
"ok": ok,
|
|
27
|
+
"status": status,
|
|
28
|
+
"reason": reason,
|
|
29
|
+
"parsed_at": now_iso(),
|
|
30
|
+
"data": data or {},
|
|
31
|
+
"metadata": metadata or {},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_spec(path: str) -> DocSpec:
|
|
36
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
37
|
+
raw = json.load(f)
|
|
38
|
+
return DocSpec.from_dict(raw)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_build(args: argparse.Namespace) -> int:
|
|
42
|
+
try:
|
|
43
|
+
spec = _load_spec(args.spec)
|
|
44
|
+
if args.profile:
|
|
45
|
+
spec.profile = args.profile
|
|
46
|
+
except DocxCreatorError as exc:
|
|
47
|
+
env = _envelope(False, "error", f"spec_error: {exc}")
|
|
48
|
+
print(json.dumps(env, ensure_ascii=False))
|
|
49
|
+
return 1
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
env = _envelope(False, "error", f"spec_load_failed: {exc}")
|
|
52
|
+
print(json.dumps(env, ensure_ascii=False))
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
result = build_document(spec, args.output)
|
|
57
|
+
except DocxCreatorError as exc:
|
|
58
|
+
env = _envelope(False, "error", str(exc))
|
|
59
|
+
print(json.dumps(env, ensure_ascii=False))
|
|
60
|
+
return 1
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
env = _envelope(False, "error", f"build_failed: {exc}")
|
|
63
|
+
print(json.dumps(env, ensure_ascii=False))
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
67
|
+
if result["status"] == "ok":
|
|
68
|
+
return 0
|
|
69
|
+
if result["status"] == "degraded":
|
|
70
|
+
return 2
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_profile_extract(args: argparse.Namespace) -> int:
|
|
75
|
+
import json as _json
|
|
76
|
+
from .profiles import ProfileError, extract
|
|
77
|
+
try:
|
|
78
|
+
prof = extract(args.sample)
|
|
79
|
+
except (ProfileError, Exception) as exc: # noqa: BLE001
|
|
80
|
+
print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)}, ensure_ascii=False)); return 1
|
|
81
|
+
try:
|
|
82
|
+
import pathlib
|
|
83
|
+
pathlib.Path(args.out_json).write_text(
|
|
84
|
+
_json.dumps(prof, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
85
|
+
except Exception as exc: # noqa: BLE001
|
|
86
|
+
print(_json.dumps({"ok": False, "status": "io_error", "reason": str(exc)}, ensure_ascii=False)); return 1
|
|
87
|
+
print(_json.dumps({"ok": True, "status": "ok", "profile": args.out_json}, ensure_ascii=False))
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_profile_validate(args: argparse.Namespace) -> int:
|
|
92
|
+
import json as _json
|
|
93
|
+
from .profiles import ProfileError, load_profile, validate
|
|
94
|
+
try:
|
|
95
|
+
prof = load_profile(args.profile_json)
|
|
96
|
+
problems = validate(args.file, prof)
|
|
97
|
+
except (ProfileError, Exception) as exc: # noqa: BLE001
|
|
98
|
+
print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)}, ensure_ascii=False)); return 1
|
|
99
|
+
print(_json.dumps({"ok": len(problems) == 0, "status": "ok" if not problems else "problems",
|
|
100
|
+
"count": len(problems), "problems": problems}, ensure_ascii=False))
|
|
101
|
+
return 0 if not problems else 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_profile_repair(args: argparse.Namespace) -> int:
|
|
105
|
+
import json as _json
|
|
106
|
+
from .profiles import ProfileError, load_profile, repair
|
|
107
|
+
try:
|
|
108
|
+
prof = load_profile(args.profile_json)
|
|
109
|
+
res = repair(args.file, prof, args.out)
|
|
110
|
+
except Exception as exc: # noqa: BLE001
|
|
111
|
+
print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)}, ensure_ascii=False)); return 1
|
|
112
|
+
res.update({"ok": True, "status": "ok"})
|
|
113
|
+
print(_json.dumps(res, ensure_ascii=False))
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_agent_read(args: argparse.Namespace) -> int:
|
|
118
|
+
import json as _json
|
|
119
|
+
from .agent_reader import AgentReadError, read
|
|
120
|
+
try:
|
|
121
|
+
res = read(args.file, args.start, args.max_chars)
|
|
122
|
+
except AgentReadError as exc:
|
|
123
|
+
msg = str(exc)
|
|
124
|
+
status = "invalid_usage" if "invalid_usage" in msg else "error"
|
|
125
|
+
print(_json.dumps({"ok": False, "status": status, "reason": msg}, ensure_ascii=False))
|
|
126
|
+
return 2 if status == "invalid_usage" else 1
|
|
127
|
+
res.update({"ok": True, "status": "ok"})
|
|
128
|
+
print(_json.dumps(res, ensure_ascii=False))
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cmd_verify_forbidden(args: argparse.Namespace) -> int:
|
|
133
|
+
"""desync-guard юриста: запрещённые строки не должны попасть в итоговый docx/md."""
|
|
134
|
+
import json as _json
|
|
135
|
+
import zipfile as _zf
|
|
136
|
+
from pathlib import Path as _P
|
|
137
|
+
from xml.etree import ElementTree as _ET
|
|
138
|
+
from docx import Document as _D
|
|
139
|
+
|
|
140
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
141
|
+
|
|
142
|
+
# v0.5.1: --forbid теперь nargs='+' (группы значений); флэттим, сохраняя
|
|
143
|
+
# совместимость со старые вызовами (список строк).
|
|
144
|
+
patterns: list[str] = []
|
|
145
|
+
for group in args.forbid or []:
|
|
146
|
+
if isinstance(group, list):
|
|
147
|
+
patterns.extend(group)
|
|
148
|
+
else:
|
|
149
|
+
patterns.append(group)
|
|
150
|
+
if args.forbid_file:
|
|
151
|
+
try:
|
|
152
|
+
for line in _P(args.forbid_file).read_text(encoding="utf-8-sig").splitlines():
|
|
153
|
+
line = line.strip()
|
|
154
|
+
if line and not line.startswith("#"):
|
|
155
|
+
patterns.append(line)
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
print(_json.dumps({"ok": False, "status": "io_error", "reason": "io_error",
|
|
158
|
+
"error": "io_error", "findings": [{"type": "io_error", "path": str(args.forbid_file), "reason": str(exc)}],
|
|
159
|
+
"count": 0, "hits": []}, ensure_ascii=False))
|
|
160
|
+
return 1
|
|
161
|
+
if not patterns:
|
|
162
|
+
print(_json.dumps({"ok": False, "status": "invalid_usage", "reason": "no --forbid patterns"}, ensure_ascii=False))
|
|
163
|
+
return 2
|
|
164
|
+
|
|
165
|
+
# collect texts (same parts as scan: doc + headers/footers + footnotes/endnotes + tables)
|
|
166
|
+
if not _P(args.docx).is_file():
|
|
167
|
+
_emit_vf_error("io_error", str(args.docx), "file not found")
|
|
168
|
+
return 1
|
|
169
|
+
try:
|
|
170
|
+
d = _D(str(args.docx))
|
|
171
|
+
except Exception as exc: # noqa: BLE001
|
|
172
|
+
msg = str(exc).lower()
|
|
173
|
+
if "zip" in msg or "not a zip" in msg or "no such file" in msg or "file not found" in msg:
|
|
174
|
+
_emit_vf_error("io_error", str(args.docx), str(exc))
|
|
175
|
+
return 1
|
|
176
|
+
_emit_vf_error("xml_parse_error", str(args.docx), str(exc))
|
|
177
|
+
return 1
|
|
178
|
+
|
|
179
|
+
texts = []
|
|
180
|
+
try:
|
|
181
|
+
texts += [p.text for p in d.paragraphs if p.text.strip()]
|
|
182
|
+
for t in d.tables:
|
|
183
|
+
for row in t.rows:
|
|
184
|
+
for cell in row.cells:
|
|
185
|
+
if cell.text.strip():
|
|
186
|
+
texts.append(cell.text)
|
|
187
|
+
for section in d.sections:
|
|
188
|
+
for part in (section.header, section.footer,
|
|
189
|
+
getattr(section, "first_page_header", None),
|
|
190
|
+
getattr(section, "first_page_footer", None),
|
|
191
|
+
getattr(section, "even_page_header", None),
|
|
192
|
+
getattr(section, "even_page_footer", None)):
|
|
193
|
+
if part is None:
|
|
194
|
+
continue
|
|
195
|
+
for p in part.paragraphs:
|
|
196
|
+
if p.text.strip():
|
|
197
|
+
texts.append(p.text)
|
|
198
|
+
for t in part.tables:
|
|
199
|
+
for row in t.rows:
|
|
200
|
+
for cell in row.cells:
|
|
201
|
+
if cell.text.strip():
|
|
202
|
+
texts.append(cell.text)
|
|
203
|
+
# footnotes / endnotes from zip
|
|
204
|
+
with _zf.ZipFile(str(args.docx)) as zf:
|
|
205
|
+
for partname in ("word/footnotes.xml", "word/endnotes.xml"):
|
|
206
|
+
if partname not in zf.namelist():
|
|
207
|
+
continue
|
|
208
|
+
try:
|
|
209
|
+
root = _ET.fromstring(zf.read(partname))
|
|
210
|
+
except _ET.ParseError as exc:
|
|
211
|
+
_emit_vf_error("xml_parse_error", partname, str(exc))
|
|
212
|
+
return 1
|
|
213
|
+
for p in root.iter(f"{{{W}}}p"):
|
|
214
|
+
txt = "".join(t.text or "" for t in p.iter(f"{{{W}}}t"))
|
|
215
|
+
if txt.strip():
|
|
216
|
+
texts.append(txt)
|
|
217
|
+
except FileNotFoundError as exc:
|
|
218
|
+
_emit_vf_error("io_error", str(args.docx), str(exc))
|
|
219
|
+
return 1
|
|
220
|
+
except _zf.BadZipFile as exc:
|
|
221
|
+
_emit_vf_error("io_error", str(args.docx), str(exc))
|
|
222
|
+
return 1
|
|
223
|
+
except OSError as exc:
|
|
224
|
+
_emit_vf_error("io_error", str(args.docx), str(exc))
|
|
225
|
+
return 1
|
|
226
|
+
except _ET.ParseError as exc:
|
|
227
|
+
_emit_vf_error("xml_parse_error", str(args.docx), str(exc))
|
|
228
|
+
return 1
|
|
229
|
+
|
|
230
|
+
if args.md and _P(args.md).is_file():
|
|
231
|
+
try:
|
|
232
|
+
texts += [l for l in _P(args.md).read_text(encoding="utf-8-sig").splitlines() if l.strip()]
|
|
233
|
+
except OSError as exc:
|
|
234
|
+
_emit_vf_error("io_error", str(args.md), str(exc))
|
|
235
|
+
return 1
|
|
236
|
+
elif args.md and _P(args.md).exists() is False and args.md:
|
|
237
|
+
# md optional; ignore missing
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
hits = []
|
|
241
|
+
for pat in patterns:
|
|
242
|
+
for txt in texts:
|
|
243
|
+
if pat in txt:
|
|
244
|
+
hits.append({"pattern": pat, "found_in": txt[:80]})
|
|
245
|
+
print(_json.dumps({"ok": not hits, "status": "ok" if not hits else "forbidden_found",
|
|
246
|
+
"count": len(hits), "hits": hits[:20]}, ensure_ascii=False))
|
|
247
|
+
return 0 if not hits else 1
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _emit_vf_error(kind: str, path: str, reason: str) -> None:
|
|
251
|
+
import json as _json
|
|
252
|
+
print(_json.dumps({"ok": False, "status": kind, "reason": kind,
|
|
253
|
+
"error": kind, "findings": [{"type": kind, "path": path, "reason": reason}],
|
|
254
|
+
"count": 0, "hits": []}, ensure_ascii=False))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cmd_md_build(args: argparse.Namespace) -> int:
|
|
258
|
+
"""v0.4.0: Markdown -> процессуальный docx (собственные эвристики)."""
|
|
259
|
+
import json as _json
|
|
260
|
+
from .md_court import MdBuildError, md_build
|
|
261
|
+
try:
|
|
262
|
+
res = md_build(args.input_md, args.output, args.author)
|
|
263
|
+
except (MdBuildError, Exception) as exc: # noqa: BLE001
|
|
264
|
+
print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)},
|
|
265
|
+
ensure_ascii=False))
|
|
266
|
+
return 1
|
|
267
|
+
res.update({"ok": True, "status": "ok"})
|
|
268
|
+
print(_json.dumps(res, ensure_ascii=False))
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def cmd_normalize(args: argparse.Namespace) -> int:
|
|
273
|
+
"""v0.4.0: принудительный Times New Roman + интервалы (собственные эвристики)."""
|
|
274
|
+
import json as _json
|
|
275
|
+
from pathlib import Path
|
|
276
|
+
from .fonts_normalize import normalize_path
|
|
277
|
+
|
|
278
|
+
p = Path(args.path)
|
|
279
|
+
if p.is_dir() and not args.yes:
|
|
280
|
+
print(_json.dumps({"ok": False, "status": "invalid_usage",
|
|
281
|
+
"reason": "directory mode requires --yes (pass --yes to process all .docx)"},
|
|
282
|
+
ensure_ascii=False))
|
|
283
|
+
return 2
|
|
284
|
+
try:
|
|
285
|
+
res = normalize_path(args.path, body_size=args.body_size,
|
|
286
|
+
header_size=args.header_size, dry_run=args.dry_run,
|
|
287
|
+
backup=args.backup, yes=args.yes)
|
|
288
|
+
except Exception as exc: # noqa: BLE001 — fail-closed envelope
|
|
289
|
+
print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)},
|
|
290
|
+
ensure_ascii=False))
|
|
291
|
+
return 1
|
|
292
|
+
res.update({"ok": True, "status": "ok"})
|
|
293
|
+
print(_json.dumps(res, ensure_ascii=False))
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
298
|
+
"""v0.3.0: скрытые векторы атак в входящих docx (собственные эвристики).
|
|
299
|
+
clean -> ok/exit 0; находки или битый файл -> error/exit 1 (fail-closed)."""
|
|
300
|
+
import json as _json
|
|
301
|
+
import shlex
|
|
302
|
+
from pathlib import Path as _P
|
|
303
|
+
from .inject_scan import scan_all
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
res = scan_all(str(args.file))
|
|
307
|
+
except Exception as exc: # noqa: BLE001 — fail-closed envelope
|
|
308
|
+
print(_json.dumps({"ok": False, "status": "error", "reason": str(exc)},
|
|
309
|
+
ensure_ascii=False))
|
|
310
|
+
return 1
|
|
311
|
+
if getattr(args, "a2a_args", False):
|
|
312
|
+
count = len(res.get("findings", [])) if isinstance(res, dict) else 0
|
|
313
|
+
file_path = str(args.file)
|
|
314
|
+
scan_json_path = str(_P(file_path).with_suffix(".scan.json"))
|
|
315
|
+
verify_cmd = f"docx-cli scan {shlex.quote(file_path)}"
|
|
316
|
+
a2a_command = (
|
|
317
|
+
f'a2a log "scan findings: {file_path} ({count})" '
|
|
318
|
+
f'--status done --artifacts {shlex.quote(scan_json_path)} '
|
|
319
|
+
f'--verify {shlex.quote(verify_cmd)} --verify-run'
|
|
320
|
+
)
|
|
321
|
+
res["a2a_command"] = a2a_command
|
|
322
|
+
res.setdefault("data", {})["a2a_command"] = a2a_command
|
|
323
|
+
print(_json.dumps(res, ensure_ascii=False))
|
|
324
|
+
return 0 if res["clean"] else 1
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
328
|
+
path = args.file
|
|
329
|
+
if not os.path.exists(path):
|
|
330
|
+
env = _envelope(False, "io_error", f"io_error: file_not_found: {path}")
|
|
331
|
+
print(json.dumps(env, ensure_ascii=False))
|
|
332
|
+
return 1
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
validation = validate.validate_document(path, mode=args.mode)
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
env = _envelope(False, "error", f"validate_failed: {exc}")
|
|
338
|
+
print(json.dumps(env, ensure_ascii=False))
|
|
339
|
+
return 1
|
|
340
|
+
|
|
341
|
+
ok = not validation["errors"]
|
|
342
|
+
has_warnings = bool(validation["warnings"])
|
|
343
|
+
if ok and has_warnings:
|
|
344
|
+
status = "degraded"
|
|
345
|
+
reason = "warnings_present"
|
|
346
|
+
elif ok:
|
|
347
|
+
status = "ok"
|
|
348
|
+
reason = None
|
|
349
|
+
else:
|
|
350
|
+
status = "error"
|
|
351
|
+
reason = "validation_failed: " + "; ".join(validation["errors"])
|
|
352
|
+
|
|
353
|
+
env = _envelope(
|
|
354
|
+
ok,
|
|
355
|
+
status,
|
|
356
|
+
reason,
|
|
357
|
+
data={"file": path},
|
|
358
|
+
metadata=validation,
|
|
359
|
+
)
|
|
360
|
+
print(json.dumps(env, ensure_ascii=False))
|
|
361
|
+
|
|
362
|
+
if not ok:
|
|
363
|
+
return 1
|
|
364
|
+
if args.strict and has_warnings:
|
|
365
|
+
return 2
|
|
366
|
+
if args.strict and validation.get("skipped"):
|
|
367
|
+
return 2
|
|
368
|
+
return 0
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
372
|
+
import shutil as _shutil
|
|
373
|
+
import subprocess as _sp
|
|
374
|
+
|
|
375
|
+
checks: dict[str, Any] = {}
|
|
376
|
+
errors: list[str] = []
|
|
377
|
+
warnings: list[str] = []
|
|
378
|
+
|
|
379
|
+
checks["python_version"] = platform.python_version()
|
|
380
|
+
if sys.version_info < (3, 11):
|
|
381
|
+
errors.append("python_version: requires >=3.11")
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
import docx
|
|
385
|
+
checks["python_docx"] = docx.__version__
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
errors.append(f"python_docx_import_failed: {exc}")
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
import lxml
|
|
391
|
+
checks["lxml"] = lxml.__version__
|
|
392
|
+
except Exception as exc:
|
|
393
|
+
errors.append(f"lxml_import_failed: {exc}")
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
import pydantic
|
|
397
|
+
checks["pydantic"] = pydantic.VERSION
|
|
398
|
+
except Exception as exc:
|
|
399
|
+
errors.append(f"pydantic_import_failed: {exc}")
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
import docx_cli
|
|
403
|
+
checks["docx_cli"] = getattr(docx_cli, "__version__", "unknown")
|
|
404
|
+
except Exception as exc:
|
|
405
|
+
errors.append(f"docx_cli_import_failed: {exc}")
|
|
406
|
+
|
|
407
|
+
cwd = Path.cwd()
|
|
408
|
+
if os.access(cwd, os.W_OK):
|
|
409
|
+
checks["write_permissions"] = str(cwd)
|
|
410
|
+
else:
|
|
411
|
+
errors.append(f"write_permissions: no write access to {cwd}")
|
|
412
|
+
|
|
413
|
+
verify_sig = bool(getattr(args, "verify_signature", False))
|
|
414
|
+
dist = Path(getattr(args, "dist", "dist") or "dist")
|
|
415
|
+
signature_status = None
|
|
416
|
+
sig_reason = None
|
|
417
|
+
if verify_sig:
|
|
418
|
+
sums_path = dist / "SHA256SUMS"
|
|
419
|
+
sig_path = None
|
|
420
|
+
for cand in (dist / "SHA256SUMS.asc", dist / "SHA256SUMS.sig", dist / "SHA256SUMS.gpg"):
|
|
421
|
+
if cand.is_file():
|
|
422
|
+
sig_path = cand
|
|
423
|
+
break
|
|
424
|
+
if _shutil.which("gpg") is None:
|
|
425
|
+
signature_status = "unsigned"
|
|
426
|
+
sig_reason = "gpg binary not found"
|
|
427
|
+
elif not sums_path.is_file() or sig_path is None:
|
|
428
|
+
signature_status = "unsigned"
|
|
429
|
+
sig_reason = f"SHA256SUMS or paired .asc not found in {dist}"
|
|
430
|
+
else:
|
|
431
|
+
try:
|
|
432
|
+
cp = _sp.run(["gpg", "--verify", str(sig_path), str(sums_path)],
|
|
433
|
+
capture_output=True, text=True)
|
|
434
|
+
except Exception as exc: # noqa: BLE001
|
|
435
|
+
signature_status = "unsigned"
|
|
436
|
+
sig_reason = f"gpg invocation failed: {exc}"
|
|
437
|
+
else:
|
|
438
|
+
if cp.returncode == 0:
|
|
439
|
+
# подпись валидна — ОБЯЗАТЕЛЬНО сверяем хэши перечисленных файлов
|
|
440
|
+
# (иначе подмена dist-артефакта пройдёт как ok: fail-open)
|
|
441
|
+
import hashlib
|
|
442
|
+
mismatch = []
|
|
443
|
+
for line in sums_path.read_text().splitlines():
|
|
444
|
+
parts = line.split(None, 1)
|
|
445
|
+
if len(parts) != 2:
|
|
446
|
+
continue
|
|
447
|
+
want, name = parts[0].strip(), parts[1].strip().lstrip("*")
|
|
448
|
+
f = dist / name
|
|
449
|
+
if not f.exists():
|
|
450
|
+
mismatch.append(name)
|
|
451
|
+
elif hashlib.sha256(f.read_bytes()).hexdigest() != want:
|
|
452
|
+
mismatch.append(name)
|
|
453
|
+
if mismatch:
|
|
454
|
+
signature_status = "fail"
|
|
455
|
+
sig_reason = f"sha256 mismatch for: {', '.join(mismatch[:5])}"
|
|
456
|
+
else:
|
|
457
|
+
signature_status = "ok"
|
|
458
|
+
else:
|
|
459
|
+
signature_status = "fail"
|
|
460
|
+
sig_reason = (cp.stderr or cp.stdout or "gpg verify failed").strip()
|
|
461
|
+
|
|
462
|
+
checks["signature"] = signature_status
|
|
463
|
+
checks["verify_signature"] = signature_status
|
|
464
|
+
checks["signature_status"] = signature_status
|
|
465
|
+
|
|
466
|
+
ok = not errors
|
|
467
|
+
if verify_sig:
|
|
468
|
+
ok = ok and signature_status == "ok"
|
|
469
|
+
|
|
470
|
+
if errors:
|
|
471
|
+
status = "error"
|
|
472
|
+
elif verify_sig:
|
|
473
|
+
status = signature_status
|
|
474
|
+
elif warnings:
|
|
475
|
+
status = "degraded"
|
|
476
|
+
else:
|
|
477
|
+
status = "ok"
|
|
478
|
+
|
|
479
|
+
reasons = list(errors)
|
|
480
|
+
if verify_sig and sig_reason:
|
|
481
|
+
reasons.append(sig_reason)
|
|
482
|
+
reason = "; ".join(reasons) if reasons else None
|
|
483
|
+
|
|
484
|
+
data = {"checks": checks}
|
|
485
|
+
metadata = {"warnings": warnings, "errors": errors}
|
|
486
|
+
if verify_sig:
|
|
487
|
+
data["signature_status"] = signature_status
|
|
488
|
+
data["verify_signature"] = signature_status
|
|
489
|
+
data["signature"] = signature_status
|
|
490
|
+
data["dist"] = str(dist)
|
|
491
|
+
metadata["signature_status"] = signature_status
|
|
492
|
+
metadata["verify_signature"] = signature_status
|
|
493
|
+
metadata["signature"] = signature_status
|
|
494
|
+
|
|
495
|
+
env = _envelope(
|
|
496
|
+
ok,
|
|
497
|
+
status,
|
|
498
|
+
reason,
|
|
499
|
+
data=data,
|
|
500
|
+
metadata=metadata,
|
|
501
|
+
)
|
|
502
|
+
print(json.dumps(env, ensure_ascii=False))
|
|
503
|
+
return 0 if ok else 1
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
507
|
+
"""v0.1.0 A-05: docx-cli mcp — MCP stdio-сервер; --tools — tools/list dump."""
|
|
508
|
+
from . import mcp_server
|
|
509
|
+
if getattr(args, "tools", False):
|
|
510
|
+
return mcp_server.run_tools_dump()
|
|
511
|
+
return mcp_server.run_stdio_server()
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
515
|
+
parser = argparse.ArgumentParser(
|
|
516
|
+
prog="docx-cli",
|
|
517
|
+
description="Генератор юридических документов .docx по машинному контракту.",
|
|
518
|
+
)
|
|
519
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
520
|
+
|
|
521
|
+
build_p = sub.add_parser("build", help="Собрать .docx из spec.json")
|
|
522
|
+
build_p.add_argument("spec", help="Путь к spec.json")
|
|
523
|
+
build_p.add_argument("output", help="Путь к output.docx")
|
|
524
|
+
build_p.add_argument("--profile",
|
|
525
|
+
choices=["gost", "compact", "court", "vs_sud", "soyu_rayon"],
|
|
526
|
+
help="Переопределить profile в spec")
|
|
527
|
+
build_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
528
|
+
build_p.set_defaults(func=cmd_build)
|
|
529
|
+
|
|
530
|
+
val_p = sub.add_parser("validate", help="Проверить существующий .docx")
|
|
531
|
+
val_p.add_argument("file", help="Путь к .docx")
|
|
532
|
+
val_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
533
|
+
val_p.add_argument("--strict", action="store_true", help="Предупреждения -> exit 2")
|
|
534
|
+
# v0.5.3 (фикс 5; согласовано с внешним рецензентом):
|
|
535
|
+
# gost — строгая геометрия для собственных документов (по умолчанию,
|
|
536
|
+
# прежнее поведение); review — входящие акты судов/оппонентов: геометрия
|
|
537
|
+
# (поля 1.0–3.0 см, шрифт 10–14 pt, межстрочник 1.0–1.5, красная строка
|
|
538
|
+
# 0.5–1.5 см либо отсутствие при явном межпараграфном интервале) — только
|
|
539
|
+
# warnings; errors строго контентные.
|
|
540
|
+
val_p.add_argument("--mode", choices=["gost", "review"], default="gost",
|
|
541
|
+
help="gost (default) — строгая геометрия; review — "
|
|
542
|
+
"входящие документы: геометрия только warnings")
|
|
543
|
+
val_p.set_defaults(func=cmd_validate)
|
|
544
|
+
|
|
545
|
+
doc_p = sub.add_parser("doctor", help="Проверить окружение")
|
|
546
|
+
doc_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
547
|
+
doc_p.add_argument("--verify-signature", dest="verify_signature", action="store_true",
|
|
548
|
+
help="Проверить подпись SHA256SUMS в dist (--dist DIR)")
|
|
549
|
+
doc_p.add_argument("--dist", default="dist", help="Каталог с SHA256SUMS и .asc (по умолчанию dist/)")
|
|
550
|
+
doc_p.set_defaults(func=cmd_doctor)
|
|
551
|
+
|
|
552
|
+
scan_p = sub.add_parser(
|
|
553
|
+
"scan", help="Аудит входящего .docx на скрытые LLM-атаки "
|
|
554
|
+
"(vanish/микрошрифт/белый текст/tracked changes/comments). "
|
|
555
|
+
"Прогонять ДО передачи файла третьих лиц агенту.")
|
|
556
|
+
scan_p.add_argument("file", help="Путь к .docx")
|
|
557
|
+
scan_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
558
|
+
scan_p.add_argument("--a2a-args", dest="a2a_args", action="store_true",
|
|
559
|
+
help="Добавить a2a_command (bus-free) в конверт")
|
|
560
|
+
scan_p.set_defaults(func=cmd_scan)
|
|
561
|
+
|
|
562
|
+
mdb_p = sub.add_parser(
|
|
563
|
+
"md-build", help="Собрать процессуальный .docx из Markdown "
|
|
564
|
+
"(шапка-реквизиты, титул ИСК/ОТЗЫВ/ХОДАТАЙСТВО, **жирный**, списки).")
|
|
565
|
+
mdb_p.add_argument("input_md", help="Путь к исходному .md")
|
|
566
|
+
mdb_p.add_argument("output", help="Путь к output.docx")
|
|
567
|
+
mdb_p.add_argument("--author", default="docx-cli")
|
|
568
|
+
mdb_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
569
|
+
mdb_p.set_defaults(func=cmd_md_build)
|
|
570
|
+
|
|
571
|
+
norm_p = sub.add_parser(
|
|
572
|
+
"normalize", help="Нормализация шрифтов/интервалов .docx к Times New Roman "
|
|
573
|
+
"(вычищает Calibri/Cambria/Arial). Каталог или файл.")
|
|
574
|
+
norm_p.add_argument("path", help="Файл или каталог .docx")
|
|
575
|
+
norm_p.add_argument("--body-size", type=int, default=12)
|
|
576
|
+
norm_p.add_argument("--header-size", type=int, default=11)
|
|
577
|
+
norm_p.add_argument("--dry-run", action="store_true",
|
|
578
|
+
help="Показать, что будет изменено, без записи файлов")
|
|
579
|
+
norm_p.add_argument("--backup", dest="backup", action="store_true", default=True,
|
|
580
|
+
help="Создать .bak рядом при записи (по умолчанию)")
|
|
581
|
+
norm_p.add_argument("--no-backup", dest="backup", action="store_false",
|
|
582
|
+
help="Не создавать .bak")
|
|
583
|
+
norm_p.add_argument("--yes", action="store_true",
|
|
584
|
+
help="Подтвердить обработку каталога")
|
|
585
|
+
norm_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
586
|
+
norm_p.set_defaults(func=cmd_normalize)
|
|
587
|
+
|
|
588
|
+
pe_p = sub.add_parser("profile-extract", help="Снять профиль оформления с образца .docx")
|
|
589
|
+
pe_p.add_argument("sample")
|
|
590
|
+
pe_p.add_argument("out_json")
|
|
591
|
+
pe_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
592
|
+
pe_p.set_defaults(func=cmd_profile_extract)
|
|
593
|
+
|
|
594
|
+
pv_p = sub.add_parser("profile-validate", help="Проверить .docx по профилю оформления")
|
|
595
|
+
pv_p.add_argument("file")
|
|
596
|
+
pv_p.add_argument("profile_json")
|
|
597
|
+
pv_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
598
|
+
pv_p.set_defaults(func=cmd_profile_validate)
|
|
599
|
+
|
|
600
|
+
pr_p = sub.add_parser("profile-repair", help="Починить .docx в профиль -> новый файл (before/after)")
|
|
601
|
+
pr_p.add_argument("file")
|
|
602
|
+
pr_p.add_argument("profile_json")
|
|
603
|
+
pr_p.add_argument("out")
|
|
604
|
+
pr_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
605
|
+
pr_p.set_defaults(func=cmd_profile_repair)
|
|
606
|
+
|
|
607
|
+
ar_p = sub.add_parser("agent-read", help="Ограниченное JSON-чтение docx (окно + курсор)")
|
|
608
|
+
ar_p.add_argument("file")
|
|
609
|
+
ar_p.add_argument("--start", type=int, default=0)
|
|
610
|
+
ar_p.add_argument("--max", "--max-chars", dest="max_chars", type=int, default=4000)
|
|
611
|
+
ar_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
612
|
+
ar_p.set_defaults(func=cmd_agent_read)
|
|
613
|
+
|
|
614
|
+
vf_p = sub.add_parser("verify-forbidden", help="Проверить docx (+md) на запрещённые строки (desync-guard)")
|
|
615
|
+
vf_p.add_argument("docx")
|
|
616
|
+
vf_p.add_argument("md", nargs="?")
|
|
617
|
+
vf_p.add_argument("--forbid", action="append", nargs="+", default=[],
|
|
618
|
+
metavar="СТРОКА") # v0.5.1: --forbid A B C за один раз
|
|
619
|
+
vf_p.add_argument("--forbid-file", dest="forbid_file")
|
|
620
|
+
vf_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
621
|
+
vf_p.set_defaults(func=cmd_verify_forbidden)
|
|
622
|
+
|
|
623
|
+
mcp_p = sub.add_parser(
|
|
624
|
+
"mcp", help="Запустить MCP stdio-сервер (JSON-RPC 2.0). "
|
|
625
|
+
"--tools — печатать tools/list (скриптуемый smoke) и выйти.")
|
|
626
|
+
mcp_p.add_argument("--tools", action="store_true",
|
|
627
|
+
help="напечатать tools/list JSON и выйти")
|
|
628
|
+
mcp_p.add_argument("--json", action="store_true", help="Вывести JSON (по умолчанию)")
|
|
629
|
+
mcp_p.set_defaults(func=cmd_mcp)
|
|
630
|
+
|
|
631
|
+
return parser
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def main(argv: list[str] | None = None) -> int:
|
|
635
|
+
parser = build_parser()
|
|
636
|
+
args = parser.parse_args(argv)
|
|
637
|
+
return args.func(args)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
if __name__ == "__main__":
|
|
641
|
+
sys.exit(main())
|
docx_cli/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Domain errors for docx-cli."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DocxCreatorError(Exception):
|
|
5
|
+
"""Base exception."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SpecError(DocxCreatorError):
|
|
9
|
+
"""Input spec violates the machine contract."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BuildError(DocxCreatorError):
|
|
13
|
+
"""Generation failed mid-way."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValidationError(DocxCreatorError):
|
|
17
|
+
"""Generated document does not conform to the profile/contract."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProfileError(DocxCreatorError):
|
|
21
|
+
"""Requested profile is unknown or misconfigured."""
|