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.
@@ -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