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,96 @@
1
+ """Implementation of `sheetwright import`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import click
9
+
10
+ from sheetwright.exceptions import ProjectError, StaleSessionFormatError
11
+ from sheetwright.project import Project
12
+ from sheetwright.reimport import archive_xlsx
13
+ from sheetwright.security import SecurityLimits, get_operator_limits
14
+ from sheetwright.source.writer import write_source
15
+ from sheetwright.xlsx.flatten import (
16
+ detect_external_refs,
17
+ flatten_external_refs,
18
+ )
19
+ from sheetwright.xlsx.reader import read_xlsx
20
+
21
+
22
+ def run(
23
+ *,
24
+ xlsx_path: Optional[str],
25
+ project_path: str,
26
+ archive: bool,
27
+ flatten: bool,
28
+ non_interactive: bool,
29
+ apply: bool,
30
+ abort: bool,
31
+ force: bool,
32
+ ) -> None:
33
+ project_root = Path(project_path).resolve()
34
+
35
+ try:
36
+ project = Project.open(project_root)
37
+ except ProjectError as e:
38
+ raise click.ClickException(str(e))
39
+
40
+ if abort:
41
+ from sheetwright.reimport import clear_session
42
+
43
+ clear_session(project.reimport_session_path)
44
+ click.echo('Session cleared.')
45
+ return
46
+
47
+ if apply:
48
+ from sheetwright.reimport import apply_session
49
+
50
+ try:
51
+ apply_session(project, archive=archive, flatten=flatten)
52
+ except StaleSessionFormatError as e:
53
+ raise click.ClickException(str(e))
54
+ return
55
+
56
+ if xlsx_path is None:
57
+ raise click.ClickException(
58
+ 'Missing XLSX argument. Pass a path, or use --apply / --abort '
59
+ 'to act on a staged session.'
60
+ )
61
+ xlsx = Path(xlsx_path).resolve()
62
+
63
+ if project.has_source():
64
+ from sheetwright.reimport import do_reimport
65
+
66
+ do_reimport(
67
+ project,
68
+ xlsx,
69
+ archive=archive,
70
+ flatten=flatten,
71
+ non_interactive=non_interactive,
72
+ force=force,
73
+ )
74
+ return
75
+
76
+ limits = SecurityLimits.effective(
77
+ get_operator_limits(), project.config.security
78
+ )
79
+ extrefs = detect_external_refs(xlsx, limits=limits)
80
+ if extrefs and not flatten:
81
+ raise click.ClickException(
82
+ 'Workbook contains external references; '
83
+ 'pass --flatten to replace them with cached values, '
84
+ 'or resolve them in Excel before importing.\n'
85
+ 'First few: ' + ', '.join(extrefs[:3])
86
+ )
87
+
88
+ wb = read_xlsx(xlsx, limits=limits)
89
+ if flatten:
90
+ flatten_external_refs(wb, xlsx, limits=limits)
91
+ write_source(wb, project_root)
92
+
93
+ if archive:
94
+ archive_xlsx(xlsx, project_root)
95
+
96
+ click.echo(f'Imported {xlsx} into {project_root}')
@@ -0,0 +1,52 @@
1
+ """Implementation of `sheetwright init`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ DEFAULT_SHEETWRIGHT_TOML = """\
10
+ [project]
11
+ name = "my-model"
12
+
13
+ [build]
14
+ calc_engine = "libreoffice"
15
+ """
16
+
17
+ DEFAULT_WORKBOOK_TOML = """\
18
+ [workbook]
19
+ name = "my-model"
20
+
21
+ # Sheets are listed in workbook order. Each entry must match a file in sheets/
22
+ # (without the .md/.yaml extension).
23
+ sheets = []
24
+
25
+ # Workbook-scoped named ranges:
26
+ # [[named_ranges]]
27
+ # name = "growth_rate"
28
+ # scope = "workbook"
29
+ # ref = "Assumptions!B5"
30
+ """
31
+
32
+ DEFAULT_GITIGNORE = """\
33
+ build/
34
+ .sheetwright/
35
+ """
36
+
37
+
38
+ def run(path: str) -> None:
39
+ project = Path(path).resolve()
40
+ if project.exists() and any(project.iterdir()):
41
+ raise click.ClickException(f'{project} is not empty.')
42
+
43
+ project.mkdir(parents=True, exist_ok=True)
44
+ (project / 'sheets').mkdir()
45
+ (project / 'data').mkdir()
46
+ (project / 'tests').mkdir()
47
+ (project / 'tests' / '__init__.py').write_text('')
48
+ (project / 'sheetwright.toml').write_text(DEFAULT_SHEETWRIGHT_TOML)
49
+ (project / 'workbook.toml').write_text(DEFAULT_WORKBOOK_TOML)
50
+ (project / '.gitignore').write_text(DEFAULT_GITIGNORE)
51
+
52
+ click.echo(f'Initialised sheetwright project at {project}')
@@ -0,0 +1,10 @@
1
+ """Implementation of `sheetwright mcp`."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def run() -> None:
7
+ from sheetwright.mcp import build_server
8
+
9
+ server = build_server()
10
+ server.run(transport='stdio')
@@ -0,0 +1,52 @@
1
+ """Implementation of `sheetwright recalc`."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ import click
7
+
8
+ from sheetwright.calc import get_calc_engine
9
+ from sheetwright.calc.cache import (
10
+ hash_xlsx,
11
+ read_cached,
12
+ write_cached,
13
+ )
14
+ from sheetwright.exceptions import ProjectError
15
+ from sheetwright.project import Project
16
+ from sheetwright.security import SecurityLimits, get_operator_limits
17
+
18
+
19
+ CACHE_HIT_MESSAGE = 'cache hit'
20
+
21
+
22
+ def run(*, project_path: str, force: bool) -> None:
23
+ try:
24
+ project = Project.open(project_path)
25
+ except ProjectError as e:
26
+ raise click.ClickException(str(e))
27
+
28
+ from sheetwright.external_edit import warn_if_externally_edited
29
+
30
+ warn_if_externally_edited(project)
31
+
32
+ cfg = project.config
33
+ built = project.build_dir / f'{cfg.name}.xlsx'
34
+ if not built.is_file():
35
+ raise click.ClickException(
36
+ f'No built xlsx at {built}. Run `sheetwright build` first.'
37
+ )
38
+
39
+ key = hash_xlsx(built)
40
+ if not force:
41
+ cached = read_cached(project.calc_cache_dir, key)
42
+ if cached is not None:
43
+ click.echo(f'{CACHE_HIT_MESSAGE}: {key[:12]}')
44
+ return
45
+
46
+ limits = SecurityLimits.effective(
47
+ get_operator_limits(), project.config.security
48
+ )
49
+ engine = get_calc_engine(cfg.calc_engine)
50
+ result = engine.evaluate(built, limits=limits)
51
+ path = write_cached(project.calc_cache_dir, key, result)
52
+ click.echo(f'recalculated: {path}')
@@ -0,0 +1,74 @@
1
+ """Implementation of `sheetwright snapshot`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from sheetwright.calc import get_calc_engine
8
+ from sheetwright.calc.cache import (
9
+ hash_xlsx,
10
+ read_cached,
11
+ write_cached,
12
+ )
13
+ from sheetwright.exceptions import ProjectError
14
+ from sheetwright.project import Project
15
+ from sheetwright.security import SecurityLimits, get_operator_limits
16
+ from sheetwright.snapshot import (
17
+ Snapshot,
18
+ diff_snapshots,
19
+ snapshot_from_calc_result,
20
+ )
21
+ from sheetwright.source.reader import read_source
22
+
23
+
24
+ def run(*, project_path: str, update: bool) -> None:
25
+ try:
26
+ project = Project.open(project_path)
27
+ except ProjectError as e:
28
+ raise click.ClickException(str(e))
29
+
30
+ from sheetwright.external_edit import warn_if_externally_edited
31
+
32
+ warn_if_externally_edited(project)
33
+
34
+ cfg = project.config
35
+ built = project.build_dir / f'{cfg.name}.xlsx'
36
+ if not built.is_file():
37
+ raise click.ClickException(
38
+ f'No built xlsx at {built}. Run `sheetwright build` first.'
39
+ )
40
+
41
+ limits = SecurityLimits.effective(
42
+ get_operator_limits(), project.config.security
43
+ )
44
+ key = hash_xlsx(built)
45
+ cached = read_cached(project.calc_cache_dir, key)
46
+ if cached is None:
47
+ cached = get_calc_engine(cfg.calc_engine).evaluate(
48
+ built, limits=limits
49
+ )
50
+ write_cached(project.calc_cache_dir, key, cached)
51
+
52
+ workbook = read_source(project.root)
53
+ current = snapshot_from_calc_result(cached, workbook)
54
+ snap_path = project.snapshots_dir / f'{cfg.name}.json'
55
+
56
+ if not snap_path.is_file():
57
+ current.write(snap_path)
58
+ click.echo(f'initialized snapshot at {snap_path}')
59
+ return
60
+
61
+ if update:
62
+ current.write(snap_path)
63
+ click.echo(f'updated snapshot at {snap_path}')
64
+ return
65
+
66
+ saved = Snapshot.read(snap_path)
67
+ diffs = diff_snapshots(saved, current)
68
+ if not diffs:
69
+ click.echo('no changes')
70
+ return
71
+
72
+ for sheet, addr, old, new in diffs:
73
+ click.echo(f' {sheet}!{addr}: {old!r} -> {new!r}')
74
+ raise click.exceptions.Exit(1)
@@ -0,0 +1,133 @@
1
+ """Implementation of `sheetwright test` (testsweet, in-process).
2
+
3
+ Uses testsweet's lower-level `discover` + `run` API directly so we
4
+ can run user tests without mutating the process's cwd. This matters
5
+ for long-running hosts (the MCP server in particular).
6
+
7
+ User projects lose support for `[tool.testsweet.discovery]` in their
8
+ `pyproject.toml` via this command — we walk `tests/test_*.py` (and
9
+ honour `targets` if given) and import each file ourselves. Users who
10
+ want `[tool.testsweet.discovery]` can `python -m testsweet` from
11
+ their project root directly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib.util
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Sequence
20
+
21
+ import click
22
+ from testsweet import (
23
+ Errored,
24
+ Failed,
25
+ Passed,
26
+ Skipped,
27
+ XFailed,
28
+ XPassed,
29
+ run as ts_run,
30
+ )
31
+
32
+ from sheetwright.exceptions import ProjectError
33
+ from sheetwright.project import Project
34
+ from sheetwright.security import PathOutsideProjectError, resolve_under
35
+
36
+
37
+ def run(*, project_path: str, targets: Sequence[str]) -> None:
38
+ try:
39
+ project = Project.open(project_path)
40
+ except ProjectError as e:
41
+ raise click.ClickException(str(e))
42
+
43
+ if not project.tests_dir.is_dir():
44
+ raise click.ClickException(f'no tests/ directory in {project.root}')
45
+
46
+ test_files = list(_resolve_targets(project, list(targets)))
47
+ if not test_files:
48
+ click.echo('no tests collected')
49
+ return
50
+
51
+ saved_path = list(sys.path)
52
+ saved_modules = {
53
+ name: mod
54
+ for name, mod in sys.modules.items()
55
+ if name == '_user_tests' or name.startswith('_user_tests.')
56
+ }
57
+ sys.path.insert(0, str(project.root))
58
+ try:
59
+ any_failure = False
60
+ for test_file in test_files:
61
+ module = _import_module(test_file)
62
+ for name, outcome in ts_run(module):
63
+ full = f'{test_file.relative_to(project.root)}::{name}'
64
+ match outcome:
65
+ case Passed():
66
+ click.echo(f'{full} ... ok')
67
+ case Skipped(reason=reason):
68
+ click.echo(f'{full} ... skipped: {reason}')
69
+ case XFailed(reason=reason):
70
+ click.echo(f'{full} ... xfail: {reason}')
71
+ case XPassed(reason=reason):
72
+ any_failure = True
73
+ click.echo(f'{full} ... XPASS: {reason}')
74
+ case Failed(exc=exc):
75
+ any_failure = True
76
+ click.echo(
77
+ f'{full} ... FAIL: {type(exc).__name__}: {exc}'
78
+ )
79
+ case Errored(exc=exc):
80
+ any_failure = True
81
+ click.echo(
82
+ f'{full} ... ERROR: {type(exc).__name__}: {exc}'
83
+ )
84
+ finally:
85
+ sys.path[:] = saved_path
86
+ # Restore any pre-existing _user_tests modules we may have
87
+ # shadowed; remove ones we created.
88
+ for name in [
89
+ n
90
+ for n in sys.modules
91
+ if n == '_user_tests' or n.startswith('_user_tests.')
92
+ ]:
93
+ del sys.modules[name]
94
+ sys.modules.update(saved_modules)
95
+
96
+ if any_failure:
97
+ raise click.exceptions.Exit(1)
98
+
99
+
100
+ def _resolve_targets(project: Project, targets: list[str]) -> list[Path]:
101
+ """Return the list of test files to run.
102
+
103
+ Targets are resolved relative to project.root (so users can pass
104
+ 'tests/test_foo.py'). The resolved path must fall under tests_dir.
105
+ """
106
+ if not targets:
107
+ return sorted(project.tests_dir.rglob('test_*.py'))
108
+ out = []
109
+ for t in targets:
110
+ p = resolve_under(project.root, t)
111
+ if not p.is_relative_to(project.tests_dir):
112
+ raise PathOutsideProjectError(
113
+ f'{t!r} resolves outside {project.tests_dir!r}'
114
+ )
115
+ if p.is_file():
116
+ out.append(p)
117
+ elif p.is_dir():
118
+ out.extend(sorted(p.rglob('test_*.py')))
119
+ else:
120
+ raise click.ClickException(f'no such target: {t}')
121
+ return out
122
+
123
+
124
+ def _import_module(path: Path):
125
+ """Import a test file under a stable namespace."""
126
+ name = '_user_tests.' + path.stem
127
+ spec = importlib.util.spec_from_file_location(name, path)
128
+ if spec is None or spec.loader is None:
129
+ raise click.ClickException(f'cannot import {path}')
130
+ module = importlib.util.module_from_spec(spec)
131
+ sys.modules[name] = module
132
+ spec.loader.exec_module(module)
133
+ return module
sheetwright/config.py ADDED
@@ -0,0 +1,111 @@
1
+ """Read/write sheetwright.toml and workbook.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import tomli_w
11
+
12
+ from sheetwright.exceptions import ProjectError
13
+ from sheetwright.model.workbook import NamedRange
14
+ from sheetwright.security import SecurityLimits
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ProjectConfig:
19
+ name: str
20
+ calc_engine: str = 'libreoffice'
21
+ security: Optional[SecurityLimits] = None
22
+
23
+
24
+ @dataclass
25
+ class WorkbookManifest:
26
+ name: str
27
+ sheets: List[str] = field(default_factory=list)
28
+ named_ranges: List[NamedRange] = field(default_factory=list)
29
+
30
+ def __eq__(self, other: object) -> bool:
31
+ if not isinstance(other, WorkbookManifest):
32
+ return NotImplemented
33
+ return (
34
+ self.name == other.name
35
+ and self.sheets == other.sheets
36
+ and len(self.named_ranges) == len(other.named_ranges)
37
+ and all(
38
+ a.name == b.name
39
+ and a.scope == b.scope
40
+ and a.sheet == b.sheet
41
+ and a.ref == b.ref
42
+ for a, b in zip(self.named_ranges, other.named_ranges)
43
+ )
44
+ )
45
+
46
+
47
+ def dump_project(cfg: ProjectConfig) -> str:
48
+ doc: Dict[str, Any] = {
49
+ 'project': {'name': cfg.name},
50
+ 'build': {'calc_engine': cfg.calc_engine},
51
+ }
52
+ if cfg.security is not None:
53
+ doc['security'] = dataclasses.asdict(cfg.security)
54
+ return tomli_w.dumps(doc)
55
+
56
+
57
+ def load_project(text: str) -> ProjectConfig:
58
+ data = tomllib.loads(text)
59
+ security: Optional[SecurityLimits] = None
60
+ if 'security' in data:
61
+ sec = data['security']
62
+ defaults = SecurityLimits.defaults()
63
+ known = {f.name for f in dataclasses.fields(SecurityLimits)}
64
+ unknown = set(sec) - known
65
+ if unknown:
66
+ raise ProjectError(
67
+ f'Unknown [security] keys in sheetwright.toml: {sorted(unknown)}'
68
+ )
69
+ security = dataclasses.replace(defaults, **sec)
70
+ return ProjectConfig(
71
+ name=data['project']['name'],
72
+ calc_engine=data.get('build', {}).get('calc_engine', 'libreoffice'),
73
+ security=security,
74
+ )
75
+
76
+
77
+ def dump_workbook(m: WorkbookManifest) -> str:
78
+ nrs = [
79
+ {
80
+ 'name': nr.name,
81
+ 'scope': nr.scope,
82
+ **({'sheet': nr.sheet} if nr.sheet else {}),
83
+ 'ref': nr.ref,
84
+ }
85
+ for nr in m.named_ranges
86
+ ]
87
+ doc: Dict[str, Any] = {
88
+ 'workbook': {'name': m.name, 'sheets': list(m.sheets)},
89
+ }
90
+ if nrs:
91
+ doc['named_ranges'] = nrs
92
+ return tomli_w.dumps(doc)
93
+
94
+
95
+ def load_workbook(text: str) -> WorkbookManifest:
96
+ data = tomllib.loads(text)
97
+ nrs = [
98
+ NamedRange(
99
+ name=d['name'],
100
+ ref=d['ref'],
101
+ scope=d.get('scope', 'workbook'),
102
+ sheet=d.get('sheet'),
103
+ )
104
+ for d in data.get('named_ranges', [])
105
+ ]
106
+ wb = data['workbook']
107
+ return WorkbookManifest(
108
+ name=wb['name'],
109
+ sheets=list(wb.get('sheets', [])),
110
+ named_ranges=nrs,
111
+ )
@@ -0,0 +1,23 @@
1
+ """Workbook diff: pure model + computation, no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sheetwright.diff.compute import diff_workbooks
6
+ from sheetwright.diff.model import (
7
+ CellChange,
8
+ FrozenPanesChange,
9
+ NamedRangeChange,
10
+ PrintAreaChange,
11
+ SheetDiff,
12
+ WorkbookDiff,
13
+ )
14
+
15
+ __all__ = [
16
+ 'CellChange',
17
+ 'FrozenPanesChange',
18
+ 'NamedRangeChange',
19
+ 'PrintAreaChange',
20
+ 'SheetDiff',
21
+ 'WorkbookDiff',
22
+ 'diff_workbooks',
23
+ ]