rowspec 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.
rowspec/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """rowspec — the reference implementation.
2
+
3
+ Deliberately boring. The specification and the conformance suite are the
4
+ contribution; this exists so there is something to check the suite against, and
5
+ so the suite has a second consumer besides its author.
6
+ """
7
+
8
+ from .csvmode import check_file as check_csv
9
+ from .sidecar import find as find_sidecar
10
+ from .table import Malformed, canon, evaluate, parse, render, set_cell, structure
11
+
12
+ __all__ = [
13
+ "Malformed",
14
+ "canon",
15
+ "check_csv",
16
+ "evaluate",
17
+ "find_sidecar",
18
+ "parse",
19
+ "render",
20
+ "set_cell",
21
+ "structure",
22
+ ]
23
+ __version__ = "0.0.0"
rowspec/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
rowspec/cli.py ADDED
@@ -0,0 +1,277 @@
1
+ """rowspec check — the specification's refusals, runnable on real files.
2
+
3
+ `.mdtbl` files go through the full parser and evaluator. `.csv` and `.tsv`
4
+ files go through CSV mode, which runs every refusal that a file nobody migrated
5
+ can support, plus the ones an adjacent `<file>.rowspec.json` unlocks.
6
+
7
+ Errors name entities, never offsets. `duplicate key id='r_01'` can be pasted
8
+ into a pull-request comment and acted on; "error at line 7" cannot, because
9
+ line 7 moves the moment anyone else edits the file.
10
+ """
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+
16
+ from .csvmode import Finding, check_file
17
+ from .table import Malformed, _why_not_a_number, canon, escape_cell, evaluate, parse
18
+
19
+ CSV_EXT = (".csv", ".tsv", ".tab")
20
+ TABLE_EXT = (".mdtbl",)
21
+ SKIP_DIRS = {".git", ".cache", "node_modules", ".venv", "__pycache__"}
22
+
23
+
24
+ def validate(path: str) -> list[Finding]:
25
+ """Every finding for one file. A refusal that stops the file being read at
26
+ all arrives as a Malformed and becomes a single finding."""
27
+ if path.lower().endswith(CSV_EXT):
28
+ try:
29
+ return check_file(path)
30
+ except Malformed as e:
31
+ return [Finding("refused", str(e))]
32
+ try:
33
+ text = open(path, encoding="utf-8", newline="").read()
34
+ except UnicodeDecodeError as e:
35
+ return [Finding("encoding", f"not valid UTF-8: {e}")]
36
+ try:
37
+ evaluate(text)
38
+ except Malformed as e:
39
+ return [Finding("refused", str(e))]
40
+ return []
41
+
42
+
43
+ def collect(targets: list[str]) -> list[str]:
44
+ files: list[str] = []
45
+ for target in targets:
46
+ if os.path.isdir(target):
47
+ for dp, dn, fn in os.walk(target):
48
+ dn[:] = [d for d in dn if d not in SKIP_DIRS]
49
+ files += [
50
+ os.path.join(dp, f) for f in fn if f.lower().endswith(CSV_EXT + TABLE_EXT)
51
+ ]
52
+ else:
53
+ files.append(target)
54
+ return sorted(files)
55
+
56
+
57
+ def _print_plain(path, findings, explained, out):
58
+ for f in findings:
59
+ tag = "" if f.level == "refuse" else "warning: "
60
+ print(f"{path}: {tag}{f}", file=out)
61
+ if f.detail and f.rule not in explained:
62
+ explained.add(f.rule)
63
+ print(f" {f.detail}", file=out)
64
+
65
+
66
+ def _print_github(path, findings, explained, out):
67
+ """GitHub Actions annotations. The message still names the entity; the file
68
+ is the only location given, because a line number would be a coordinate and
69
+ would be wrong by the time anyone reads it."""
70
+ del explained
71
+ for f in findings:
72
+ kind = "error" if f.level == "refuse" else "warning"
73
+ msg = str(f).replace("\n", " ")
74
+ if f.detail:
75
+ msg += " — " + f.detail
76
+ print(f"::{kind} file={path},title=rowspec::{msg}", file=out)
77
+
78
+
79
+ def _offending_cell(rows, col):
80
+ """The first cell in `col` that will not parse as a number, if any."""
81
+ from .table import num
82
+
83
+ for r in rows:
84
+ v = r.get(col)
85
+ if v in ("", None) or not isinstance(v, str):
86
+ continue
87
+ try:
88
+ num(v)
89
+ except (ValueError, TypeError):
90
+ return v
91
+ return None
92
+
93
+
94
+ def _fmt_value(v):
95
+ if isinstance(v, float):
96
+ if v == int(v):
97
+ return f"{int(v)}"
98
+ r = round(v, 10)
99
+ return f"{r:.10f}".rstrip("0").rstrip(".")
100
+ return "" if v is None else str(v)
101
+
102
+
103
+ def cmd_eval(paths, out=sys.stdout, fmt="plain") -> int:
104
+ """Print computed columns and aggregates, and FAIL on any #REF!.
105
+
106
+ Validation alone reports "0 refused" on a table whose total is wrong: a
107
+ misspelled column, a thousands separator and a non-ASCII space all become
108
+ #REF! under §8 and are correctly NOT §9 refusals. Without this command the
109
+ one thing the format does that a CSV cannot is invisible, and a CI recipe
110
+ built on `check` is green on a broken total.
111
+ """
112
+ bad = 0
113
+ for path in collect(paths):
114
+ try:
115
+ rows, aggs = evaluate(open(path, encoding="utf-8", newline="").read())
116
+ cols, formulas, _r, decls, _o, _k = parse(
117
+ open(path, encoding="utf-8", newline="").read()
118
+ )
119
+ except Malformed as e:
120
+ if not path.lower().endswith(TABLE_EXT):
121
+ continue # `eval` is for tables; `check` owns everything else
122
+ print(f"{path}: {e}", file=sys.stderr)
123
+ bad += 1
124
+ continue
125
+
126
+ refs = []
127
+ computed = [c for c in cols if c in formulas]
128
+ print(f"{path}", file=out)
129
+ if computed and rows:
130
+ kw = max(len(str(r.get(_k, ""))) for r in rows) if _k else 0
131
+ cw = max(len(c) for c in computed)
132
+ for r in rows:
133
+ cells = []
134
+ for c in computed:
135
+ v = r.get(c)
136
+ if isinstance(v, str) and v.startswith("#REF!"):
137
+ refs.append((r.get(_k) if _k else "?", c, v))
138
+ cells.append(f"{c}={_fmt_value(v):>{cw}}")
139
+ rk = f"{str(r.get(_k, '')):<{kw}} " if _k else ""
140
+ print(f" {rk}{' '.join(cells)}", file=out)
141
+ for name, v in aggs.items():
142
+ marker = " <-- ERROR" if isinstance(v, str) and v.startswith("#REF!") else ""
143
+ print(f" {name} = {_fmt_value(v)}{marker}", file=out)
144
+ if marker:
145
+ refs.append((None, name, v))
146
+ if refs:
147
+ bad += 1
148
+ if fmt == "github":
149
+ # Without this, `eval` failures reach CI as a bare exit code
150
+ # with nothing attached to a file, so the one thing this format
151
+ # does that a CSV cannot is invisible in the place it matters.
152
+ for rowkey, col, v in refs:
153
+ where = f"row {rowkey}, " if rowkey is not None else ""
154
+ print(
155
+ f"::error file={path},title=rowspec::{where}{col} = {v}"
156
+ f" — a computed value did not resolve. `check` does not"
157
+ f" see this: the file is well formed and its total is"
158
+ f" wrong.",
159
+ file=out,
160
+ )
161
+ print(f" {len(refs)} unresolved reference(s):", file=sys.stderr)
162
+ seen = set()
163
+ for rowkey, col, v in refs:
164
+ sig = (col, v)
165
+ if sig in seen:
166
+ continue
167
+ seen.add(sig)
168
+ where = f"row {rowkey}, " if rowkey else ""
169
+ print(f" {where}{col} = {v}", file=sys.stderr)
170
+ name = v[len("#REF!(") : -1] if v.startswith("#REF!(") else ""
171
+ cell = _offending_cell(rows, name)
172
+ if cell is not None:
173
+ print(f" {_why_not_a_number(cell)}", file=sys.stderr)
174
+ return 1 if bad else 0
175
+
176
+
177
+ def cmd_add_row(path: str, values: list[str], out=sys.stdout) -> int:
178
+ """Append a row, minting the opaque id §6 requires and nothing produced.
179
+
180
+ The spec insists row ids are machine-generated; before this, they were hand
181
+ typed, which is how a cross-branch id collision got made by hand.
182
+ """
183
+ import secrets
184
+
185
+ text = open(path, encoding="utf-8", newline="").read()
186
+ cols, formulas, rows, _d, _o, key = parse(text)
187
+ if key is None:
188
+ print(f"{path}: no `key :=` declaration, so there is no id to mint", file=sys.stderr)
189
+ return 1
190
+ taken = {str(r.get(key)) for r in rows}
191
+ while True:
192
+ rid = "r_" + secrets.token_hex(3)
193
+ if rid not in taken:
194
+ break
195
+ fillable = [c for c in cols if c != key and c not in formulas]
196
+ if len(values) != len(fillable):
197
+ print(
198
+ f"{path}: expected {len(fillable)} value(s) for {', '.join(fillable)}; "
199
+ f"got {len(values)}",
200
+ file=sys.stderr,
201
+ )
202
+ return 1
203
+ supplied = dict(zip(fillable, values, strict=True))
204
+ cells = [rid if c == key else "" if c in formulas else supplied.get(c, "") for c in cols]
205
+ line = "| " + " | ".join(escape_cell(c) for c in cells) + " |"
206
+ lines = text.splitlines(keepends=True)
207
+ last = max(i for i, ln in enumerate(lines) if ln.strip().startswith("|"))
208
+ eol = "\r\n" if lines[last].endswith("\r\n") else "\n"
209
+ lines.insert(last + 1, line + eol)
210
+ open(path, "w", encoding="utf-8", newline="").write("".join(lines))
211
+ print(f"{path}: added {key}={rid}", file=out)
212
+ return 0
213
+
214
+
215
+ def main(argv: list[str] | None = None) -> int:
216
+ argv = list(sys.argv[1:] if argv is None else argv)
217
+ # Pull the subcommand off BEFORE argparse. Leaving it as a `nargs="?"`
218
+ # positional beside a `nargs="+"` one made argparse mis-split
219
+ # `eval --format github DIR` -- it reported "unrecognized arguments: DIR"
220
+ # and the same line worked only with the option written after the path.
221
+ cmd = "check"
222
+ if argv and argv[0] in ("check", "eval", "add-row"):
223
+ cmd = argv.pop(0)
224
+
225
+ p = argparse.ArgumentParser(
226
+ prog="rowspec check", description="Validate .csv, .tsv and .mdtbl tables."
227
+ )
228
+ p.add_argument("paths", nargs="+", help="files or directories")
229
+ p.add_argument("--fmt", action="store_true", help="rewrite .mdtbl files into canonical form")
230
+ p.add_argument(
231
+ "--fmt-check",
232
+ action="store_true",
233
+ help="report files that are not in canonical form, without rewriting them",
234
+ )
235
+ p.add_argument("--strict", action="store_true", help="treat warnings (CRLF, BOM) as refusals")
236
+ p.add_argument("--format", choices=("plain", "github"), default="plain", help="output format")
237
+ a = p.parse_args(argv)
238
+ if cmd == "eval":
239
+ return cmd_eval(a.paths, fmt=a.format)
240
+ if cmd == "add-row":
241
+ return cmd_add_row(a.paths[0], a.paths[1:])
242
+
243
+ files = collect(a.paths)
244
+ emit = _print_github if a.format == "github" else _print_plain
245
+ explained: set[str] = set()
246
+ refused = warned = 0
247
+
248
+ for f in files:
249
+ findings = validate(f)
250
+ errs = [x for x in findings if x.level == "refuse"]
251
+ warns = [x for x in findings if x.level == "warn"]
252
+ if a.strict:
253
+ errs, warns = errs + warns, []
254
+ for x in errs:
255
+ x.level = "refuse"
256
+ if errs or warns:
257
+ emit(f, errs + warns, explained, sys.stderr)
258
+ if errs:
259
+ refused += 1
260
+ elif warns:
261
+ warned += 1
262
+ elif (a.fmt or a.fmt_check) and f.lower().endswith(TABLE_EXT):
263
+ text = open(f, encoding="utf-8", newline="").read()
264
+ c = canon(text)
265
+ if c != text:
266
+ if a.fmt_check:
267
+ print(f"{f}: not in canonical form (run --fmt)", file=sys.stderr)
268
+ refused += 1
269
+ else:
270
+ open(f, "w", encoding="utf-8", newline="").write(c)
271
+ print(f"{f}: reformatted")
272
+
273
+ summary = f"{len(files)} file(s) checked, {refused} refused"
274
+ if warned:
275
+ summary += f", {warned} with warnings"
276
+ print(summary)
277
+ return 1 if refused else 0