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
|
@@ -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,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
|
+
]
|