sheetwright 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.
Files changed (65) hide show
  1. sheetwright/__init__.py +3 -0
  2. sheetwright/build_hash.py +41 -0
  3. sheetwright/bulk.py +82 -0
  4. sheetwright/calc/__init__.py +20 -0
  5. sheetwright/calc/base.py +22 -0
  6. sheetwright/calc/cache.py +59 -0
  7. sheetwright/calc/libreoffice.py +112 -0
  8. sheetwright/cli.py +230 -0
  9. sheetwright/commands/__init__.py +0 -0
  10. sheetwright/commands/build_cmd.py +44 -0
  11. sheetwright/commands/check_cmd.py +29 -0
  12. sheetwright/commands/diff_cmd.py +29 -0
  13. sheetwright/commands/import_cmd.py +96 -0
  14. sheetwright/commands/init_cmd.py +52 -0
  15. sheetwright/commands/mcp_cmd.py +10 -0
  16. sheetwright/commands/recalc_cmd.py +52 -0
  17. sheetwright/commands/snapshot_cmd.py +74 -0
  18. sheetwright/commands/test_cmd.py +133 -0
  19. sheetwright/config.py +111 -0
  20. sheetwright/diff/__init__.py +23 -0
  21. sheetwright/diff/check.py +177 -0
  22. sheetwright/diff/compute.py +165 -0
  23. sheetwright/diff/format.py +79 -0
  24. sheetwright/diff/loaders.py +77 -0
  25. sheetwright/diff/model.py +105 -0
  26. sheetwright/exceptions.py +45 -0
  27. sheetwright/external_edit.py +44 -0
  28. sheetwright/gitutil.py +28 -0
  29. sheetwright/mcp/__init__.py +8 -0
  30. sheetwright/mcp/errors.py +42 -0
  31. sheetwright/mcp/server.py +359 -0
  32. sheetwright/mcp/shaping.py +86 -0
  33. sheetwright/model/__init__.py +0 -0
  34. sheetwright/model/cell.py +36 -0
  35. sheetwright/model/comment.py +11 -0
  36. sheetwright/model/conditional.py +116 -0
  37. sheetwright/model/format.py +45 -0
  38. sheetwright/model/table.py +27 -0
  39. sheetwright/model/validation.py +25 -0
  40. sheetwright/model/workbook.py +66 -0
  41. sheetwright/project.py +83 -0
  42. sheetwright/reimport/__init__.py +31 -0
  43. sheetwright/reimport/flow.py +239 -0
  44. sheetwright/reimport/session.py +60 -0
  45. sheetwright/security.py +196 -0
  46. sheetwright/snapshot.py +88 -0
  47. sheetwright/source/__init__.py +0 -0
  48. sheetwright/source/markdown.py +169 -0
  49. sheetwright/source/reader.py +32 -0
  50. sheetwright/source/writer.py +43 -0
  51. sheetwright/source/yaml_sidecar.py +408 -0
  52. sheetwright/testing/__init__.py +6 -0
  53. sheetwright/testing/addresses.py +44 -0
  54. sheetwright/testing/model.py +79 -0
  55. sheetwright/xlsx/__init__.py +0 -0
  56. sheetwright/xlsx/cf_translate.py +219 -0
  57. sheetwright/xlsx/flatten.py +96 -0
  58. sheetwright/xlsx/reader.py +246 -0
  59. sheetwright/xlsx/safe_load.py +88 -0
  60. sheetwright/xlsx/writer.py +238 -0
  61. sheetwright-0.1.0.dist-info/METADATA +92 -0
  62. sheetwright-0.1.0.dist-info/RECORD +65 -0
  63. sheetwright-0.1.0.dist-info/WHEEL +4 -0
  64. sheetwright-0.1.0.dist-info/entry_points.txt +2 -0
  65. sheetwright-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,3 @@
