dsh-office-kit 0.2.0 → 0.3.0
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.
- package/CAPABILITY.md +147 -68
- package/README.md +3 -1
- package/assets/skills/excel-live-control/SKILL.md +234 -0
- package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
- package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
- package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
- package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
- package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
- package/assets/skills/excel-live-control/references/office-automation.md +207 -0
- package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
- package/assets/skills/excel-live-control/style_guidelines.md +87 -0
- package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
- package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
- package/assets/skills/office-documents/SKILL.md +90 -77
- package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
- package/assets/skills/office-documents/ooxml/comments.md +40 -0
- package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
- package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
- package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
- package/assets/skills/office-documents/references/docx-structure.md +83 -0
- package/assets/skills/office-documents/references/template-elicitation.md +49 -0
- package/assets/skills/office-documents/references/writing-quality.md +49 -0
- package/assets/skills/office-documents/render_docx.py +92 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
- package/assets/skills/office-documents/scripts/comments_add.py +136 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
- package/assets/skills/office-documents/scripts/content_controls.py +192 -0
- package/assets/skills/office-documents/scripts/core_properties.py +124 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
- package/assets/skills/office-documents/scripts/fields_report.py +59 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
- package/assets/skills/office-documents/scripts/images_audit.py +80 -0
- package/assets/skills/office-documents/scripts/insert_note.py +126 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
- package/assets/skills/office-documents/scripts/replace_text.py +154 -0
- package/assets/skills/office-documents/scripts/section_audit.py +91 -0
- package/assets/skills/office-documents/scripts/set_protection.py +92 -0
- package/assets/skills/office-documents/scripts/style_lint.py +111 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
- package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
- package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
- package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
- package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
- package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
- package/assets/skills/office-documents/tasks/create_edit.md +107 -0
- package/assets/skills/office-documents/tasks/equations.md +59 -0
- package/assets/skills/office-documents/tasks/fields_update.md +44 -0
- package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
- package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
- package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
- package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
- package/assets/skills/office-documents/tasks/images_figures.md +63 -0
- package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
- package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
- package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
- package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
- package/assets/skills/office-documents/tasks/read_review.md +50 -0
- package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
- package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
- package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
- package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
- package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
- package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
- package/assets/skills/office-documents/tasks/verify_render.md +50 -0
- package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
- package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
- package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
- package/assets/skills/office-pdf/SKILL.md +73 -56
- package/assets/skills/office-pdf/references/form-fields.md +56 -0
- package/assets/skills/office-pdf/references/geometry.md +44 -0
- package/assets/skills/office-pdf/references/layout-quality.md +41 -0
- package/assets/skills/office-pdf/references/limits.md +58 -0
- package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
- package/assets/skills/office-pdf/scripts/forms.py +556 -0
- package/assets/skills/office-pdf/scripts/metadata.py +161 -0
- package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
- package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
- package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
- package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
- package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
- package/assets/skills/office-pdf/tasks/annotations.md +98 -0
- package/assets/skills/office-pdf/tasks/create.md +75 -0
- package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
- package/assets/skills/office-pdf/tasks/forms.md +67 -0
- package/assets/skills/office-pdf/tasks/read.md +62 -0
- package/assets/skills/office-pdf/tasks/redact.md +72 -0
- package/assets/skills/office-pdf/tasks/stamp.md +60 -0
- package/assets/skills/office-presentations/SKILL.md +141 -87
- package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
- package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
- package/assets/skills/office-presentations/recipes/README.md +71 -0
- package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
- package/assets/skills/office-presentations/recipes/connectors.md +83 -0
- package/assets/skills/office-presentations/recipes/images.md +80 -0
- package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
- package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
- package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
- package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
- package/assets/skills/office-presentations/recipes/tables.md +88 -0
- package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
- package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
- package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
- package/assets/skills/office-presentations/references/finalization.md +63 -0
- package/assets/skills/office-presentations/references/native-bullets.md +78 -0
- package/assets/skills/office-presentations/references/native-evidence.md +67 -0
- package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
- package/assets/skills/office-presentations/references/templates.md +76 -0
- package/assets/skills/office-presentations/routing/google_slides.md +45 -0
- package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
- package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
- package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
- package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
- package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
- package/assets/skills/office-presentations/tasks/template-following.md +132 -0
- package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
- package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
- package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
- package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
- package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
- package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
- package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
- package/assets/skills/office-spreadsheets/references/charts.md +63 -0
- package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
- package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
- package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
- package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
- package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
- package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
- package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
- package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
- package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
- package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
- package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
- package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
- package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
- package/assets/skills/shared/annotation-recipes.md +252 -0
- package/assets/skills/shared/check_runtime.py +253 -0
- package/assets/skills/shared/common-workflow.md +139 -0
- package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/index.js +239 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +171 -246
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Inventory an existing .xlsx workbook before touching it.
|
|
3
|
+
|
|
4
|
+
Read-only: opens the file, never saves it. Prints one JSON object listing, per
|
|
5
|
+
sheet, the structures that openpyxl can silently rewrite on save (Excel tables,
|
|
6
|
+
pivot caches, shared and array formulas, conditional formats, data validations,
|
|
7
|
+
charts, merged ranges, defined names), plus whether cached formula results are
|
|
8
|
+
present. Use it to plan an edit from facts instead of guesses.
|
|
9
|
+
|
|
10
|
+
Exit codes:
|
|
11
|
+
0 workbook inspected, JSON printed on stdout
|
|
12
|
+
1 the file is missing or is not a readable .xlsx container
|
|
13
|
+
2 usage error (argparse)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import zipfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
26
|
+
# Legacy console codepages (cp936, cp1252) must not abort a report.
|
|
27
|
+
sys.stdout.reconfigure(errors="replace")
|
|
28
|
+
|
|
29
|
+
FORMULA_KINDS = {
|
|
30
|
+
"shared": '<f t="shared"',
|
|
31
|
+
"array": '<f t="array"',
|
|
32
|
+
"data_table": 't="dataTable"',
|
|
33
|
+
}
|
|
34
|
+
ATTR = re.compile(r'([\w:]+)="([^"]*)"')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_args() -> argparse.Namespace:
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
description="Inventory an .xlsx workbook: sheets, formulas, tables, "
|
|
40
|
+
"pivot caches, conditional formats, defined names, cached values.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--input", required=True, help="path to the .xlsx file")
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--no-values",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="skip the second load that checks cached formula results",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--sample",
|
|
50
|
+
type=int,
|
|
51
|
+
default=5,
|
|
52
|
+
help="how many formula samples to show per sheet (default 5)",
|
|
53
|
+
)
|
|
54
|
+
return parser.parse_args()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------
|
|
58
|
+
# Raw container scan: what the file holds that a library round-trip may drop.
|
|
59
|
+
# --------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _attrs(tag: str) -> dict[str, str]:
|
|
63
|
+
return dict(ATTR.findall(tag))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _sheet_xml_names(archive: zipfile.ZipFile) -> dict[str, str]:
|
|
67
|
+
"""Map worksheet part path -> sheet name, following workbook relationships."""
|
|
68
|
+
try:
|
|
69
|
+
workbook = archive.read("xl/workbook.xml").decode("utf-8", "replace")
|
|
70
|
+
rels = archive.read("xl/_rels/workbook.xml.rels").decode("utf-8", "replace")
|
|
71
|
+
except KeyError:
|
|
72
|
+
return {}
|
|
73
|
+
rel_targets: dict[str, str] = {}
|
|
74
|
+
for tag in re.findall(r"<Relationship\b[^>]*>", rels):
|
|
75
|
+
attrs = _attrs(tag)
|
|
76
|
+
rel_targets[attrs.get("Id", "")] = attrs.get("Target", "")
|
|
77
|
+
|
|
78
|
+
mapping: dict[str, str] = {}
|
|
79
|
+
for tag in re.findall(r"<sheet\b[^>]*?/?>", workbook):
|
|
80
|
+
attrs = _attrs(tag)
|
|
81
|
+
target = rel_targets.get(attrs.get("r:id") or attrs.get("rId") or "", "")
|
|
82
|
+
if not target:
|
|
83
|
+
continue
|
|
84
|
+
if target.startswith("/"):
|
|
85
|
+
path = target.lstrip("/")
|
|
86
|
+
else:
|
|
87
|
+
path = "xl/" + target
|
|
88
|
+
parts: list[str] = []
|
|
89
|
+
for segment in path.split("/"):
|
|
90
|
+
if segment == "..":
|
|
91
|
+
if parts:
|
|
92
|
+
parts.pop()
|
|
93
|
+
elif segment not in ("", "."):
|
|
94
|
+
parts.append(segment)
|
|
95
|
+
mapping["/".join(parts)] = attrs.get("name", "")
|
|
96
|
+
return mapping
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _formula_kinds(archive: zipfile.ZipFile, part: str) -> dict[str, int]:
|
|
100
|
+
try:
|
|
101
|
+
xml = archive.read(part).decode("utf-8", "replace")
|
|
102
|
+
except KeyError:
|
|
103
|
+
return {kind: 0 for kind in FORMULA_KINDS}
|
|
104
|
+
return {kind: xml.count(marker) for kind, marker in FORMULA_KINDS.items()}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _container(archive: zipfile.ZipFile) -> dict[str, object]:
|
|
108
|
+
names = archive.namelist()
|
|
109
|
+
|
|
110
|
+
def count(prefix: str, suffix: str = "") -> int:
|
|
111
|
+
return sum(1 for name in names if name.startswith(prefix) and name.endswith(suffix))
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"tables": count("xl/tables/", ".xml"),
|
|
115
|
+
"pivot_tables": count("xl/pivotTables/", ".xml"),
|
|
116
|
+
"pivot_caches": count("xl/pivotCache/", ".xml"),
|
|
117
|
+
"charts": count("xl/charts/", ".xml"),
|
|
118
|
+
"drawings": count("xl/drawings/drawing", ".xml"),
|
|
119
|
+
"media": count("xl/media/"),
|
|
120
|
+
"comment_parts": count("xl/comments", ".xml"),
|
|
121
|
+
"external_links": count("xl/externalLinks/", ".xml"),
|
|
122
|
+
"has_vba_project": "xl/vbaProject.bin" in names,
|
|
123
|
+
"has_calc_chain": "xl/calcChain.xml" in names,
|
|
124
|
+
"has_shared_strings": "xl/sharedStrings.xml" in names,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------
|
|
129
|
+
# Sheet inventory through openpyxl.
|
|
130
|
+
# --------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _anchor(chart) -> str | None:
|
|
134
|
+
from openpyxl.utils import get_column_letter
|
|
135
|
+
|
|
136
|
+
start = getattr(getattr(chart, "anchor", None), "_from", None)
|
|
137
|
+
if start is None:
|
|
138
|
+
return None
|
|
139
|
+
return f"{get_column_letter(start.col + 1)}{start.row + 1}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _chart_kind(chart) -> str:
|
|
143
|
+
"""Name the chart the way a user would: a column is not a bar."""
|
|
144
|
+
class_name = type(chart).__name__
|
|
145
|
+
kind = class_name[: -len("Chart")].lower() if class_name.endswith("Chart") else class_name.lower()
|
|
146
|
+
if kind == "bar":
|
|
147
|
+
return "column" if getattr(chart, "type", "col") == "col" else "bar"
|
|
148
|
+
return kind
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _date_system(workbook) -> str:
|
|
152
|
+
epoch = getattr(workbook, "epoch", None)
|
|
153
|
+
return "1904" if getattr(epoch, "year", 1900) == 1904 else "1900"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _defined_names(workbook) -> list[dict[str, object]]:
|
|
157
|
+
found: list[dict[str, object]] = []
|
|
158
|
+
for entry in workbook.defined_names.values():
|
|
159
|
+
scope: object = "workbook"
|
|
160
|
+
index = entry.localSheetId
|
|
161
|
+
if index is not None and index < len(workbook.sheetnames):
|
|
162
|
+
scope = workbook.sheetnames[index]
|
|
163
|
+
found.append(
|
|
164
|
+
{
|
|
165
|
+
"name": entry.name,
|
|
166
|
+
"refers_to": entry.attr_text,
|
|
167
|
+
"scope": scope,
|
|
168
|
+
"hidden": bool(entry.hidden),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
return found
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _sheet_inventory(
|
|
175
|
+
worksheet, raw: dict[str, int], sample: int
|
|
176
|
+
) -> tuple[dict[str, object], set[str]]:
|
|
177
|
+
formulas: list[tuple[str, str]] = []
|
|
178
|
+
for row in worksheet.iter_rows():
|
|
179
|
+
for cell in row:
|
|
180
|
+
if isinstance(cell.value, str) and cell.value.startswith("="):
|
|
181
|
+
formulas.append((cell.coordinate, cell.value))
|
|
182
|
+
|
|
183
|
+
charts = []
|
|
184
|
+
for chart in getattr(worksheet, "_charts", []):
|
|
185
|
+
charts.append(
|
|
186
|
+
{
|
|
187
|
+
"kind": _chart_kind(chart),
|
|
188
|
+
"class": type(chart).__name__,
|
|
189
|
+
"series": len(getattr(chart, "series", []) or []),
|
|
190
|
+
"anchor": _anchor(chart),
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
tables = []
|
|
195
|
+
for table in worksheet.tables.values():
|
|
196
|
+
tables.append(
|
|
197
|
+
{
|
|
198
|
+
"name": table.name,
|
|
199
|
+
"ref": table.ref,
|
|
200
|
+
"style": getattr(table.tableStyleInfo, "name", None),
|
|
201
|
+
"header_rows": table.headerRowCount,
|
|
202
|
+
"totals_rows": table.totalsRowCount,
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
conditional = []
|
|
207
|
+
for block in worksheet.conditional_formatting:
|
|
208
|
+
rules = []
|
|
209
|
+
for rule in block.rules:
|
|
210
|
+
rules.append(
|
|
211
|
+
{
|
|
212
|
+
"type": rule.type,
|
|
213
|
+
"operator": rule.operator,
|
|
214
|
+
"formula": list(rule.formula or []),
|
|
215
|
+
"priority": rule.priority,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
conditional.append({"range": str(block.sqref), "rules": rules})
|
|
219
|
+
|
|
220
|
+
validations = []
|
|
221
|
+
for validation in worksheet.data_validations.dataValidation:
|
|
222
|
+
validations.append(
|
|
223
|
+
{
|
|
224
|
+
"type": validation.type,
|
|
225
|
+
"cells": str(validation.sqref),
|
|
226
|
+
"formula1": validation.formula1,
|
|
227
|
+
"operator": validation.operator,
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
widths = {
|
|
232
|
+
name: dimension.width
|
|
233
|
+
for name, dimension in worksheet.column_dimensions.items()
|
|
234
|
+
if dimension.width
|
|
235
|
+
}
|
|
236
|
+
if len(widths) > 26:
|
|
237
|
+
widths = dict(list(widths.items())[:26])
|
|
238
|
+
|
|
239
|
+
hidden_rows = [
|
|
240
|
+
index for index, dimension in worksheet.row_dimensions.items() if dimension.hidden
|
|
241
|
+
]
|
|
242
|
+
hidden_columns = [
|
|
243
|
+
name for name, dimension in worksheet.column_dimensions.items() if dimension.hidden
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"name": worksheet.title,
|
|
248
|
+
"state": worksheet.sheet_state,
|
|
249
|
+
"dimensions": worksheet.dimensions,
|
|
250
|
+
"max_row": worksheet.max_row,
|
|
251
|
+
"max_column": worksheet.max_column,
|
|
252
|
+
"freeze_panes": worksheet.freeze_panes,
|
|
253
|
+
"gridlines": bool(worksheet.sheet_view.showGridLines),
|
|
254
|
+
"auto_filter": getattr(worksheet.auto_filter, "ref", None),
|
|
255
|
+
"protection": bool(worksheet.protection.sheet),
|
|
256
|
+
"print_area": str(worksheet.print_area) if worksheet.print_area else None,
|
|
257
|
+
"merged": [str(item) for item in worksheet.merged_cells.ranges],
|
|
258
|
+
"tables": tables,
|
|
259
|
+
"pivot_tables": len(getattr(worksheet, "_pivots", []) or []),
|
|
260
|
+
"charts": charts,
|
|
261
|
+
"images": len(getattr(worksheet, "_images", [])),
|
|
262
|
+
"conditional_formats": conditional,
|
|
263
|
+
"data_validations": validations,
|
|
264
|
+
"hidden_rows": hidden_rows[:20],
|
|
265
|
+
"hidden_columns": hidden_columns[:20],
|
|
266
|
+
"column_widths": widths,
|
|
267
|
+
"formulas": {
|
|
268
|
+
"count": len(formulas),
|
|
269
|
+
"sample": [f"{address}: {text}" for address, text in formulas[: max(sample, 0)]],
|
|
270
|
+
"shared": raw.get("shared", 0),
|
|
271
|
+
"array": raw.get("array", 0),
|
|
272
|
+
"data_tables": raw.get("data_table", 0),
|
|
273
|
+
},
|
|
274
|
+
}, {address for address, _ in formulas}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _cached_values(path: Path, formula_cells: dict[str, set[str]]) -> dict[str, dict[str, object]]:
|
|
278
|
+
"""Classify stored results for the formula cells found in the formula pass."""
|
|
279
|
+
from openpyxl import load_workbook
|
|
280
|
+
|
|
281
|
+
workbook = load_workbook(str(path), data_only=True)
|
|
282
|
+
result: dict[str, dict[str, object]] = {}
|
|
283
|
+
try:
|
|
284
|
+
for name, coordinates in formula_cells.items():
|
|
285
|
+
if name not in workbook.sheetnames:
|
|
286
|
+
continue
|
|
287
|
+
worksheet = workbook[name]
|
|
288
|
+
cached = 0
|
|
289
|
+
missing = 0
|
|
290
|
+
errors = []
|
|
291
|
+
for coordinate in sorted(coordinates):
|
|
292
|
+
value = worksheet[coordinate].value
|
|
293
|
+
if value is None:
|
|
294
|
+
missing += 1
|
|
295
|
+
elif isinstance(value, str) and value.startswith("#"):
|
|
296
|
+
errors.append({"cell": coordinate, "value": value})
|
|
297
|
+
else:
|
|
298
|
+
cached += 1
|
|
299
|
+
result[name] = {"cached": cached, "missing": missing, "errors": errors}
|
|
300
|
+
finally:
|
|
301
|
+
workbook.close()
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def main() -> int:
|
|
306
|
+
args = parse_args()
|
|
307
|
+
path = Path(args.input).expanduser()
|
|
308
|
+
if not path.is_file():
|
|
309
|
+
print(json.dumps({"ok": False, "error": f"file not found: {path}"}), file=sys.stderr)
|
|
310
|
+
return 1
|
|
311
|
+
if not zipfile.is_zipfile(path):
|
|
312
|
+
print(json.dumps({"ok": False, "error": f"not a zip container: {path}"}), file=sys.stderr)
|
|
313
|
+
return 1
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
from openpyxl import load_workbook
|
|
317
|
+
except ImportError as error: # pragma: no cover - environment guard
|
|
318
|
+
print(json.dumps({"ok": False, "error": f"openpyxl missing: {error}"}), file=sys.stderr)
|
|
319
|
+
return 1
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
with zipfile.ZipFile(path) as archive:
|
|
323
|
+
part_names = _sheet_xml_names(archive)
|
|
324
|
+
container = _container(archive)
|
|
325
|
+
raw_by_sheet = {
|
|
326
|
+
name: _formula_kinds(archive, part) for part, name in part_names.items()
|
|
327
|
+
}
|
|
328
|
+
workbook = load_workbook(str(path), data_only=False)
|
|
329
|
+
except Exception as error: # noqa: BLE001 - report any unreadable workbook the same way
|
|
330
|
+
print(
|
|
331
|
+
json.dumps({"ok": False, "error": f"cannot read workbook: {error}"}),
|
|
332
|
+
file=sys.stderr,
|
|
333
|
+
)
|
|
334
|
+
return 1
|
|
335
|
+
|
|
336
|
+
warnings: list[str] = []
|
|
337
|
+
sheets = []
|
|
338
|
+
formula_cells: dict[str, set[str]] = {}
|
|
339
|
+
try:
|
|
340
|
+
for worksheet in workbook.worksheets:
|
|
341
|
+
inventory, coordinates = _sheet_inventory(
|
|
342
|
+
worksheet, raw_by_sheet.get(worksheet.title, {}), args.sample
|
|
343
|
+
)
|
|
344
|
+
formula_cells[worksheet.title] = coordinates
|
|
345
|
+
sheets.append(inventory)
|
|
346
|
+
workbook_meta = {
|
|
347
|
+
"sheets": list(workbook.sheetnames),
|
|
348
|
+
"sheet_count": len(workbook.sheetnames),
|
|
349
|
+
"active_sheet": workbook.active.title if workbook.active else None,
|
|
350
|
+
"defined_names": _defined_names(workbook),
|
|
351
|
+
"full_calc_on_load": bool(
|
|
352
|
+
getattr(getattr(workbook, "calculation", None), "fullCalcOnLoad", False)
|
|
353
|
+
),
|
|
354
|
+
"calc_mode": getattr(getattr(workbook, "calculation", None), "calcMode", None),
|
|
355
|
+
"date_system": _date_system(workbook),
|
|
356
|
+
}
|
|
357
|
+
finally:
|
|
358
|
+
workbook.close()
|
|
359
|
+
|
|
360
|
+
if not args.no_values:
|
|
361
|
+
values = _cached_values(path, formula_cells)
|
|
362
|
+
for sheet in sheets:
|
|
363
|
+
sheet["values"] = values.get(sheet["name"], {"cached": 0, "missing": 0, "errors": []})
|
|
364
|
+
entry = sheet["values"]
|
|
365
|
+
if entry["missing"]: # type: ignore[index]
|
|
366
|
+
warnings.append(
|
|
367
|
+
f"{sheet['name']}: {entry['missing']} formula cell(s) have no cached value" # type: ignore[index]
|
|
368
|
+
)
|
|
369
|
+
for error in entry["errors"]: # type: ignore[index]
|
|
370
|
+
warnings.append(
|
|
371
|
+
f"{sheet['name']}!{error['cell']} already stores {error['value']}"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if container["pivot_tables"]:
|
|
375
|
+
warnings.append(
|
|
376
|
+
f"{container['pivot_tables']} pivot table(s) present: a library round-trip "
|
|
377
|
+
"may drop or re-key them; verify after saving"
|
|
378
|
+
)
|
|
379
|
+
if container["has_calc_chain"]:
|
|
380
|
+
warnings.append(
|
|
381
|
+
"the file carries a calculation chain; a library round-trip may leave it stale"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
payload = {
|
|
385
|
+
"ok": True,
|
|
386
|
+
"input": str(path),
|
|
387
|
+
"size_bytes": path.stat().st_size,
|
|
388
|
+
"workbook": workbook_meta,
|
|
389
|
+
"container": container,
|
|
390
|
+
"sheets": sheets,
|
|
391
|
+
"warnings": warnings,
|
|
392
|
+
}
|
|
393
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
|
|
394
|
+
return 0
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
sys.exit(main())
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Gate a workbook on formulas that actually resolve.
|
|
3
|
+
|
|
4
|
+
Recomputes the file through the helper CLI (`xlsx recalc`, which drives Microsoft
|
|
5
|
+
Excel or WPS Spreadsheets over COM), then classifies every formula cell: a real
|
|
6
|
+
value, a spreadsheet error, or no cached value at all. Also flags formulas that
|
|
7
|
+
point at sheets the workbook does not contain, and formulas whose text already
|
|
8
|
+
carries a `#REF!` literal.
|
|
9
|
+
|
|
10
|
+
Without a calculation engine the recomputation cannot happen; `--no-recalc` then
|
|
11
|
+
inspects the cached values a previous producer left in the file instead of failing
|
|
12
|
+
outright. A file whose formulas carry no cached value at all cannot be certified.
|
|
13
|
+
|
|
14
|
+
Exit codes:
|
|
15
|
+
0 verified: every formula resolved to a value
|
|
16
|
+
1 failed: error cells, unverified formulas after recomputation, or formulas
|
|
17
|
+
that point at sheets the workbook does not contain
|
|
18
|
+
2 usage error, missing file, or unreadable workbook
|
|
19
|
+
3 unverified: no formula engine and no complete set of cached values
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
32
|
+
# Legacy console codepages (cp936, cp1252) must not abort a report.
|
|
33
|
+
sys.stdout.reconfigure(errors="replace")
|
|
34
|
+
|
|
35
|
+
SHEET_REF = re.compile(r"(?:'((?:[^']|'')+)'|([A-Za-z_][A-Za-z0-9_.]*))!")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_args() -> argparse.Namespace:
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
description="Verify that a workbook's formulas recalculate to values; "
|
|
41
|
+
"report errors, missing cached results and dangling sheet references.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("--input", required=True, help="workbook to check")
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--out",
|
|
46
|
+
help="where to write the recalculated copy (default: <input>-recalculated.xlsx)",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--cli",
|
|
50
|
+
help="path to python/office_cli.py (default: guessed from this script's location)",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--python",
|
|
54
|
+
default=sys.executable,
|
|
55
|
+
help="interpreter that runs the helper CLI (default: this interpreter)",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--no-recalc",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="do not recompute; check the cached values already stored in the file",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--strict",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="treat formulas without a cached value as failures even without recalculation",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument("--ignore", help="comma-separated cells to accept as known errors, e.g. 'Rates!B7,B9'")
|
|
68
|
+
return parser.parse_args()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _default_cli() -> Path:
|
|
72
|
+
return Path(__file__).resolve().parents[4] / "python" / "office_cli.py"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _formula_cells(path: Path) -> tuple[list[str], list[tuple[str, str, str]]]:
|
|
76
|
+
from openpyxl import load_workbook
|
|
77
|
+
|
|
78
|
+
workbook = load_workbook(str(path), data_only=False)
|
|
79
|
+
try:
|
|
80
|
+
names = list(workbook.sheetnames)
|
|
81
|
+
cells = [
|
|
82
|
+
(worksheet.title, cell.coordinate, str(cell.value))
|
|
83
|
+
for worksheet in workbook.worksheets
|
|
84
|
+
for row in worksheet.iter_rows()
|
|
85
|
+
for cell in row
|
|
86
|
+
if isinstance(cell.value, str) and cell.value.startswith("=")
|
|
87
|
+
]
|
|
88
|
+
finally:
|
|
89
|
+
workbook.close()
|
|
90
|
+
return names, cells
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cached_values(path: Path, cells: list[tuple[str, str, str]]) -> dict[tuple[str, str], object]:
|
|
94
|
+
from openpyxl import load_workbook
|
|
95
|
+
|
|
96
|
+
workbook = load_workbook(str(path), data_only=True)
|
|
97
|
+
try:
|
|
98
|
+
return {
|
|
99
|
+
(sheet, cell): workbook[sheet][cell].value
|
|
100
|
+
for sheet, cell, _ in cells
|
|
101
|
+
if sheet in workbook.sheetnames
|
|
102
|
+
}
|
|
103
|
+
finally:
|
|
104
|
+
workbook.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _referenced_sheets(formula: str) -> list[str]:
|
|
108
|
+
found = []
|
|
109
|
+
for match in SHEET_REF.finditer(formula):
|
|
110
|
+
if match.start() and formula[match.start() - 1] == "#":
|
|
111
|
+
continue # error literal such as #REF!, not a sheet named REF
|
|
112
|
+
name = (match.group(1) or match.group(2)).replace("''", "'")
|
|
113
|
+
if name.startswith("[") or name.startswith("file:"):
|
|
114
|
+
continue
|
|
115
|
+
found.append(name)
|
|
116
|
+
return found
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _recalculate(python: str, cli: Path, source: Path, out: Path) -> tuple[bool, str | None]:
|
|
120
|
+
command = [python, str(cli), "xlsx", "recalc", "--input", str(source), "--out", str(out)]
|
|
121
|
+
try:
|
|
122
|
+
completed = subprocess.run(command, capture_output=True, text=True, timeout=300)
|
|
123
|
+
except FileNotFoundError as error:
|
|
124
|
+
return False, f"cannot start the helper CLI: {error}"
|
|
125
|
+
except subprocess.TimeoutExpired:
|
|
126
|
+
return False, "recalculation timed out after 300 seconds"
|
|
127
|
+
if completed.returncode == 0 and out.is_file():
|
|
128
|
+
return True, None
|
|
129
|
+
message = (completed.stderr or completed.stdout or "").strip()
|
|
130
|
+
try:
|
|
131
|
+
parsed = json.loads(message)
|
|
132
|
+
message = str(parsed.get("error", message))
|
|
133
|
+
except json.JSONDecodeError:
|
|
134
|
+
pass
|
|
135
|
+
return False, message or f"helper CLI exited with code {completed.returncode}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _ignored(entries: str | None) -> dict[str, set[str]]:
|
|
139
|
+
allowed: dict[str, set[str]] = {}
|
|
140
|
+
for entry in (entries or "").split(","):
|
|
141
|
+
entry = entry.strip()
|
|
142
|
+
if not entry:
|
|
143
|
+
continue
|
|
144
|
+
if "!" in entry:
|
|
145
|
+
sheet, cell = entry.split("!", 1)
|
|
146
|
+
allowed.setdefault(sheet.strip("'"), set()).add(cell.upper())
|
|
147
|
+
else:
|
|
148
|
+
allowed.setdefault("*", set()).add(entry.upper())
|
|
149
|
+
return allowed
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main() -> int:
|
|
153
|
+
args = parse_args()
|
|
154
|
+
source = Path(args.input).expanduser()
|
|
155
|
+
if not source.is_file():
|
|
156
|
+
print(json.dumps({"ok": False, "error": f"file not found: {source}"}), file=sys.stderr)
|
|
157
|
+
return 2
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
sheet_names, cells = _formula_cells(source)
|
|
161
|
+
except Exception as error: # noqa: BLE001 - every unreadable workbook fails the same way
|
|
162
|
+
print(json.dumps({"ok": False, "error": f"cannot read workbook: {error}"}), file=sys.stderr)
|
|
163
|
+
return 2
|
|
164
|
+
|
|
165
|
+
recalculated = False
|
|
166
|
+
engine = None
|
|
167
|
+
recalc_error = None
|
|
168
|
+
checked = source
|
|
169
|
+
if args.no_recalc:
|
|
170
|
+
recalc_error = "recalculation skipped by --no-recalc"
|
|
171
|
+
else:
|
|
172
|
+
cli = Path(args.cli).expanduser() if args.cli else _default_cli()
|
|
173
|
+
out = Path(args.out).expanduser() if args.out else source.with_name(
|
|
174
|
+
f"{source.stem}-recalculated{source.suffix}"
|
|
175
|
+
)
|
|
176
|
+
if not cli.is_file():
|
|
177
|
+
recalc_error = f"helper CLI not found: {cli}; pass --cli or use --no-recalc"
|
|
178
|
+
else:
|
|
179
|
+
recalculated, recalc_error = _recalculate(args.python, cli, source, out)
|
|
180
|
+
if recalculated:
|
|
181
|
+
checked = out
|
|
182
|
+
engine = "microsoft excel"
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
cached = _cached_values(checked, cells) if cells else {}
|
|
186
|
+
except Exception as error: # noqa: BLE001
|
|
187
|
+
print(json.dumps({"ok": False, "error": f"cannot read values: {error}"}), file=sys.stderr)
|
|
188
|
+
return 2
|
|
189
|
+
|
|
190
|
+
allowed = _ignored(args.ignore)
|
|
191
|
+
errors: list[dict[str, object]] = []
|
|
192
|
+
missing: list[dict[str, object]] = []
|
|
193
|
+
dangling: list[dict[str, object]] = []
|
|
194
|
+
ignored_cells: list[str] = []
|
|
195
|
+
sheets_seen: dict[str, int] = {}
|
|
196
|
+
valued = 0
|
|
197
|
+
|
|
198
|
+
for sheet, coordinate, formula in cells:
|
|
199
|
+
sheets_seen[sheet] = sheets_seen.get(sheet, 0) + 1
|
|
200
|
+
value = cached.get((sheet, coordinate))
|
|
201
|
+
accepted = coordinate.upper() in allowed.get(sheet, set()) or coordinate.upper() in allowed.get("*", set())
|
|
202
|
+
|
|
203
|
+
if "#REF!" in formula:
|
|
204
|
+
entry = {"sheet": sheet, "cell": coordinate, "formula": formula, "value": "#REF!"}
|
|
205
|
+
if accepted:
|
|
206
|
+
ignored_cells.append(f"{sheet}!{coordinate}")
|
|
207
|
+
else:
|
|
208
|
+
errors.append(entry)
|
|
209
|
+
elif isinstance(value, str) and value.startswith("#"):
|
|
210
|
+
entry = {"sheet": sheet, "cell": coordinate, "formula": formula, "value": value}
|
|
211
|
+
if accepted:
|
|
212
|
+
ignored_cells.append(f"{sheet}!{coordinate}")
|
|
213
|
+
else:
|
|
214
|
+
errors.append(entry)
|
|
215
|
+
elif value is None:
|
|
216
|
+
entry = {"sheet": sheet, "cell": coordinate, "formula": formula}
|
|
217
|
+
if accepted:
|
|
218
|
+
ignored_cells.append(f"{sheet}!{coordinate}")
|
|
219
|
+
else:
|
|
220
|
+
missing.append(entry)
|
|
221
|
+
else:
|
|
222
|
+
valued += 1
|
|
223
|
+
|
|
224
|
+
if accepted:
|
|
225
|
+
continue
|
|
226
|
+
for reference in _referenced_sheets(formula):
|
|
227
|
+
base = reference.split(":", 1)
|
|
228
|
+
if all(part in sheet_names for part in base):
|
|
229
|
+
continue
|
|
230
|
+
dangling.append(
|
|
231
|
+
{"sheet": sheet, "cell": coordinate, "formula": formula, "references": reference}
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
failures = bool(errors or dangling)
|
|
235
|
+
if missing and (recalculated or args.strict):
|
|
236
|
+
failures = True
|
|
237
|
+
|
|
238
|
+
# Verified means every formula resolved on this run or arrived fully cached;
|
|
239
|
+
# a file whose formulas have never been computed cannot be certified.
|
|
240
|
+
if failures:
|
|
241
|
+
verdict = "failed"
|
|
242
|
+
elif not cells or recalculated or not missing:
|
|
243
|
+
verdict = "verified"
|
|
244
|
+
else:
|
|
245
|
+
verdict = "unverified"
|
|
246
|
+
|
|
247
|
+
payload = {
|
|
248
|
+
"ok": verdict == "verified",
|
|
249
|
+
"input": str(source),
|
|
250
|
+
"checked_against": str(checked),
|
|
251
|
+
"recalculated": recalculated,
|
|
252
|
+
"engine": engine,
|
|
253
|
+
"value_source": "recalculated" if recalculated else ("cached" if cells and not missing else None),
|
|
254
|
+
"recalc_error": recalc_error,
|
|
255
|
+
"verdict": verdict,
|
|
256
|
+
"formulas_by_sheet": sheets_seen,
|
|
257
|
+
"counts": {
|
|
258
|
+
"formulas": len(cells),
|
|
259
|
+
"valued": valued,
|
|
260
|
+
"errors": len(errors),
|
|
261
|
+
"missing": len(missing),
|
|
262
|
+
"dangling": len(dangling),
|
|
263
|
+
"ignored": len(ignored_cells),
|
|
264
|
+
},
|
|
265
|
+
"errors": errors,
|
|
266
|
+
"missing": missing,
|
|
267
|
+
"dangling": dangling,
|
|
268
|
+
"ignored": ignored_cells,
|
|
269
|
+
}
|
|
270
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
|
|
271
|
+
return {"verified": 0, "failed": 1, "unverified": 3}[verdict]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
sys.exit(main())
|