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 +3 -0
- threeway/cli.py +247 -0
- threeway/config.py +116 -0
- threeway/core.py +439 -0
- threeway/csvio.py +209 -0
- threeway/report.py +234 -0
- threeway-1.1.0.dist-info/METADATA +289 -0
- threeway-1.1.0.dist-info/RECORD +12 -0
- threeway-1.1.0.dist-info/WHEEL +5 -0
- threeway-1.1.0.dist-info/entry_points.txt +2 -0
- threeway-1.1.0.dist-info/licenses/LICENSE +21 -0
- threeway-1.1.0.dist-info/top_level.txt +1 -0
threeway/__init__.py
ADDED
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)
|