1
+ """sheetwright — work with spreadsheets from Claude Code."""
2
+
3
+ __version__ = '0.0.0'
@@ -0,0 +1,41 @@
1
+ """Record and read the SHA-256 of the most recent built xlsx.
2
+
3
+ Used by the escape-hatch detection in `import`/`build`/`recalc` to
4
+ warn when the built xlsx has been edited externally.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class BuildHashRecord:
17
+ name: str
18
+ sha256: str
19
+ built_at: str # ISO-8601 timestamp
20
+
21
+
22
+ def write_build_hash(path: Path, rec: BuildHashRecord) -> None:
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ path.write_text(json.dumps(asdict(rec), indent=2, sort_keys=True))
25
+
26
+
27
+ def read_build_hash(path: Path) -> Optional[BuildHashRecord]:
28
+ if not path.is_file():
29
+ return None
30
+ try:
31
+ data = json.loads(path.read_text())
32
+ except json.JSONDecodeError:
33
+ return None
34
+ try:
35
+ return BuildHashRecord(
36
+ name=data['name'],
37
+ sha256=data['sha256'],
38
+ built_at=data['built_at'],
39
+ )
40
+ except (KeyError, TypeError):
41
+ return None
sheetwright/bulk.py ADDED
@@ -0,0 +1,82 @@
1
+ """Build the cached SQLite from data/*.csv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import re
7
+ import sqlite3
8
+ from pathlib import Path
9
+ from typing import List
10
+
11
+ from sheetwright.exceptions import BulkInvalidIdentifierError
12
+
13
+ _IDENT_RE = re.compile(r'[A-Za-z_][A-Za-z0-9_]{0,62}')
14
+
15
+
16
+ def _validate_identifier(name: str) -> str:
17
+ if not _IDENT_RE.fullmatch(name):
18
+ raise BulkInvalidIdentifierError(
19
+ f'invalid SQL identifier: {name!r} '
20
+ '(must match [A-Za-z_][A-Za-z0-9_]{0,62})'
21
+ )
22
+ return name
23
+
24
+
25
+ def table_name_for(filename: str) -> str:
26
+ stem = Path(filename).stem
27
+ s = re.sub(r'[^A-Za-z0-9]+', '_', stem).strip('_').lower()
28
+ return s or '_unnamed'
29
+
30
+
31
+ def _csvs_newer_than(csv_paths: List[Path], db_path: Path) -> bool:
32
+ if not db_path.exists():
33
+ return True
34
+ db_mtime = db_path.stat().st_mtime
35
+ return any(p.stat().st_mtime > db_mtime for p in csv_paths)
36
+
37
+
38
+ def build_bulk_cache(project_root: Path) -> Path:
39
+ project_root = Path(project_root)
40
+ data_dir = project_root / 'data'
41
+ cache_dir = project_root / '.sheetwright'
42
+ cache_dir.mkdir(parents=True, exist_ok=True)
43
+ db_path = cache_dir / 'bulk.sqlite'
44
+
45
+ csvs = sorted(data_dir.glob('*.csv')) if data_dir.is_dir() else []
46
+ if not csvs:
47
+ # No CSVs: ensure no stale db
48
+ if db_path.exists():
49
+ db_path.unlink()
50
+ return db_path
51
+
52
+ if not _csvs_newer_than(csvs, db_path):
53
+ return db_path
54
+
55
+ # Rebuild from scratch (deterministic).
56
+ if db_path.exists():
57
+ db_path.unlink()
58
+
59
+ conn = sqlite3.connect(db_path)
60
+ try:
61
+ for csv_path in csvs:
62
+ table = _validate_identifier(table_name_for(csv_path.name))
63
+ with csv_path.open() as f:
64
+ reader = csv.reader(f)
65
+ header = next(reader, None)
66
+ if not header:
67
+ continue
68
+ cols = [_validate_identifier(c) for c in header]
69
+ # All columns typed TEXT in Plan 1; _schema.sql support
70
+ # is deferred to a later plan.
71
+ cols_sql = ', '.join(f'"{c}" TEXT' for c in cols)
72
+ conn.execute(f'CREATE TABLE "{table}" ({cols_sql})')
73
+ placeholders = ', '.join(['?'] * len(header))
74
+ conn.executemany(
75
+ f'INSERT INTO "{table}" VALUES ({placeholders})',
76
+ list(reader),
77
+ )
78
+ conn.commit()
79
+ finally:
80
+ conn.close()
81
+
82
+ return db_path
@@ -0,0 +1,20 @@
1
+ """Calc engine plugin layer.
2
+
3
+ A calc engine evaluates a built `.xlsx` and returns the calculated
4
+ values for every cell. The default engine is LibreOffice headless.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from sheetwright.calc.base import CalcEngine, CalcResult
10
+
11
+
12
+ def get_calc_engine(name: str) -> CalcEngine:
13
+ if name == 'libreoffice':
14
+ from sheetwright.calc.libreoffice import LibreOfficeEngine
15
+
16
+ return LibreOfficeEngine()
17
+ raise ValueError(f'unknown calc engine: {name!r}')
18
+
19
+
20
+ __all__ = ['CalcEngine', 'CalcResult', 'get_calc_engine']
@@ -0,0 +1,22 @@
1
+ """CalcEngine ABC and the registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+ from typing import Dict
8
+
9
+ from sheetwright.model.cell import CellValue
10
+ from sheetwright.security import SecurityLimits
11
+
12
+ CalcResult = Dict[str, Dict[str, CellValue]]
13
+
14
+
15
+ class CalcEngine(ABC):
16
+ """Abstract calc engine: evaluate a built .xlsx and return values."""
17
+
18
+ @abstractmethod
19
+ def evaluate(
20
+ self, xlsx_path: Path, *, limits: SecurityLimits
21
+ ) -> CalcResult:
22
+ """Return calculated values keyed by sheet, then by A1 address."""
@@ -0,0 +1,59 @@
1
+ """Content-addressed calc cache.
2
+
3
+ The cache key is the SHA-256 of the built xlsx bytes. Hits avoid
4
+ running the calc engine entirely.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from sheetwright.calc.base import CalcResult
16
+
17
+
18
+ def hash_xlsx(xlsx_path: Path) -> str:
19
+ h = hashlib.sha256()
20
+ with open(xlsx_path, 'rb') as f:
21
+ for chunk in iter(lambda: f.read(65536), b''):
22
+ h.update(chunk)
23
+ return h.hexdigest()
24
+
25
+
26
+ def cache_path_for(cache_dir: Path, key: str) -> Path:
27
+ return cache_dir / f'{key}.json'
28
+
29
+
30
+ def read_cached(cache_dir: Path, key: str) -> Optional[CalcResult]:
31
+ p = cache_path_for(cache_dir, key)
32
+ if not p.is_file():
33
+ return None
34
+ data = json.loads(p.read_text())
35
+ return data['result']
36
+
37
+
38
+ def write_cached(cache_dir: Path, key: str, result: CalcResult) -> Path:
39
+ cache_dir.mkdir(parents=True, exist_ok=True)
40
+ p = cache_path_for(cache_dir, key)
41
+ p.write_text(
42
+ json.dumps(
43
+ {
44
+ 'key': key,
45
+ 'computed_at': datetime.now(timezone.utc).isoformat(),
46
+ 'result': result,
47
+ },
48
+ indent=2,
49
+ sort_keys=True,
50
+ default=_json_default,
51
+ )
52
+ )
53
+ return p
54
+
55
+
56
+ def _json_default(obj: object) -> object:
57
+ if isinstance(obj, datetime):
58
+ return obj.isoformat() + 'Z'
59
+ raise TypeError(f'not JSON serializable: {type(obj).__name__}')
@@ -0,0 +1,112 @@
1
+ """LibreOffice headless calc engine.
2
+
3
+ Strategy: copy the input xlsx into a fresh temp dir, run
4
+ `soffice --headless --calc --convert-to xlsx --outdir <tmp> <input>`
5
+ which forces LibreOffice to recalculate every formula and persist
6
+ cached values, then load the converted file with openpyxl in
7
+ `data_only=True` mode and read every cell.
8
+
9
+ We use `-env:UserInstallation=file://<tmp>/profile` to give each run
10
+ its own profile directory so concurrent invocations do not block each
11
+ other on LibreOffice's user-profile lock.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ import tempfile
18
+ from pathlib import Path
19
+ from typing import Dict, cast
20
+
21
+ from sheetwright.calc.base import CalcEngine, CalcResult
22
+ from sheetwright.model.cell import CellValue
23
+ from sheetwright.security import SecurityLimits
24
+ from sheetwright.xlsx.safe_load import safe_load_workbook
25
+
26
+
27
+ class LibreOfficeError(RuntimeError):
28
+ """soffice exited non-zero or produced no output."""
29
+
30
+
31
+ class LibreOfficeEngine(CalcEngine):
32
+ def __init__(self, soffice: str = 'soffice'):
33
+ self.soffice = soffice
34
+
35
+ def evaluate(
36
+ self, xlsx_path: Path, *, limits: SecurityLimits
37
+ ) -> CalcResult:
38
+ xlsx_path = Path(xlsx_path).resolve()
39
+ with tempfile.TemporaryDirectory(prefix='sheetwright-calc-') as td_str:
40
+ td = Path(td_str)
41
+ profile = td / 'profile'
42
+ outdir = td / 'out'
43
+ outdir.mkdir()
44
+
45
+ cmd = [
46
+ self.soffice,
47
+ '--headless',
48
+ '--safe-mode',
49
+ '--norestore',
50
+ '--nolockcheck',
51
+ '--nofirststartwizard',
52
+ '--nodefault',
53
+ '--calc',
54
+ f'-env:UserInstallation={profile.as_uri()}',
55
+ '--convert-to',
56
+ 'xlsx',
57
+ '--outdir',
58
+ str(outdir),
59
+ str(xlsx_path),
60
+ ]
61
+ timeout = limits.soffice_timeout
62
+ try:
63
+ proc = subprocess.run(
64
+ cmd,
65
+ capture_output=True,
66
+ timeout=timeout,
67
+ check=False,
68
+ )
69
+ except FileNotFoundError as e:
70
+ raise LibreOfficeError(
71
+ f'{self.soffice}: not on $PATH or not executable'
72
+ ) from e
73
+ except subprocess.TimeoutExpired as e:
74
+ raise LibreOfficeError(
75
+ f'{self.soffice} timed out after {timeout}s'
76
+ ) from e
77
+
78
+ if proc.returncode != 0:
79
+ stderr = proc.stderr.decode('utf-8', 'replace').strip()
80
+ stdout = proc.stdout.decode('utf-8', 'replace').strip()
81
+ detail = stderr or stdout or '(no output)'
82
+ raise LibreOfficeError(
83
+ f'soffice exited {proc.returncode}: {detail}'
84
+ )
85
+
86
+ converted = outdir / xlsx_path.name
87
+ if not converted.is_file():
88
+ candidates = list(outdir.glob('*.xlsx'))
89
+ if len(candidates) != 1:
90
+ raise LibreOfficeError(
91
+ f'expected 1 xlsx in {outdir}, found {candidates}'
92
+ )
93
+ converted = candidates[0]
94
+
95
+ return _read_calculated(converted, limits)
96
+
97
+
98
+ def _read_calculated(xlsx_path: Path, limits: SecurityLimits) -> CalcResult:
99
+ wb = safe_load_workbook(xlsx_path, limits, data_only=True, read_only=True)
100
+ try:
101
+ out: CalcResult = {}
102
+ for ws in wb.worksheets:
103
+ sheet: Dict[str, CellValue] = {}
104
+ for row in ws.iter_rows():
105
+ for cell in row:
106
+ if cell.value is None:
107
+ continue
108
+ sheet[cell.coordinate] = cast(CellValue, cell.value)
109
+ out[ws.title] = sheet
110
+ return out
111
+ finally:
112
+ wb.close()
sheetwright/cli.py ADDED
@@ -0,0 +1,230 @@
1
+ """sheetwright CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Optional
7
+
8
+ import click
9
+
10
+
11
+ @click.group(context_settings={'help_option_names': ['-h', '--help']})
12
+ @click.version_option(package_name='sheetwright')
13
+ def main() -> None:
14
+ """Work with spreadsheets from Claude Code."""
15
+
16
+
17
+ @main.command('init')
18
+ @click.argument('path', type=click.Path(file_okay=False), default='.')
19
+ def init_cmd(path: str) -> None:
20
+ """Scaffold an empty sheetwright project."""
21
+ from sheetwright.commands.init_cmd import run
22
+
23
+ run(path)
24
+
25
+
26
+ @main.command('import')
27
+ @click.argument(
28
+ 'xlsx',
29
+ type=click.Path(exists=True, dir_okay=False),
30
+ required=False,
31
+ )
32
+ @click.option('--archive', is_flag=True, help='Copy the xlsx into imports/.')
33
+ @click.option(
34
+ '--flatten',
35
+ is_flag=True,
36
+ help='Replace external-reference formulas with cached values.',
37
+ )
38
+ @click.option(
39
+ '-I',
40
+ '--non-interactive',
41
+ 'non_interactive',
42
+ is_flag=True,
43
+ help='Print the diff and exit; use --apply or --abort to follow up.',
44
+ )
45
+ @click.option(
46
+ '--apply',
47
+ is_flag=True,
48
+ help='Apply a previously staged re-import.',
49
+ )
50
+ @click.option(
51
+ '--abort',
52
+ is_flag=True,
53
+ help='Discard a previously staged re-import.',
54
+ )
55
+ @click.option(
56
+ '--force',
57
+ is_flag=True,
58
+ help='Skip the uncommitted-source guard during re-import.',
59
+ )
60
+ @click.option(
61
+ '--project',
62
+ 'project_path',
63
+ type=click.Path(file_okay=False),
64
+ default='.',
65
+ help='Path to the sheetwright project.',
66
+ )
67
+ def import_cmd(
68
+ xlsx: Optional[str],
69
+ archive: bool,
70
+ flatten: bool,
71
+ non_interactive: bool,
72
+ apply: bool,
73
+ abort: bool,
74
+ force: bool,
75
+ project_path: str,
76
+ ) -> None:
77
+ """Read an .xlsx file into source form, or merge updates into an
78
+ existing project."""
79
+ from sheetwright.commands.import_cmd import run
80
+
81
+ run(
82
+ xlsx_path=xlsx,
83
+ project_path=project_path,
84
+ archive=archive,
85
+ flatten=flatten,
86
+ non_interactive=non_interactive,
87
+ apply=apply,
88
+ abort=abort,
89
+ force=force,
90
+ )
91
+
92
+
93
+ @main.command('build')
94
+ @click.option(
95
+ '--out',
96
+ 'out_path',
97
+ type=click.Path(dir_okay=False),
98
+ default=None,
99
+ help='Output path. Defaults to build/<workbook-name>.xlsx.',
100
+ )
101
+ @click.option(
102
+ '--project',
103
+ 'project_path',
104
+ type=click.Path(file_okay=False),
105
+ default='.',
106
+ help='Path to the sheetwright project.',
107
+ )
108
+ def build_cmd(out_path: str | None, project_path: str) -> None:
109
+ """Compile source files into an .xlsx."""
110
+ from sheetwright.commands.build_cmd import run
111
+
112
+ run(project_path=project_path, out_path=out_path)
113
+
114
+
115
+ @main.command('recalc')
116
+ @click.option(
117
+ '--project',
118
+ 'project_path',
119
+ type=click.Path(file_okay=False),
120
+ default='.',
121
+ help='Path to the sheetwright project.',
122
+ )
123
+ @click.option(
124
+ '--force',
125
+ is_flag=True,
126
+ help='Ignore the cache and re-run the calc engine.',
127
+ )
128
+ def recalc_cmd(project_path: str, force: bool) -> None:
129
+ """Run the calc engine and cache calculated values."""
130
+ from sheetwright.commands.recalc_cmd import run
131
+
132
+ run(project_path=project_path, force=force)
133
+
134
+
135
+ @main.command('snapshot')
136
+ @click.option(
137
+ '--project',
138
+ 'project_path',
139
+ type=click.Path(file_okay=False),
140
+ default='.',
141
+ help='Path to the sheetwright project.',
142
+ )
143
+ @click.option(
144
+ '--update',
145
+ is_flag=True,
146
+ help='Overwrite the saved snapshot with the current calculated values.',
147
+ )
148
+ def snapshot_cmd(project_path: str, update: bool) -> None:
149
+ """Compare or update the golden-file snapshot of calculated values."""
150
+ from sheetwright.commands.snapshot_cmd import run
151
+
152
+ run(project_path=project_path, update=update)
153
+
154
+
155
+ @main.command('diff')
156
+ @click.option(
157
+ '--project',
158
+ 'project_path',
159
+ type=click.Path(file_okay=False),
160
+ default='.',
161
+ help='Path to the sheetwright project.',
162
+ )
163
+ @click.option(
164
+ '--vs',
165
+ 'vs',
166
+ type=str,
167
+ default=None,
168
+ help='Comparison target: "xlsx:<path>" or "source:<path>". '
169
+ 'Default: build/<name>.xlsx of this project.',
170
+ )
171
+ def diff_cmd(project_path: str, vs: Optional[str]) -> None:
172
+ """Show a semantic diff between source and a target workbook."""
173
+ from sheetwright.commands.diff_cmd import run
174
+
175
+ run(project_path=project_path, vs=vs)
176
+
177
+
178
+ @main.command('check')
179
+ @click.option(
180
+ '--project',
181
+ 'project_path',
182
+ type=click.Path(file_okay=False),
183
+ default='.',
184
+ help='Path to the sheetwright project.',
185
+ )
186
+ def check_cmd(project_path: str) -> None:
187
+ """Lint dangling refs, missing names, schema mismatches."""
188
+ from sheetwright.commands.check_cmd import run
189
+
190
+ run(project_path=project_path)
191
+
192
+
193
+ @main.command(
194
+ 'test',
195
+ context_settings={'ignore_unknown_options': True},
196
+ )
197
+ @click.option(
198
+ '--project',
199
+ 'project_path',
200
+ type=click.Path(file_okay=False),
201
+ default='.',
202
+ help='Path to the sheetwright project.',
203
+ )
204
+ @click.argument('targets', nargs=-1, type=click.UNPROCESSED)
205
+ def test_cmd(project_path: str, targets: tuple[str, ...]) -> None:
206
+ """Run the project's testsweet tests."""
207
+ from sheetwright.commands.test_cmd import run
208
+ from sheetwright.security import PathOutsideProjectError
209
+
210
+ try:
211
+ run(project_path=project_path, targets=list(targets))
212
+ except PathOutsideProjectError as e:
213
+ raise click.ClickException(str(e))
214
+
215
+
216
+ @main.command('mcp')
217
+ def mcp_cmd() -> None:
218
+ """Run the MCP server on stdio.
219
+
220
+ Most MCP clients launch this subcommand as a subprocess and
221
+ communicate via stdin/stdout. The server stays alive until the
222
+ client closes the connection.
223
+ """
224
+ from sheetwright.commands.mcp_cmd import run
225
+
226
+ run()
227
+
228
+
229
+ if __name__ == '__main__': # pragma: no cover
230
+ sys.exit(main())
File without changes
@@ -0,0 +1,44 @@
1
+ """Implementation of `sheetwright build`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from sheetwright.build_hash import BuildHashRecord, write_build_hash
11
+ from sheetwright.bulk import build_bulk_cache
12
+ from sheetwright.calc.cache import hash_xlsx
13
+ from sheetwright.exceptions import ProjectError
14
+ from sheetwright.project import Project
15
+ from sheetwright.source.reader import read_source
16
+ from sheetwright.xlsx.writer import write_xlsx
17
+
18
+
19
+ def run(*, project_path: str, out_path: str | None) -> None:
20
+ try:
21
+ project = Project.open(project_path)
22
+ except ProjectError as e:
23
+ raise click.ClickException(str(e))
24
+
25
+ cfg = project.config
26
+
27
+ wb = read_source(project.root)
28
+ build_bulk_cache(project.root)
29
+
30
+ project.build_dir.mkdir(parents=True, exist_ok=True)
31
+ target = (
32
+ Path(out_path) if out_path else project.build_dir / f'{cfg.name}.xlsx'
33
+ )
34
+ write_xlsx(wb, target)
35
+ write_build_hash(
36
+ project.build_hash_path,
37
+ BuildHashRecord(
38
+ name=cfg.name,
39
+ sha256=hash_xlsx(target),
40
+ built_at=datetime.now(timezone.utc).isoformat(),
41
+ ),
42
+ )
43
+
44
+ click.echo(f'Built {target}')
@@ -0,0 +1,29 @@
1
+ """Implementation of `sheetwright check`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from sheetwright.diff.check import check_workbook
8
+ from sheetwright.exceptions import ProjectError
9
+ from sheetwright.project import Project
10
+ from sheetwright.source.reader import read_source
11
+
12
+
13
+ def run(*, project_path: str) -> None:
14
+ try:
15
+ project = Project.open(project_path)
16
+ except ProjectError as e:
17
+ raise click.ClickException(str(e))
18
+
19
+ wb = read_source(project.root)
20
+ issues = check_workbook(wb, project)
21
+
22
+ if not issues:
23
+ click.echo('no issues')
24
+ return
25
+
26
+ for issue in issues:
27
+ loc = f' at {issue.location}' if issue.location else ''
28
+ click.echo(f' [{issue.kind}]{loc}: {issue.detail}')
29
+ raise click.exceptions.Exit(1)
@@ -0,0 +1,29 @@
1
+ """Implementation of `sheetwright diff`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import click
8
+
9
+ from sheetwright.diff import diff_workbooks
10
+ from sheetwright.diff.format import render
11
+ from sheetwright.diff.loaders import load_target
12
+ from sheetwright.exceptions import ProjectError
13
+ from sheetwright.project import Project
14
+ from sheetwright.source.reader import read_source
15
+
16
+
17
+ def run(*, project_path: str, vs: Optional[str]) -> None:
18
+ try:
19
+ project = Project.open(project_path)
20
+ except ProjectError as e:
21
+ raise click.ClickException(str(e))
22
+
23
+ source_wb = read_source(project.root)
24
+ target_wb = load_target(project, vs)
25
+
26
+ d = diff_workbooks(target_wb, source_wb)
27
+ click.echo(render(d))
28
+ if not d.is_empty():
29
+ raise click.exceptions.Exit(1)