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.
- sheetwright/__init__.py +3 -0
- sheetwright/build_hash.py +41 -0
- sheetwright/bulk.py +82 -0
- sheetwright/calc/__init__.py +20 -0
- sheetwright/calc/base.py +22 -0
- sheetwright/calc/cache.py +59 -0
- sheetwright/calc/libreoffice.py +112 -0
- sheetwright/cli.py +230 -0
- sheetwright/commands/__init__.py +0 -0
- sheetwright/commands/build_cmd.py +44 -0
- sheetwright/commands/check_cmd.py +29 -0
- sheetwright/commands/diff_cmd.py +29 -0
- sheetwright/commands/import_cmd.py +96 -0
- sheetwright/commands/init_cmd.py +52 -0
- sheetwright/commands/mcp_cmd.py +10 -0
- sheetwright/commands/recalc_cmd.py +52 -0
- sheetwright/commands/snapshot_cmd.py +74 -0
- sheetwright/commands/test_cmd.py +133 -0
- sheetwright/config.py +111 -0
- sheetwright/diff/__init__.py +23 -0
- sheetwright/diff/check.py +177 -0
- sheetwright/diff/compute.py +165 -0
- sheetwright/diff/format.py +79 -0
- sheetwright/diff/loaders.py +77 -0
- sheetwright/diff/model.py +105 -0
- sheetwright/exceptions.py +45 -0
- sheetwright/external_edit.py +44 -0
- sheetwright/gitutil.py +28 -0
- sheetwright/mcp/__init__.py +8 -0
- sheetwright/mcp/errors.py +42 -0
- sheetwright/mcp/server.py +359 -0
- sheetwright/mcp/shaping.py +86 -0
- sheetwright/model/__init__.py +0 -0
- sheetwright/model/cell.py +36 -0
- sheetwright/model/comment.py +11 -0
- sheetwright/model/conditional.py +116 -0
- sheetwright/model/format.py +45 -0
- sheetwright/model/table.py +27 -0
- sheetwright/model/validation.py +25 -0
- sheetwright/model/workbook.py +66 -0
- sheetwright/project.py +83 -0
- sheetwright/reimport/__init__.py +31 -0
- sheetwright/reimport/flow.py +239 -0
- sheetwright/reimport/session.py +60 -0
- sheetwright/security.py +196 -0
- sheetwright/snapshot.py +88 -0
- sheetwright/source/__init__.py +0 -0
- sheetwright/source/markdown.py +169 -0
- sheetwright/source/reader.py +32 -0
- sheetwright/source/writer.py +43 -0
- sheetwright/source/yaml_sidecar.py +408 -0
- sheetwright/testing/__init__.py +6 -0
- sheetwright/testing/addresses.py +44 -0
- sheetwright/testing/model.py +79 -0
- sheetwright/xlsx/__init__.py +0 -0
- sheetwright/xlsx/cf_translate.py +219 -0
- sheetwright/xlsx/flatten.py +96 -0
- sheetwright/xlsx/reader.py +246 -0
- sheetwright/xlsx/safe_load.py +88 -0
- sheetwright/xlsx/writer.py +238 -0
- sheetwright-0.1.0.dist-info/METADATA +92 -0
- sheetwright-0.1.0.dist-info/RECORD +65 -0
- sheetwright-0.1.0.dist-info/WHEEL +4 -0
- sheetwright-0.1.0.dist-info/entry_points.txt +2 -0
- sheetwright-0.1.0.dist-info/licenses/LICENSE +674 -0
sheetwright/__init__.py
ADDED
|
@@ -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']
|
sheetwright/calc/base.py
ADDED
|
@@ -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)
|