pacioliscube 0.1.2__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.
- pacioliscube/__init__.py +11 -0
- pacioliscube/cli.py +320 -0
- pacioliscube/data.py +177 -0
- pacioliscube/errors.py +31 -0
- pacioliscube/evaluate.py +360 -0
- pacioliscube/model.py +367 -0
- pacioliscube/report.py +160 -0
- pacioliscube/rules.py +364 -0
- pacioliscube/validate.py +375 -0
- pacioliscube-0.1.2.dist-info/METADATA +227 -0
- pacioliscube-0.1.2.dist-info/RECORD +14 -0
- pacioliscube-0.1.2.dist-info/WHEEL +4 -0
- pacioliscube-0.1.2.dist-info/entry_points.txt +2 -0
- pacioliscube-0.1.2.dist-info/licenses/LICENSE +22 -0
pacioliscube/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""An open IBM Planning Analytics budgeting model and an offline engine for it."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _installed_version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = _installed_version("pacioliscube")
|
|
8
|
+
except PackageNotFoundError: # running from a source tree without installation
|
|
9
|
+
__version__ = "0.0.0.dev0"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
pacioliscube/cli.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""The command line, being the way a shell script or a CI job drives this package.
|
|
2
|
+
|
|
3
|
+
Three subcommands. ``validate`` reports what is structurally wrong with a model
|
|
4
|
+
tree, ``calculate`` prints the value at named cells, and ``report`` prints a
|
|
5
|
+
small profit and loss. Every run reads: nothing here writes a file, touches a
|
|
6
|
+
network, or changes the model it is pointed at.
|
|
7
|
+
|
|
8
|
+
Argument handling and the three subcommands live here. The statement the report
|
|
9
|
+
prints is in report.py and reading a directory of CSV input is in data.py, so
|
|
10
|
+
this module is the part a reader consults for what the arguments mean and which
|
|
11
|
+
exit code a failure takes.
|
|
12
|
+
|
|
13
|
+
Exit codes, which a shell script or a CI job can rely on:
|
|
14
|
+
|
|
15
|
+
0 the command finished and found nothing wrong
|
|
16
|
+
1 a usage or input error, being a directory that is not there, a CSV that
|
|
17
|
+
cannot be read, a cell reference that cannot be parsed, or a model the
|
|
18
|
+
report subcommand does not fit
|
|
19
|
+
2 the model is not sound: it does not load, validation reports at least one
|
|
20
|
+
error severity finding, or a fault the structural checks do not reach
|
|
21
|
+
surfaces while a figure is being calculated. Every subcommand validates
|
|
22
|
+
before it calculates, because a figure taken from a structurally broken
|
|
23
|
+
model is worse than no figure at all
|
|
24
|
+
3 a calculation failed, which is a division by zero, a circular reference, or
|
|
25
|
+
any other EvaluationError
|
|
26
|
+
|
|
27
|
+
Warnings never change the exit code. A warning is a finding the engine cannot
|
|
28
|
+
prove is wrong, so failing a build on one would make the check useless.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import sys
|
|
35
|
+
from decimal import Decimal
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Iterator, Optional, Sequence
|
|
38
|
+
|
|
39
|
+
from pacioliscube import __version__, report
|
|
40
|
+
from pacioliscube.data import load_data
|
|
41
|
+
from pacioliscube.errors import (
|
|
42
|
+
EXIT_CALCULATION,
|
|
43
|
+
EXIT_INVALID_MODEL,
|
|
44
|
+
EXIT_OK,
|
|
45
|
+
EXIT_USAGE,
|
|
46
|
+
CliError,
|
|
47
|
+
element_or_error,
|
|
48
|
+
)
|
|
49
|
+
from pacioliscube.evaluate import CellStore, EvaluationError, consolidate_many, evaluate
|
|
50
|
+
from pacioliscube.model import Cube, Model, ModelError, load_model
|
|
51
|
+
from pacioliscube.validate import ERROR, Finding, validate_model
|
|
52
|
+
|
|
53
|
+
DEFAULT_MODEL_ROOT = "model"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _directory(argument: str, what: str) -> Path:
|
|
57
|
+
path = Path(argument)
|
|
58
|
+
if not path.is_dir():
|
|
59
|
+
raise CliError(EXIT_USAGE, f"{path}: there is no {what} directory there")
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _model_root(args: argparse.Namespace) -> Path:
|
|
64
|
+
"""The model directory, given as the positional or left at its default."""
|
|
65
|
+
return _directory(args.model_dir or DEFAULT_MODEL_ROOT, "model")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load(root: Path) -> Model:
|
|
69
|
+
try:
|
|
70
|
+
return load_model(root)
|
|
71
|
+
except ModelError as error:
|
|
72
|
+
raise CliError(EXIT_INVALID_MODEL, str(error)) from error
|
|
73
|
+
except UnicodeDecodeError as error:
|
|
74
|
+
# A decode error carries the bytes and not the path, and the loader does
|
|
75
|
+
# not say which of the files it opened was being read, so the message
|
|
76
|
+
# reports the tree and the byte that stopped it.
|
|
77
|
+
raise CliError(
|
|
78
|
+
EXIT_INVALID_MODEL,
|
|
79
|
+
f"{root}: a file in the model tree is not UTF-8 text ({error}). "
|
|
80
|
+
"Every file of a model tree is read as UTF-8",
|
|
81
|
+
) from error
|
|
82
|
+
except OSError as error:
|
|
83
|
+
raise CliError(
|
|
84
|
+
EXIT_INVALID_MODEL, f"{root}: the model could not be read, {error}"
|
|
85
|
+
) from error
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cube(model: Model, name: str) -> Cube:
|
|
89
|
+
for cube in model.cubes.values():
|
|
90
|
+
if cube.name.casefold() == name.casefold():
|
|
91
|
+
return cube
|
|
92
|
+
raise CliError(
|
|
93
|
+
EXIT_USAGE,
|
|
94
|
+
f"no cube named {name!r} in model {model.name!r}. "
|
|
95
|
+
f"The model holds {', '.join(model.cubes) or 'no cubes'}",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _format_finding(finding: Finding) -> str:
|
|
100
|
+
return f"{finding.severity} {finding.code} {finding.location}: {finding.message}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _summary(errors: int, warnings: int) -> str:
|
|
104
|
+
return (
|
|
105
|
+
f"{errors} {'error' if errors == 1 else 'errors'}, "
|
|
106
|
+
f"{warnings} {'warning' if warnings == 1 else 'warnings'}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _refuse_broken_model(model: Model) -> None:
|
|
111
|
+
"""Stop calculate and report before they read a model that cannot be right."""
|
|
112
|
+
errors = [finding for finding in validate_model(model) if finding.severity == ERROR]
|
|
113
|
+
if not errors:
|
|
114
|
+
return
|
|
115
|
+
for finding in errors:
|
|
116
|
+
print(_format_finding(finding), file=sys.stderr)
|
|
117
|
+
count = f"{len(errors)} {'error' if len(errors) == 1 else 'errors'}"
|
|
118
|
+
raise CliError(
|
|
119
|
+
EXIT_INVALID_MODEL,
|
|
120
|
+
f"the model has {count}, so nothing was calculated. "
|
|
121
|
+
"Run the validate subcommand for the findings in full",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _evaluate(model: Model, store: CellStore) -> CellStore:
|
|
126
|
+
try:
|
|
127
|
+
return evaluate(model, store)
|
|
128
|
+
except EvaluationError as error:
|
|
129
|
+
raise CliError(EXIT_CALCULATION, str(error)) from error
|
|
130
|
+
except ModelError as error:
|
|
131
|
+
# Validation has already passed, so a model error at this point is a
|
|
132
|
+
# defect the structural checks do not reach rather than a bad argument.
|
|
133
|
+
raise CliError(EXIT_INVALID_MODEL, str(error)) from error
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _values(
|
|
137
|
+
model: Model, store: CellStore, cells: Sequence[tuple[str, tuple[str, ...]]]
|
|
138
|
+
) -> Iterator[Decimal]:
|
|
139
|
+
try:
|
|
140
|
+
yield from consolidate_many(model, store, cells)
|
|
141
|
+
except EvaluationError as error:
|
|
142
|
+
raise CliError(EXIT_CALCULATION, str(error)) from error
|
|
143
|
+
except ModelError as error:
|
|
144
|
+
# The same reasoning as _evaluate, and the same exit code for the same
|
|
145
|
+
# exception. The coordinate reached here has already been resolved
|
|
146
|
+
# element by element against the model, so what is left is a fault in
|
|
147
|
+
# the model rather than a bad argument.
|
|
148
|
+
raise CliError(EXIT_INVALID_MODEL, str(error)) from error
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_cell(model: Model, text: str) -> tuple[str, tuple[str, ...]]:
|
|
152
|
+
"""Turn CUBE:element,element into a cube name and a canonical coordinate."""
|
|
153
|
+
cube_name, separator, coordinate_text = text.partition(":")
|
|
154
|
+
if not separator or not cube_name.strip() or not coordinate_text.strip():
|
|
155
|
+
raise CliError(
|
|
156
|
+
EXIT_USAGE,
|
|
157
|
+
f"cell reference {text!r} is not in the form CUBE:element,element,...",
|
|
158
|
+
)
|
|
159
|
+
cube = _cube(model, cube_name.strip())
|
|
160
|
+
names = [part.strip() for part in coordinate_text.split(",")]
|
|
161
|
+
if not all(names):
|
|
162
|
+
raise CliError(EXIT_USAGE, f"cell reference {text!r} has an empty element name")
|
|
163
|
+
if len(names) != len(cube.dimensions):
|
|
164
|
+
raise CliError(
|
|
165
|
+
EXIT_USAGE,
|
|
166
|
+
f"cell reference {text!r} gives {len(names)} elements for cube {cube.name!r}, "
|
|
167
|
+
f"which takes {len(cube.dimensions)}, being {', '.join(cube.dimensions)}",
|
|
168
|
+
)
|
|
169
|
+
coordinate = tuple(
|
|
170
|
+
element_or_error(model, dimension, name, EXIT_USAGE)
|
|
171
|
+
for dimension, name in zip(cube.dimensions, names)
|
|
172
|
+
)
|
|
173
|
+
return cube.name, coordinate
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _plain(value: Decimal) -> str:
|
|
177
|
+
"""A Decimal in full, never in exponent form and never through a float."""
|
|
178
|
+
text = format(value, "f")
|
|
179
|
+
if "." in text:
|
|
180
|
+
text = text.rstrip("0").rstrip(".")
|
|
181
|
+
return text
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _validate_command(args: argparse.Namespace) -> int:
|
|
185
|
+
model = _load(_model_root(args))
|
|
186
|
+
findings = validate_model(model)
|
|
187
|
+
errors = [finding for finding in findings if finding.severity == ERROR]
|
|
188
|
+
warnings = [finding for finding in findings if finding.severity != ERROR]
|
|
189
|
+
for finding in errors + warnings:
|
|
190
|
+
print(_format_finding(finding))
|
|
191
|
+
print(_summary(len(errors), len(warnings)))
|
|
192
|
+
return EXIT_INVALID_MODEL if errors else EXIT_OK
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _calculate_command(args: argparse.Namespace) -> int:
|
|
196
|
+
model = _load(_model_root(args))
|
|
197
|
+
_refuse_broken_model(model)
|
|
198
|
+
data = _directory(args.data, "data")
|
|
199
|
+
# Cell references are parsed before the CSVs are read so that a typo costs
|
|
200
|
+
# a second rather than a full load and evaluation.
|
|
201
|
+
cells = [_parse_cell(model, text) for text in args.cell]
|
|
202
|
+
store = _evaluate(model, load_data(model, data))
|
|
203
|
+
values = _values(model, store, cells)
|
|
204
|
+
for (cube, coordinate), value in zip(cells, values):
|
|
205
|
+
print(f"{cube}:{','.join(coordinate)} = {_plain(value)}")
|
|
206
|
+
return EXIT_OK
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _report_command(args: argparse.Namespace) -> int:
|
|
210
|
+
model = _load(_model_root(args))
|
|
211
|
+
_refuse_broken_model(model)
|
|
212
|
+
data = _directory(args.data, "data")
|
|
213
|
+
cube = _cube(model, report.REPORT_CUBE)
|
|
214
|
+
selections = report.rows(model, cube, args.year, args.version)
|
|
215
|
+
store = _evaluate(model, load_data(model, data))
|
|
216
|
+
cells = [
|
|
217
|
+
(cube.name, tuple(selection[dimension] for dimension in cube.dimensions))
|
|
218
|
+
for selection in selections
|
|
219
|
+
]
|
|
220
|
+
values = list(_values(model, store, cells))
|
|
221
|
+
for line in report.lines(cube, selections, values):
|
|
222
|
+
print(line)
|
|
223
|
+
return EXIT_OK
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
227
|
+
parser = argparse.ArgumentParser(
|
|
228
|
+
prog="pacioliscube",
|
|
229
|
+
description="Validate, calculate and report on a Planning Analytics model tree. "
|
|
230
|
+
"Reads the model and its data, never writes.",
|
|
231
|
+
)
|
|
232
|
+
parser.add_argument("--version", action="version", version=f"pacioliscube {__version__}")
|
|
233
|
+
subcommands = parser.add_subparsers(dest="command", metavar="SUBCOMMAND", required=True)
|
|
234
|
+
|
|
235
|
+
def with_model_dir(subparser: argparse.ArgumentParser) -> argparse.ArgumentParser:
|
|
236
|
+
subparser.add_argument(
|
|
237
|
+
"model_dir",
|
|
238
|
+
nargs="?",
|
|
239
|
+
default=None,
|
|
240
|
+
metavar="MODEL_DIR",
|
|
241
|
+
help=f"the directory holding tm1project.json, {DEFAULT_MODEL_ROOT} by default",
|
|
242
|
+
)
|
|
243
|
+
return subparser
|
|
244
|
+
|
|
245
|
+
validate = with_model_dir(
|
|
246
|
+
subcommands.add_parser("validate", help="report structural findings in a model")
|
|
247
|
+
)
|
|
248
|
+
validate.set_defaults(handler=_validate_command)
|
|
249
|
+
|
|
250
|
+
calculate = with_model_dir(
|
|
251
|
+
subcommands.add_parser("calculate", help="print the value at one or more cells")
|
|
252
|
+
)
|
|
253
|
+
calculate.add_argument(
|
|
254
|
+
"--data", required=True, metavar="DIR", help="directory of long format CSV input"
|
|
255
|
+
)
|
|
256
|
+
calculate.add_argument(
|
|
257
|
+
"--cell",
|
|
258
|
+
required=True,
|
|
259
|
+
action="append",
|
|
260
|
+
metavar="CUBE:ELEMENT,...",
|
|
261
|
+
help="a cell to print, given once per cell, as CUBE:element,element,...",
|
|
262
|
+
)
|
|
263
|
+
calculate.set_defaults(handler=_calculate_command)
|
|
264
|
+
|
|
265
|
+
report_parser = with_model_dir(
|
|
266
|
+
subcommands.add_parser(
|
|
267
|
+
"report",
|
|
268
|
+
help="print a profit and loss for a year and version",
|
|
269
|
+
description="Print the group profit and loss for one year and version, at the FY "
|
|
270
|
+
"period and every cost centre. A line that is taken off the result below it, being "
|
|
271
|
+
"direct costs, employment costs, overheads and depreciation, prints in brackets, as "
|
|
272
|
+
"does any figure that comes out negative, so the column reads in the direction it "
|
|
273
|
+
"adds. Each line is rounded to whole dollars on its own, so a subtotal can sit a "
|
|
274
|
+
"dollar away from the lines above it: in the shipped budget, EBITDA less "
|
|
275
|
+
"depreciation prints as 18,684,639 while EBIT prints as 18,684,640.",
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
report_parser.add_argument(
|
|
279
|
+
"--data", required=True, metavar="DIR", help="directory of long format CSV input"
|
|
280
|
+
)
|
|
281
|
+
report_parser.add_argument(
|
|
282
|
+
"--year", required=True, metavar="Y", help="an element of the Year dimension"
|
|
283
|
+
)
|
|
284
|
+
report_parser.add_argument(
|
|
285
|
+
"--version", required=True, metavar="V", help="an element of the Version dimension"
|
|
286
|
+
)
|
|
287
|
+
report_parser.set_defaults(handler=_report_command)
|
|
288
|
+
return parser
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
292
|
+
parser = build_parser()
|
|
293
|
+
try:
|
|
294
|
+
args = parser.parse_args(list(sys.argv[1:] if argv is None else argv))
|
|
295
|
+
except SystemExit as stop:
|
|
296
|
+
# argparse has already written its own message. It exits 0 for --help
|
|
297
|
+
# and --version and 2 for a bad argument, so only the codes change here.
|
|
298
|
+
return EXIT_OK if stop.code in (0, None) else EXIT_USAGE
|
|
299
|
+
try:
|
|
300
|
+
return args.handler(args)
|
|
301
|
+
except CliError as error:
|
|
302
|
+
print(f"pacioliscube: {error}", file=sys.stderr)
|
|
303
|
+
return error.code
|
|
304
|
+
except EvaluationError as error: # a backstop: a command line should not traceback
|
|
305
|
+
print(f"pacioliscube: {error}", file=sys.stderr)
|
|
306
|
+
return EXIT_CALCULATION
|
|
307
|
+
except ModelError as error:
|
|
308
|
+
print(f"pacioliscube: {error}", file=sys.stderr)
|
|
309
|
+
return EXIT_INVALID_MODEL
|
|
310
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
311
|
+
# Another backstop. The places that read a file map their own failures,
|
|
312
|
+
# because only they know whether the file was model source or input, so
|
|
313
|
+
# anything arriving here is a path this module has not thought about and
|
|
314
|
+
# takes the input error code.
|
|
315
|
+
print(f"pacioliscube: {error}", file=sys.stderr)
|
|
316
|
+
return EXIT_USAGE
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
raise SystemExit(main())
|
pacioliscube/data.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Read long format CSV input into cube coordinates, a file or a directory at a time.
|
|
2
|
+
|
|
3
|
+
The engine reads the same synthetic CSV files that the TurboIntegrator
|
|
4
|
+
processes read on a real server. A row carries one leading column per cube
|
|
5
|
+
dimension, in the cube's dimension order, then a value column. Every row is
|
|
6
|
+
checked before it is used, and every error names the file and the row.
|
|
7
|
+
|
|
8
|
+
``load_csv`` and ``load_into_store`` read one file into one cube. ``load_data``
|
|
9
|
+
reads a whole directory, which is what the command line points at, and decides
|
|
10
|
+
which cube each file feeds.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import csv
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Iterator
|
|
19
|
+
|
|
20
|
+
from pacioliscube.errors import EXIT_USAGE, CliError
|
|
21
|
+
from pacioliscube.evaluate import CellStore
|
|
22
|
+
from pacioliscube.model import Cube, Model, ModelError
|
|
23
|
+
from pacioliscube.rules import decimal_or_raise
|
|
24
|
+
|
|
25
|
+
Coordinate = tuple[str, ...]
|
|
26
|
+
|
|
27
|
+
# The stem of a data file names the cube it feeds. The shipped example files are
|
|
28
|
+
# listed rather than derived, because pnl-direct is not spelled like the cube it
|
|
29
|
+
# loads. A stem that matches a cube name is taken as well, which is how a model
|
|
30
|
+
# built for a test feeds cubes this map has never heard of, and load_data
|
|
31
|
+
# refuses the case that makes the fallback dangerous: two files, one cube.
|
|
32
|
+
CUBE_BY_STEM = {
|
|
33
|
+
"drivers": "Drivers",
|
|
34
|
+
"workforce": "Workforce",
|
|
35
|
+
"revenue": "Revenue",
|
|
36
|
+
"capex": "Capex",
|
|
37
|
+
"pnl-direct": "PnL",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_csv(path: Path, cube: Cube, model: Model) -> Iterator[tuple[Coordinate, Decimal]]:
|
|
42
|
+
"""Yield one coordinate and value per data row of a long format CSV."""
|
|
43
|
+
path = Path(path)
|
|
44
|
+
if not path.is_file():
|
|
45
|
+
raise ModelError(f"{path}: file not found")
|
|
46
|
+
width = len(cube.dimensions) + 1
|
|
47
|
+
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
|
48
|
+
reader = csv.reader(handle)
|
|
49
|
+
try:
|
|
50
|
+
header = next(reader)
|
|
51
|
+
except StopIteration:
|
|
52
|
+
raise ModelError(f"{path}: the file is empty, expected a header row") from None
|
|
53
|
+
if len(header) != width:
|
|
54
|
+
raise ModelError(
|
|
55
|
+
f"{path} row 1: the header has {len(header)} columns but cube {cube.name!r} "
|
|
56
|
+
f"needs {width}, being {', '.join(cube.dimensions)} then a value"
|
|
57
|
+
)
|
|
58
|
+
seen: dict[Coordinate, tuple[Decimal, int]] = {}
|
|
59
|
+
for number, row in enumerate(reader, start=2):
|
|
60
|
+
if not row or all(field.strip() == "" for field in row):
|
|
61
|
+
continue
|
|
62
|
+
if len(row) != width:
|
|
63
|
+
raise ModelError(
|
|
64
|
+
f"{path} row {number}: {len(row)} columns, expected {width}"
|
|
65
|
+
)
|
|
66
|
+
coordinate = []
|
|
67
|
+
for position, field in enumerate(row[:-1]):
|
|
68
|
+
dimension = cube.dimensions[position]
|
|
69
|
+
element = field.strip()
|
|
70
|
+
try:
|
|
71
|
+
hierarchy = model.hierarchy(dimension)
|
|
72
|
+
canonical = hierarchy.resolve(element)
|
|
73
|
+
if not hierarchy.is_leaf(canonical):
|
|
74
|
+
raise ModelError(
|
|
75
|
+
f"{dimension}: element {canonical!r} is consolidated and cannot hold input"
|
|
76
|
+
)
|
|
77
|
+
coordinate.append(canonical)
|
|
78
|
+
except ModelError as error:
|
|
79
|
+
raise ModelError(f"{path} row {number}: {error}") from None
|
|
80
|
+
text = row[-1].strip()
|
|
81
|
+
value = decimal_or_raise(text, f"{path} row {number}", ModelError, "value ")
|
|
82
|
+
if not value.is_finite():
|
|
83
|
+
raise ModelError(
|
|
84
|
+
f"{path} row {number}: value {text!r} is not a finite number"
|
|
85
|
+
)
|
|
86
|
+
cell = tuple(coordinate)
|
|
87
|
+
previous = seen.get(cell)
|
|
88
|
+
if previous is not None and previous[0] != value:
|
|
89
|
+
raise ModelError(
|
|
90
|
+
f"{path} row {number}: conflicting value for {cube.name!r} "
|
|
91
|
+
f"at {list(cell)}; first supplied on row {previous[1]}"
|
|
92
|
+
)
|
|
93
|
+
if previous is not None:
|
|
94
|
+
continue
|
|
95
|
+
seen[cell] = (value, number)
|
|
96
|
+
yield cell, value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_into_store(model: Model, cube_name: str, path: Path, store) -> int:
|
|
100
|
+
"""Load one CSV into a cell store and return how many cells it wrote."""
|
|
101
|
+
cube = model.cubes.get(cube_name)
|
|
102
|
+
if cube is None:
|
|
103
|
+
raise ModelError(f"no cube named {cube_name!r} in model {model.name!r}")
|
|
104
|
+
written = 0
|
|
105
|
+
for coordinate, value in load_csv(path, cube, model):
|
|
106
|
+
store.set(cube.name, coordinate, value)
|
|
107
|
+
written += 1
|
|
108
|
+
return written
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cube_for_file(model: Model, path: Path) -> Cube:
|
|
112
|
+
"""Which cube a data file loads into, by its stem."""
|
|
113
|
+
stem = path.stem.casefold()
|
|
114
|
+
named = CUBE_BY_STEM.get(stem)
|
|
115
|
+
if named is not None and named in model.cubes:
|
|
116
|
+
return model.cubes[named]
|
|
117
|
+
for cube in model.cubes.values():
|
|
118
|
+
if cube.name.casefold() == stem:
|
|
119
|
+
return cube
|
|
120
|
+
raise CliError(
|
|
121
|
+
EXIT_USAGE,
|
|
122
|
+
f"{path}: no cube in model {model.name!r} matches the file name {path.stem!r}",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_data(model: Model, directory: Path) -> CellStore:
|
|
127
|
+
"""Load every CSV in a directory into one store, one file per cube."""
|
|
128
|
+
store = CellStore()
|
|
129
|
+
try:
|
|
130
|
+
boundary = directory.resolve()
|
|
131
|
+
files = sorted(
|
|
132
|
+
path
|
|
133
|
+
for path in directory.iterdir()
|
|
134
|
+
if path.is_file() and path.suffix.casefold() == ".csv"
|
|
135
|
+
)
|
|
136
|
+
except OSError as error:
|
|
137
|
+
raise CliError(
|
|
138
|
+
EXIT_USAGE, f"{directory}: the data directory could not be read, {error}"
|
|
139
|
+
) from error
|
|
140
|
+
if not files:
|
|
141
|
+
raise CliError(EXIT_USAGE, f"{directory}: there are no CSV files there")
|
|
142
|
+
|
|
143
|
+
# Every file is matched to its cube before any of them is read. A cell store
|
|
144
|
+
# takes the last write, so two files feeding one cube would leave the cells
|
|
145
|
+
# they share holding whichever file sorted second, and the run would print a
|
|
146
|
+
# wrong figure and exit 0. Guessing which of the two was meant is worse than
|
|
147
|
+
# saying that both are there.
|
|
148
|
+
feeding: dict[str, list[Path]] = {}
|
|
149
|
+
for path in files:
|
|
150
|
+
if not path.resolve().is_relative_to(boundary):
|
|
151
|
+
raise CliError(EXIT_USAGE, f"{path}: this file resolves outside the data directory")
|
|
152
|
+
feeding.setdefault(cube_for_file(model, path).name, []).append(path)
|
|
153
|
+
for cube_name, paths in feeding.items():
|
|
154
|
+
if len(paths) > 1:
|
|
155
|
+
raise CliError(
|
|
156
|
+
EXIT_USAGE,
|
|
157
|
+
f"cube {cube_name!r} is fed by more than one file: "
|
|
158
|
+
f"{', '.join(str(path) for path in paths)}. "
|
|
159
|
+
"Leave the one that belongs to this run in the data directory and move "
|
|
160
|
+
"the rest out",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
for cube_name, paths in feeding.items():
|
|
164
|
+
path = paths[0]
|
|
165
|
+
try:
|
|
166
|
+
load_into_store(model, cube_name, path, store)
|
|
167
|
+
except ModelError as error:
|
|
168
|
+
raise CliError(EXIT_USAGE, str(error)) from error
|
|
169
|
+
except UnicodeDecodeError as error:
|
|
170
|
+
raise CliError(
|
|
171
|
+
EXIT_USAGE,
|
|
172
|
+
f"{path}: this file is not UTF-8 text ({error}). "
|
|
173
|
+
"A spreadsheet export saves as UTF-8 from its own save dialogue",
|
|
174
|
+
) from error
|
|
175
|
+
except OSError as error:
|
|
176
|
+
raise CliError(EXIT_USAGE, f"{path}: this file could not be read, {error}") from error
|
|
177
|
+
return store
|
pacioliscube/errors.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The command line's failure type, its exit codes, and one model lookup.
|
|
2
|
+
|
|
3
|
+
This sits apart from cli.py so that the report and the data loader can refuse a
|
|
4
|
+
bad argument the way the command line does without importing the module that
|
|
5
|
+
imports them. Nothing here reads a file or prints anything.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pacioliscube.model import Model, ModelError
|
|
11
|
+
|
|
12
|
+
EXIT_OK = 0
|
|
13
|
+
EXIT_USAGE = 1
|
|
14
|
+
EXIT_INVALID_MODEL = 2
|
|
15
|
+
EXIT_CALCULATION = 3
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CliError(Exception):
|
|
19
|
+
"""An error the command line reports, carrying the exit code it maps to."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, code: int, message: str) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.code = code
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def element_or_error(model: Model, dimension: str, name: str, code: int) -> str:
|
|
27
|
+
"""Resolve an element name, taking the exit code that fits who supplied it."""
|
|
28
|
+
try:
|
|
29
|
+
return model.hierarchy(dimension).resolve(name)
|
|
30
|
+
except ModelError as error:
|
|
31
|
+
raise CliError(code, str(error)) from error
|