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 +23 -0
- rowspec/__main__.py +3 -0
- rowspec/cli.py +277 -0
- rowspec/csvmode.py +476 -0
- rowspec/sidecar.py +160 -0
- rowspec/table.py +1297 -0
- rowspec-0.1.0.dist-info/METADATA +167 -0
- rowspec-0.1.0.dist-info/RECORD +16 -0
- rowspec-0.1.0.dist-info/WHEEL +4 -0
- rowspec-0.1.0.dist-info/entry_points.txt +2 -0
- rowspec-0.1.0.dist-info/licenses/LICENSE +26 -0
- rowspec-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +73 -0
- rowspec-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +156 -0
- rowspec-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +121 -0
- rowspec-0.1.0.dist-info/licenses/LICENSES/MIT.txt +21 -0
- rowspec-0.1.0.dist-info/licenses/reference/LICENSE +1 -0
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
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
|