sheetdelta 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.
sheetdelta/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """sheetdelta: diff Excel workbooks without Excel.
2
+
3
+ Compares two .xlsx files cell by cell, including the formulas, the values
4
+ Excel cached, and the dependency graph that says which cells a change
5
+ reaches. Nothing is evaluated -- the reader only looks at what Excel already
6
+ stored.
7
+
8
+ Typical use is a CI check: fail the build when a formula edit corrupts a
9
+ downstream total, pass it when the change is cosmetic.
10
+
11
+ from sheetdelta import read_workbook, diff_workbooks
12
+
13
+ result = diff_workbooks(read_workbook("old.xlsx"), read_workbook("new.xlsx"))
14
+ for change in result.breaking:
15
+ print(change.ref, change.detail, change.affected)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from .differ import CellChange, ChangeKind, DiffResult, Severity, SheetChange, diff_workbooks
21
+ from .errors import SheetDeltaError, UnsupportedFormatError, WorkbookReadError
22
+ from .model import (
23
+ Cell,
24
+ CellKind,
25
+ CellRef,
26
+ RangeRef,
27
+ Reference,
28
+ Sheet,
29
+ Workbook,
30
+ column_letter,
31
+ column_number,
32
+ )
33
+ from .reader import read_workbook
34
+ from .references import extract_references
35
+ from .report import exit_code, render_json, render_text, to_dict
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ __all__ = [
40
+ "Cell",
41
+ "CellChange",
42
+ "CellKind",
43
+ "CellRef",
44
+ "ChangeKind",
45
+ "DiffResult",
46
+ "RangeRef",
47
+ "Reference",
48
+ "Severity",
49
+ "Sheet",
50
+ "SheetChange",
51
+ "SheetDeltaError",
52
+ "UnsupportedFormatError",
53
+ "Workbook",
54
+ "WorkbookReadError",
55
+ "__version__",
56
+ "column_letter",
57
+ "column_number",
58
+ "diff_workbooks",
59
+ "exit_code",
60
+ "extract_references",
61
+ "read_workbook",
62
+ "render_json",
63
+ "render_text",
64
+ "to_dict",
65
+ ]
sheetdelta/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m sheetdelta``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
sheetdelta/audit.py ADDED
@@ -0,0 +1,169 @@
1
+ """Inspect a single workbook for defects a diff cannot see.
2
+
3
+ An audit answers "is this file sound", not "what changed". Two defects are
4
+ worth reporting, and both are unambiguous rather than heuristic:
5
+
6
+ * a formula pointing at a sheet that does not exist, which Excel shows as
7
+ ``#REF!``; and
8
+ * a circular reference, a cell that depends on itself through some chain of
9
+ other cells, which Excel refuses to calculate at all.
10
+
11
+ Deliberately not checked: a reference to a cell that is merely empty. That is
12
+ normal in a spreadsheet, and flagging it would make the audit cry wolf on
13
+ every real file.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ from .model import Cell, CellKind, CellRef, Workbook
21
+
22
+
23
+ @dataclass
24
+ class BrokenReference:
25
+ """A formula reference to a sheet that is not in the workbook."""
26
+
27
+ ref: CellRef
28
+ formula: str
29
+ target: str
30
+ reason: str
31
+
32
+
33
+ @dataclass
34
+ class CircularReference:
35
+ """A cycle of cells that each depend on the next, ending where it started."""
36
+
37
+ cells: list[CellRef]
38
+
39
+ @property
40
+ def display(self) -> str:
41
+ return " -> ".join(str(ref) for ref in self.cells)
42
+
43
+
44
+ @dataclass
45
+ class AuditResult:
46
+ path: str
47
+ sheets: list[str] = field(default_factory=list)
48
+ formula_count: int = 0
49
+ cell_count: int = 0
50
+ defined_names: dict[str, str] = field(default_factory=dict)
51
+ broken: list[BrokenReference] = field(default_factory=list)
52
+ cycles: list[CircularReference] = field(default_factory=list)
53
+
54
+ @property
55
+ def is_sound(self) -> bool:
56
+ return not self.broken and not self.cycles
57
+
58
+ @property
59
+ def issue_count(self) -> int:
60
+ return len(self.broken) + len(self.cycles)
61
+
62
+
63
+ def audit_workbook(workbook: Workbook) -> AuditResult:
64
+ """Check a workbook for broken references and circular references."""
65
+ result = AuditResult(
66
+ path=workbook.path,
67
+ sheets=workbook.sheet_names,
68
+ defined_names=dict(workbook.defined_names),
69
+ )
70
+
71
+ for sheet in workbook.sheets:
72
+ for cell in sheet.cells.values():
73
+ result.cell_count += 1
74
+ if cell.kind is CellKind.FORMULA:
75
+ result.formula_count += 1
76
+ result.broken.extend(_missing_sheets(cell, workbook))
77
+
78
+ result.broken.sort(key=lambda b: (b.ref.sheet, b.ref.row, b.ref.col))
79
+ result.cycles = _find_cycles(workbook)
80
+ return result
81
+
82
+
83
+ def _missing_sheets(cell: Cell, workbook: Workbook) -> list[BrokenReference]:
84
+ """Every reference in one formula that names a sheet that is not there."""
85
+ out: list[BrokenReference] = []
86
+ for reference in sorted(cell.refs, key=str):
87
+ if workbook.sheet_by_name(reference.sheet) is None:
88
+ out.append(
89
+ BrokenReference(
90
+ ref=cell.ref,
91
+ formula=cell.formula or "",
92
+ target=str(reference),
93
+ reason=f"sheet '{reference.sheet}' does not exist",
94
+ )
95
+ )
96
+ return out
97
+
98
+
99
+ def _find_cycles(workbook: Workbook) -> list[CircularReference]:
100
+ """Find circular references across the whole workbook.
101
+
102
+ Walks the dependency graph depth-first, tracking the cells on the current
103
+ path. Reaching a cell already on the path closes a cycle, and the slice of
104
+ the path from that cell onwards is the loop to report.
105
+ """
106
+ graph = _dependency_graph(workbook)
107
+ found: list[CircularReference] = []
108
+ seen: set[CellRef] = set()
109
+ reported: set[frozenset[CellRef]] = set()
110
+
111
+ for start in sorted(graph, key=lambda r: (r.sheet, r.row, r.col)):
112
+ if start in seen:
113
+ continue
114
+ _walk(start, graph, [], set(), seen, found, reported)
115
+
116
+ return found
117
+
118
+
119
+ def _walk(
120
+ node: CellRef,
121
+ graph: dict[CellRef, set[CellRef]],
122
+ path: list[CellRef],
123
+ on_path: set[CellRef],
124
+ seen: set[CellRef],
125
+ found: list[CircularReference],
126
+ reported: set[frozenset[CellRef]],
127
+ ) -> None:
128
+ path.append(node)
129
+ on_path.add(node)
130
+
131
+ for neighbour in sorted(graph.get(node, ()), key=lambda r: (r.sheet, r.row, r.col)):
132
+ if neighbour in on_path:
133
+ cycle = path[path.index(neighbour) :]
134
+ key = frozenset(cycle)
135
+ if key not in reported:
136
+ reported.add(key)
137
+ found.append(CircularReference(cells=[*cycle, neighbour]))
138
+ elif neighbour not in seen:
139
+ _walk(neighbour, graph, path, on_path, seen, found, reported)
140
+
141
+ path.pop()
142
+ on_path.discard(node)
143
+ seen.add(node)
144
+
145
+
146
+ def _dependency_graph(workbook: Workbook) -> dict[CellRef, set[CellRef]]:
147
+ """Map each cell to the cells it reads, ignoring self-reads and blanks.
148
+
149
+ Cross-sheet references are followed to the sheet they name, so a cycle that
150
+ runs from one sheet into another is still found.
151
+ """
152
+ graph: dict[CellRef, set[CellRef]] = {}
153
+ for sheet in workbook.sheets:
154
+ for cell in sheet.cells.values():
155
+ if cell.kind is not CellKind.FORMULA:
156
+ continue
157
+ targets: set[CellRef] = set()
158
+ for reference in cell.refs:
159
+ target_sheet = workbook.sheet_by_name(reference.sheet)
160
+ if target_sheet is None:
161
+ continue
162
+ targets.update(
163
+ ref
164
+ for ref in target_sheet.cells
165
+ if ref != cell.ref and reference.contains(ref)
166
+ )
167
+ if targets:
168
+ graph[cell.ref] = targets
169
+ return graph
sheetdelta/cli.py ADDED
@@ -0,0 +1,140 @@
1
+ """The ``sheetdelta`` command line.
2
+
3
+ Two subcommands: ``diff`` compares two workbooks, ``audit`` inspects one. The
4
+ exit code is the point of the tool in CI, so ``--fail-on`` is explicit rather
5
+ than implied.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from collections.abc import Sequence
14
+
15
+ from . import __version__
16
+ from .audit import AuditResult, audit_workbook
17
+ from .differ import diff_workbooks
18
+ from .errors import SheetDeltaError
19
+ from .reader import read_workbook
20
+ from .report import exit_code, render_json, render_text
21
+
22
+ FAIL_ON = ("never", "any", "breaking")
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(
27
+ prog="sheetdelta",
28
+ description="Diff Excel workbooks without Excel.",
29
+ epilog="Exit status: 0 no changes worth failing on, 1 changes found, 2 error.",
30
+ )
31
+ parser.add_argument("--version", action="version", version=f"sheetdelta {__version__}")
32
+ sub = parser.add_subparsers(dest="command", required=True)
33
+
34
+ diff = sub.add_parser("diff", help="compare two workbooks")
35
+ diff.add_argument("old", metavar="OLD.xlsx", help="the earlier workbook")
36
+ diff.add_argument("new", metavar="NEW.xlsx", help="the later workbook")
37
+ diff.add_argument("--json", action="store_true", help="emit JSON instead of text")
38
+ diff.add_argument(
39
+ "--fail-on",
40
+ choices=FAIL_ON,
41
+ default="breaking",
42
+ help="when to exit non-zero (default: breaking)",
43
+ )
44
+
45
+ audit = sub.add_parser("audit", help="inspect one workbook for broken references")
46
+ audit.add_argument("workbook", metavar="FILE.xlsx", help="the workbook to inspect")
47
+ audit.add_argument("--json", action="store_true", help="emit JSON")
48
+ audit.add_argument(
49
+ "--fail-on",
50
+ choices=FAIL_ON,
51
+ default="any",
52
+ help="when to exit non-zero (default: any)",
53
+ )
54
+
55
+ return parser
56
+
57
+
58
+ def main(argv: Sequence[str] | None = None) -> int:
59
+ args = build_parser().parse_args(argv)
60
+ try:
61
+ if args.command == "diff":
62
+ return _cmd_diff(args)
63
+ if args.command == "audit":
64
+ return _cmd_audit(args)
65
+ except SheetDeltaError as exc:
66
+ print(f"sheetdelta: {exc}", file=sys.stderr)
67
+ return 2
68
+ return 2
69
+
70
+
71
+ def _cmd_diff(args: argparse.Namespace) -> int:
72
+ result = diff_workbooks(read_workbook(args.old), read_workbook(args.new))
73
+ print(render_json(result) if args.json else render_text(result))
74
+ return exit_code(result, args.fail_on)
75
+
76
+
77
+ def _cmd_audit(args: argparse.Namespace) -> int:
78
+ result = audit_workbook(read_workbook(args.workbook))
79
+ print(render_audit_json(result) if args.json else render_audit_text(result))
80
+ if args.fail_on == "never":
81
+ return 0
82
+ if args.fail_on == "any":
83
+ return 1 if result.issue_count else 0
84
+ return 0
85
+
86
+
87
+ def render_audit_text(result: AuditResult) -> str:
88
+ """Render an audit for a human."""
89
+ lines = [f"{result.path}"]
90
+ lines.append(
91
+ f" {len(result.sheets)} sheet(s), {result.cell_count} cell(s), "
92
+ f"{result.formula_count} formula(s)"
93
+ )
94
+ if result.defined_names:
95
+ lines.append(f" {len(result.defined_names)} defined name(s)")
96
+
97
+ if result.is_sound:
98
+ lines.append("")
99
+ lines.append("No broken references, no circular references.")
100
+ return "\n".join(lines)
101
+
102
+ if result.broken:
103
+ lines.append("")
104
+ lines.append(f"{len(result.broken)} broken reference(s):")
105
+ for broken in result.broken:
106
+ lines.append(f" !! {broken.ref.a1:<6} {broken.target}")
107
+ lines.append(f" {broken.reason}")
108
+ lines.append(f" ={broken.formula}")
109
+
110
+ if result.cycles:
111
+ lines.append("")
112
+ lines.append(f"{len(result.cycles)} circular reference(s):")
113
+ for cycle in result.cycles:
114
+ lines.append(f" !! {cycle.display}")
115
+
116
+ return "\n".join(lines)
117
+
118
+
119
+ def render_audit_json(result: AuditResult) -> str:
120
+ return json.dumps(
121
+ {
122
+ "path": result.path,
123
+ "sheets": result.sheets,
124
+ "cells": result.cell_count,
125
+ "formulas": result.formula_count,
126
+ "sound": result.is_sound,
127
+ "broken": [
128
+ {
129
+ "cell": str(b.ref),
130
+ "target": b.target,
131
+ "reason": b.reason,
132
+ "formula": f"={b.formula}",
133
+ }
134
+ for b in result.broken
135
+ ],
136
+ "cycles": [[str(ref) for ref in cycle.cells] for cycle in result.cycles],
137
+ },
138
+ indent=2,
139
+ )
140
+