threeway 1.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.
threeway/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package marker; version lives here so cli.py has a single source of truth for --version."""
2
+
3
+ __version__ = "1.1.0"
threeway/cli.py ADDED
@@ -0,0 +1,247 @@
1
+ """Entry point module: turns argv into a reconcile() call and an exit code, and
2
+ is the one place that knows about the CI-gating exit code contract (0/1/2).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import sys
9
+ from datetime import date
10
+ from decimal import Decimal, InvalidOperation
11
+ from typing import Any, Dict, Optional, cast
12
+
13
+ from . import __version__
14
+ from .config import ConfigError, discover_config_path, load_config, load_uom_map
15
+ from .core import (
16
+ DEFAULT_CUTOFF_DAYS,
17
+ DEFAULT_PCT_TOLERANCE,
18
+ DEFAULT_QTY_TOLERANCE,
19
+ DEFAULT_VALUE_TOLERANCE,
20
+ reconcile,
21
+ )
22
+ from .csvio import ColumnNotFoundError, read_goods_received, read_invoices, read_purchase_orders
23
+ from .report import render
24
+
25
+ EXIT_OK = 0
26
+ EXIT_EXCEPTIONS = 1
27
+ EXIT_USAGE_ERROR = 2
28
+
29
+
30
+ def build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(
32
+ prog="threeway",
33
+ description=(
34
+ "Reconcile purchase orders, goods received, and supplier invoices, and "
35
+ "report which lines disagree and why."
36
+ ),
37
+ epilog=(
38
+ "Exit codes: 0 = no exceptions found, 1 = one or more exceptions found "
39
+ "(useful for gating CI), 2 = usage or input error. "
40
+ "Precedence, highest first: an explicit flag, then --config (or an "
41
+ "auto-discovered threeway.toml/threeway.json in the current directory), "
42
+ "then the built-in default."
43
+ ),
44
+ )
45
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
46
+ parser.add_argument("--po", required=True, help="path to the purchase orders CSV")
47
+ parser.add_argument("--grn", required=True, help="path to the goods received CSV")
48
+ parser.add_argument("--invoice", required=True, help="path to the supplier invoices CSV")
49
+ parser.add_argument(
50
+ "--qty-tolerance",
51
+ type=str,
52
+ default=None,
53
+ help=f"allowed quantity variance for a match (default {DEFAULT_QTY_TOLERANCE})",
54
+ )
55
+ parser.add_argument(
56
+ "--value-tolerance",
57
+ type=str,
58
+ default=None,
59
+ help=f"allowed value variance for a match (default {DEFAULT_VALUE_TOLERANCE})",
60
+ )
61
+ parser.add_argument(
62
+ "--pct-tolerance",
63
+ type=str,
64
+ default=None,
65
+ help=f"allowed invoice/PO price difference, in percent (default {DEFAULT_PCT_TOLERANCE})",
66
+ )
67
+ parser.add_argument(
68
+ "--format",
69
+ choices=["table", "json", "csv"],
70
+ default="table",
71
+ help="output format (default table)",
72
+ )
73
+ parser.add_argument(
74
+ "--only-exceptions",
75
+ action="store_true",
76
+ help="show only lines with status 'exception'",
77
+ )
78
+ parser.add_argument("--output", help="write the report to this file instead of stdout")
79
+ parser.add_argument("--quiet", action="store_true", help="suppress the report; exit code only")
80
+ parser.add_argument(
81
+ "--uom-map",
82
+ help=(
83
+ "path to a JSON (or TOML, on Python 3.11+) file mapping units of "
84
+ "measure to a base unit, globally or per item code"
85
+ ),
86
+ )
87
+ parser.add_argument(
88
+ "--as-of",
89
+ help="ignore any row dated after this date (YYYY-MM-DD)",
90
+ )
91
+ parser.add_argument(
92
+ "--cutoff-days",
93
+ type=str,
94
+ default=None,
95
+ help=(
96
+ "downgrade an exception to 'check' when it is explained by a "
97
+ f"receipt/invoice date gap within this many days (default {DEFAULT_CUTOFF_DAYS})"
98
+ ),
99
+ )
100
+ parser.add_argument(
101
+ "--config",
102
+ help=(
103
+ "path to a JSON or TOML settings file (qty_tolerance, value_tolerance, "
104
+ "pct_tolerance, cutoff_days, uom_map, columns). If not given, "
105
+ "threeway.toml or threeway.json in the current directory is used "
106
+ "automatically when present."
107
+ ),
108
+ )
109
+ return parser
110
+
111
+
112
+ def _resolve_decimal(
113
+ flag_value: Optional[str],
114
+ config: Dict[str, object],
115
+ config_key: str,
116
+ default: Decimal,
117
+ flag_name: str,
118
+ ) -> Decimal:
119
+ """Explicit flag > config file > built-in default."""
120
+ raw: object
121
+ if flag_value is not None:
122
+ raw = flag_value
123
+ elif config_key in config:
124
+ raw = config[config_key]
125
+ else:
126
+ return default
127
+ try:
128
+ # raw is a CLI string or an already-Decimalized config value; either
129
+ # way Decimal() itself validates it, and a bad value is caught below.
130
+ return Decimal(cast(Any, raw))
131
+ except (InvalidOperation, TypeError):
132
+ raise ValueError(f"{flag_name} must be a number, got {raw!r}") from None
133
+
134
+
135
+ def _resolve_int(
136
+ flag_value: Optional[str],
137
+ config: Dict[str, object],
138
+ config_key: str,
139
+ default: int,
140
+ flag_name: str,
141
+ ) -> int:
142
+ raw: object
143
+ if flag_value is not None:
144
+ raw = flag_value
145
+ elif config_key in config:
146
+ raw = config[config_key]
147
+ else:
148
+ return default
149
+ try:
150
+ return int(Decimal(cast(Any, raw)))
151
+ except (InvalidOperation, TypeError, ValueError):
152
+ raise ValueError(f"{flag_name} must be a whole number, got {raw!r}") from None
153
+
154
+
155
+ def main(argv: list[str] | None = None) -> int:
156
+ parser = build_parser()
157
+ args = parser.parse_args(argv)
158
+
159
+ try:
160
+ config_path = discover_config_path(args.config)
161
+ config = load_config(config_path)
162
+ except ConfigError as exc:
163
+ print(f"error: {exc}", file=sys.stderr)
164
+ return EXIT_USAGE_ERROR
165
+
166
+ try:
167
+ qty_tolerance = _resolve_decimal(
168
+ args.qty_tolerance, config, "qty_tolerance", DEFAULT_QTY_TOLERANCE, "--qty-tolerance"
169
+ )
170
+ value_tolerance = _resolve_decimal(
171
+ args.value_tolerance,
172
+ config,
173
+ "value_tolerance",
174
+ DEFAULT_VALUE_TOLERANCE,
175
+ "--value-tolerance",
176
+ )
177
+ pct_tolerance = _resolve_decimal(
178
+ args.pct_tolerance, config, "pct_tolerance", DEFAULT_PCT_TOLERANCE, "--pct-tolerance"
179
+ )
180
+ cutoff_days = _resolve_int(
181
+ args.cutoff_days, config, "cutoff_days", DEFAULT_CUTOFF_DAYS, "--cutoff-days"
182
+ )
183
+ except ValueError as exc:
184
+ print(f"error: {exc}", file=sys.stderr)
185
+ return EXIT_USAGE_ERROR
186
+
187
+ as_of = None
188
+ if args.as_of is not None:
189
+ try:
190
+ as_of = date.fromisoformat(args.as_of)
191
+ except ValueError:
192
+ print(
193
+ f"error: --as-of must be a date in YYYY-MM-DD format, got {args.as_of!r}",
194
+ file=sys.stderr,
195
+ )
196
+ return EXIT_USAGE_ERROR
197
+
198
+ uom_map_path = args.uom_map
199
+ try:
200
+ uom_map = load_uom_map(uom_map_path)
201
+ if uom_map is None and "uom_map" in config:
202
+ # config values are untyped external data; the shape is validated
203
+ # (or not) downstream in core.py exactly as it was before typing.
204
+ uom_map = cast(Optional[Dict[str, object]], config["uom_map"])
205
+ except ConfigError as exc:
206
+ print(f"error: {exc}", file=sys.stderr)
207
+ return EXIT_USAGE_ERROR
208
+
209
+ extra_aliases = cast(Optional[Dict[str, object]], config.get("columns"))
210
+
211
+ try:
212
+ po_rows = read_purchase_orders(args.po, extra_aliases=extra_aliases)
213
+ grn_rows = read_goods_received(args.grn, extra_aliases=extra_aliases)
214
+ invoice_rows = read_invoices(args.invoice, extra_aliases=extra_aliases)
215
+ except (ColumnNotFoundError, ValueError) as exc:
216
+ print(f"error: {exc}", file=sys.stderr)
217
+ return EXIT_USAGE_ERROR
218
+ except OSError as exc:
219
+ print(f"error: {exc}", file=sys.stderr)
220
+ return EXIT_USAGE_ERROR
221
+
222
+ result = reconcile(
223
+ po_rows,
224
+ grn_rows,
225
+ invoice_rows,
226
+ qty_tolerance=qty_tolerance,
227
+ value_tolerance=value_tolerance,
228
+ pct_tolerance=pct_tolerance,
229
+ uom_map=uom_map,
230
+ as_of=as_of,
231
+ cutoff_days=cutoff_days,
232
+ )
233
+
234
+ if not args.quiet:
235
+ output_text = render(result, args.format, only_exceptions=args.only_exceptions)
236
+ if args.output:
237
+ with open(args.output, "w", encoding="utf-8", newline="") as handle:
238
+ handle.write(output_text)
239
+ else:
240
+ print(output_text, end="")
241
+
242
+ exception_count = sum(1 for line in result.lines if line.status == "exception")
243
+ return EXIT_EXCEPTIONS if exception_count > 0 else EXIT_OK
244
+
245
+
246
+ if __name__ == "__main__":
247
+ sys.exit(main())
threeway/config.py ADDED
@@ -0,0 +1,116 @@
1
+ """Loading for the two optional structured-file inputs - the settings config
2
+ (`--config`) and the unit-of-measure map (`--uom-map`) - kept apart from cli.py
3
+ because parsing JSON/TOML into plain, Decimal-safe Python objects has nothing to
4
+ do with argument handling, and apart from core.py because it is I/O.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from decimal import Decimal
12
+ from typing import Dict, List, Optional, Union
13
+
14
+ try:
15
+ # mypy is run with python_version = 3.9, and typeshed has no stub for
16
+ # tomllib below 3.11 regardless of this runtime try/except, so the import
17
+ # itself is unresolvable to the type checker on the pinned target version.
18
+ import tomllib # type: ignore[import-not-found]
19
+ except ImportError: # Python < 3.11: TOML is optional, JSON always works.
20
+ tomllib = None
21
+
22
+ # An empty tuple as an except target catches nothing; this lets the shared
23
+ # try/except below name `_TOML_DECODE_ERRORS` unconditionally even when tomllib
24
+ # itself is unavailable.
25
+ _TOML_DECODE_ERRORS = (tomllib.TOMLDecodeError,) if tomllib is not None else ()
26
+
27
+ CONFIG_FILENAMES = ("threeway.toml", "threeway.json")
28
+
29
+
30
+ class ConfigError(ValueError):
31
+ """Raised when a config or uom-map file is missing, unreadable, or malformed.
32
+ Always names the file and the problem, so the CLI can surface it and exit 2."""
33
+
34
+
35
+ JSONValue = Union[str, int, float, bool, None, Dict[str, "JSONValue"], List["JSONValue"]]
36
+ Decimalized = Union[str, bool, None, Decimal, Dict[str, "Decimalized"], List["Decimalized"]]
37
+
38
+
39
+ def _decimalize(value: JSONValue) -> Decimalized:
40
+ """Turn every int/float leaf in a parsed JSON/TOML structure into a Decimal,
41
+ via its string form, so a factor or tolerance read from a config file never
42
+ passes through a binary float. `bool` is checked first because it is a
43
+ subclass of `int` in Python."""
44
+ if isinstance(value, bool):
45
+ return value
46
+ if isinstance(value, (int, float)):
47
+ return Decimal(str(value))
48
+ if isinstance(value, dict):
49
+ return {key: _decimalize(item) for key, item in value.items()}
50
+ if isinstance(value, list):
51
+ return [_decimalize(item) for item in value]
52
+ return value
53
+
54
+
55
+ def load_structured_file(path: str) -> Dict[str, object]:
56
+ """Read a JSON or TOML file (by extension) into a plain dict, with every
57
+ number converted to Decimal. Raises ConfigError naming the file and the
58
+ problem on anything that goes wrong - missing file, bad syntax, wrong type,
59
+ or a `.toml` file on a Python without `tomllib`."""
60
+ _, ext = os.path.splitext(path)
61
+ ext = ext.lower()
62
+
63
+ try:
64
+ if ext == ".toml":
65
+ if tomllib is None:
66
+ raise ConfigError(
67
+ f"{path}: TOML config needs Python 3.11 or newer for tomllib. "
68
+ "Use a JSON config file instead."
69
+ )
70
+ with open(path, "rb") as handle:
71
+ data = tomllib.load(handle)
72
+ elif ext == ".json":
73
+ with open(path, encoding="utf-8-sig") as handle:
74
+ data = json.load(handle)
75
+ else:
76
+ raise ConfigError(
77
+ f"{path}: unrecognised config format {ext or '(none)'!r}; use .json or .toml"
78
+ )
79
+ except OSError as exc:
80
+ raise ConfigError(f"{path}: {exc.strerror or exc}") from exc
81
+ except json.JSONDecodeError as exc:
82
+ raise ConfigError(f"{path}: invalid JSON ({exc.msg} at line {exc.lineno})") from exc
83
+ except _TOML_DECODE_ERRORS as exc:
84
+ raise ConfigError(f"{path}: invalid TOML ({exc})") from exc
85
+
86
+ if not isinstance(data, dict):
87
+ raise ConfigError(f"{path}: expected a table/object at the top level")
88
+
89
+ return {key: _decimalize(item) for key, item in data.items()}
90
+
91
+
92
+ def discover_config_path(explicit: Optional[str]) -> Optional[str]:
93
+ """Resolve which config file to use: an explicit `--config` path wins outright
94
+ (even if the file turns out not to exist - that's a load-time error, not a
95
+ discovery miss); otherwise look for threeway.toml then threeway.json in the
96
+ current directory."""
97
+ if explicit is not None:
98
+ return explicit
99
+ for name in CONFIG_FILENAMES:
100
+ if os.path.isfile(name):
101
+ return name
102
+ return None
103
+
104
+
105
+ def load_config(path: Optional[str]) -> Dict[str, object]:
106
+ """Load the settings config, or return {} if there is none to load."""
107
+ if path is None:
108
+ return {}
109
+ return load_structured_file(path)
110
+
111
+
112
+ def load_uom_map(path: Optional[str]) -> Optional[Dict[str, object]]:
113
+ """Load the uom-map file, or None if none was given."""
114
+ if path is None:
115
+ return None
116
+ return load_structured_file(path)