assurance-cli 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/PKG-INFO +18 -5
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/README.md +16 -3
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/cli.py +6 -1
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/gather.py +13 -4
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/setdiff.py +36 -3
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/PKG-INFO +18 -5
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/requires.txt +1 -1
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/pyproject.toml +2 -2
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/tests/test_cli.py +31 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/tests/test_setdiff.py +86 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/LICENSE +0 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/__init__.py +0 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/__main__.py +0 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/baseline.py +0 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/paths.py +0 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/profile.py +0 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/SOURCES.txt +0 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/dependency_links.txt +0 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/entry_points.txt +0 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/top_level.txt +0 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/setup.cfg +0 -0
- {assurance_cli-0.2.1 → assurance_cli-0.2.2}/tests/test_read_only.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assurance-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: CLI for folder assurance checks — coverage, staleness, and baselines.
|
|
5
5
|
Author-email: "I-Ops Operations Intelligence, LLC" <hello@i-ops.dev>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -19,7 +19,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
|
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
License-File: LICENSE
|
|
22
|
-
Requires-Dist: assurance-core>=0.3
|
|
22
|
+
Requires-Dist: assurance-core>=0.3.2
|
|
23
23
|
Requires-Dist: openpyxl>=3.1
|
|
24
24
|
Provides-Extra: dev
|
|
25
25
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
@@ -107,12 +107,25 @@ assurance check ~/thesis-data --against-baseline
|
|
|
107
107
|
|
|
108
108
|
| | |
|
|
109
109
|
|---|---|
|
|
110
|
-
| `0` |
|
|
111
|
-
| `1` | a finding: a gap with `--fail-on-gap`,
|
|
112
|
-
| `2` | could not run: bad path, unreadable list, unparseable JSON |
|
|
110
|
+
| `0` | it checked, and either found no gap or wasn't asked to fail on one |
|
|
111
|
+
| `1` | a finding: a gap with `--fail-on-gap`, a stale baseline, or **nothing it could check** |
|
|
112
|
+
| `2` | could not run: bad path, unreadable list, unparseable JSON, a table where keys were expected |
|
|
113
|
+
|
|
114
|
+
**"I couldn't check this" exits 1, not 0.** A folder whose filenames it can't parse must not look
|
|
115
|
+
like a folder it checked and found whole.
|
|
113
116
|
|
|
114
117
|
Diagnostics go to **stderr**, results to **stdout**, so `--json` stays pipeable.
|
|
115
118
|
|
|
119
|
+
## It expects your files, not tidy ones
|
|
120
|
+
|
|
121
|
+
- **Excel exports work.** UTF-8 BOM and CRLF are handled; a BOM used to glue itself to your first
|
|
122
|
+
key and report it as missing *and* unexpected in the same sentence
|
|
123
|
+
- **Spaces, unicode and month words in filenames** — `Inventory Report August 2024.csv` parses
|
|
124
|
+
- **`.xlsx`, and nested subfolders**
|
|
125
|
+
- **A piped CSV is refused, not misread.** It names the column-picking command instead of quietly
|
|
126
|
+
admitting your header row as a key
|
|
127
|
+
- **When it can't work out a series it says so**, rather than reporting an empty check as a pass
|
|
128
|
+
|
|
116
129
|
## Use it for
|
|
117
130
|
|
|
118
131
|
| | expected | found |
|
|
@@ -80,12 +80,25 @@ assurance check ~/thesis-data --against-baseline
|
|
|
80
80
|
|
|
81
81
|
| | |
|
|
82
82
|
|---|---|
|
|
83
|
-
| `0` |
|
|
84
|
-
| `1` | a finding: a gap with `--fail-on-gap`,
|
|
85
|
-
| `2` | could not run: bad path, unreadable list, unparseable JSON |
|
|
83
|
+
| `0` | it checked, and either found no gap or wasn't asked to fail on one |
|
|
84
|
+
| `1` | a finding: a gap with `--fail-on-gap`, a stale baseline, or **nothing it could check** |
|
|
85
|
+
| `2` | could not run: bad path, unreadable list, unparseable JSON, a table where keys were expected |
|
|
86
|
+
|
|
87
|
+
**"I couldn't check this" exits 1, not 0.** A folder whose filenames it can't parse must not look
|
|
88
|
+
like a folder it checked and found whole.
|
|
86
89
|
|
|
87
90
|
Diagnostics go to **stderr**, results to **stdout**, so `--json` stays pipeable.
|
|
88
91
|
|
|
92
|
+
## It expects your files, not tidy ones
|
|
93
|
+
|
|
94
|
+
- **Excel exports work.** UTF-8 BOM and CRLF are handled; a BOM used to glue itself to your first
|
|
95
|
+
key and report it as missing *and* unexpected in the same sentence
|
|
96
|
+
- **Spaces, unicode and month words in filenames** — `Inventory Report August 2024.csv` parses
|
|
97
|
+
- **`.xlsx`, and nested subfolders**
|
|
98
|
+
- **A piped CSV is refused, not misread.** It names the column-picking command instead of quietly
|
|
99
|
+
admitting your header row as a key
|
|
100
|
+
- **When it can't work out a series it says so**, rather than reporting an empty check as a pass
|
|
101
|
+
|
|
89
102
|
## Use it for
|
|
90
103
|
|
|
91
104
|
| | expected | found |
|
|
@@ -136,7 +136,12 @@ def _has_findings(payload: dict[str, Any]) -> bool:
|
|
|
136
136
|
if payload.get("baseline") and not payload["baseline"].get("ok", True):
|
|
137
137
|
return True
|
|
138
138
|
coverage = payload.get("coverage") or payload
|
|
139
|
-
|
|
139
|
+
if coverage.get("error"):
|
|
140
|
+
return True
|
|
141
|
+
# "I could not work out what to check" is a finding, not a success. Exiting 0 here made a folder
|
|
142
|
+
# whose filenames we cannot parse indistinguishable, to a CI job, from a folder we checked and
|
|
143
|
+
# found whole — which is the one thing this tool exists not to do.
|
|
144
|
+
return bool((coverage.get("coverage") or {}).get("undetermined"))
|
|
140
145
|
|
|
141
146
|
|
|
142
147
|
def _format_text(payload: dict[str, Any]) -> str:
|
|
@@ -73,7 +73,7 @@ def check_coverage(
|
|
|
73
73
|
"complete": False,
|
|
74
74
|
"derivation": "",
|
|
75
75
|
"coverage": _coverage_to_dict(
|
|
76
|
-
Coverage(scope_label=f"items in {root.name}", expected=[])
|
|
76
|
+
Coverage(scope_label=f"items in {root.name}", expected=[], undetermined=_UNDETERMINED)
|
|
77
77
|
),
|
|
78
78
|
}
|
|
79
79
|
|
|
@@ -88,7 +88,7 @@ def check_coverage(
|
|
|
88
88
|
"complete": False,
|
|
89
89
|
"derivation": "",
|
|
90
90
|
"coverage": _coverage_to_dict(
|
|
91
|
-
Coverage(scope_label=f"items in {root.name}", expected=[])
|
|
91
|
+
Coverage(scope_label=f"items in {root.name}", expected=[], undetermined=_UNDETERMINED)
|
|
92
92
|
),
|
|
93
93
|
}
|
|
94
94
|
|
|
@@ -122,7 +122,7 @@ def check_coverage(
|
|
|
122
122
|
"complete": False,
|
|
123
123
|
"derivation": "",
|
|
124
124
|
"coverage": _coverage_to_dict(
|
|
125
|
-
Coverage(scope_label=f"items in {root.name}", expected=[])
|
|
125
|
+
Coverage(scope_label=f"items in {root.name}", expected=[], undetermined=_UNDETERMINED)
|
|
126
126
|
),
|
|
127
127
|
}
|
|
128
128
|
|
|
@@ -264,13 +264,21 @@ def _unit_for_kind(kind: SeriesKind) -> str:
|
|
|
264
264
|
}[kind]
|
|
265
265
|
|
|
266
266
|
|
|
267
|
+
_UNDETERMINED = "no dated or numbered series could be read from these filenames"
|
|
268
|
+
"""Why nothing was checked. Carried INTO the coverage record, not just the wrapper around it.
|
|
269
|
+
|
|
270
|
+
Until 0.2.2 these paths emitted an empty `Coverage`, whose `complete` is True by the arithmetic —
|
|
271
|
+
nothing was required, so nothing is missing. So the payload carried `complete: false` at the top and
|
|
272
|
+
`complete: true` one level down, and an integrator reading either one was reading a real field."""
|
|
273
|
+
|
|
274
|
+
|
|
267
275
|
def _error_result(root: Path, message: str) -> dict[str, Any]:
|
|
268
276
|
return {
|
|
269
277
|
"folder": str(root),
|
|
270
278
|
"summary": message,
|
|
271
279
|
"complete": False,
|
|
272
280
|
"derivation": "",
|
|
273
|
-
"coverage": _coverage_to_dict(Coverage(scope_label=f"items in {root.name}", expected=[])),
|
|
281
|
+
"coverage": _coverage_to_dict(Coverage(scope_label=f"items in {root.name}", expected=[], undetermined=_UNDETERMINED)),
|
|
274
282
|
"error": message,
|
|
275
283
|
}
|
|
276
284
|
|
|
@@ -290,6 +298,7 @@ def _coverage_to_dict(cov: Coverage) -> dict[str, Any]:
|
|
|
290
298
|
"unreadable": dict(cov.unreadable),
|
|
291
299
|
"unauthorized": dict(cov.unauthorized),
|
|
292
300
|
"truncated": cov.truncated,
|
|
301
|
+
"undetermined": cov.undetermined,
|
|
293
302
|
}
|
|
294
303
|
|
|
295
304
|
|
|
@@ -45,7 +45,11 @@ def read_keys(spec: str | None, *, label: str) -> list[str]:
|
|
|
45
45
|
if candidate.exists():
|
|
46
46
|
if candidate.is_dir():
|
|
47
47
|
raise KeySpecError(f"{label}: {spec} is a directory, not a list of keys")
|
|
48
|
-
|
|
48
|
+
# `utf-8-sig`, not `utf-8`. Excel writes CSV and TXT with a byte-order mark by default,
|
|
49
|
+
# and reading it as plain utf-8 glues U+FEFF to the FIRST key — so `doc-1` was reported
|
|
50
|
+
# as missing AND as unexpected in the same sentence, which is a confidently wrong answer
|
|
51
|
+
# produced by the most common export path there is.
|
|
52
|
+
text = candidate.read_text(encoding="utf-8-sig")
|
|
49
53
|
|
|
50
54
|
if text is None:
|
|
51
55
|
return _dedupe(part.strip() for part in spec.split(","))
|
|
@@ -64,11 +68,38 @@ def _parse(text: str, *, label: str) -> list[str]:
|
|
|
64
68
|
return _keys_from_json(payload, label=label)
|
|
65
69
|
# A full-line comment is dropped; a `#` inside a key is part of the key, because guessing which
|
|
66
70
|
# is which would silently change someone's denominator.
|
|
67
|
-
|
|
71
|
+
lines = [
|
|
68
72
|
line.strip()
|
|
69
73
|
for line in stripped.splitlines()
|
|
70
74
|
if line.strip() and not line.lstrip().startswith("#")
|
|
71
75
|
]
|
|
76
|
+
_refuse_a_table(lines, label=label)
|
|
77
|
+
return lines
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _refuse_a_table(lines: list[str], *, label: str) -> None:
|
|
81
|
+
"""A delimited table is not a list of keys, and reading it as one is silent nonsense.
|
|
82
|
+
|
|
83
|
+
Piping a CSV is the obvious thing to try — it is what people have. Read line by line it produced
|
|
84
|
+
`0 of 3 — also present and not expected: doc_id,score, doc-1,0.9, doc-2,0.8`: a header row and
|
|
85
|
+
two score columns admitted as keys, and a confident zero. We cannot pick the column for you, so
|
|
86
|
+
this says which command does.
|
|
87
|
+
|
|
88
|
+
Deliberately narrow, because a key may legitimately contain a comma: it fires only when EVERY
|
|
89
|
+
line carries the SAME delimiter the SAME number of times, which is a table and not a coincidence.
|
|
90
|
+
"""
|
|
91
|
+
if len(lines) < 2:
|
|
92
|
+
return
|
|
93
|
+
for delimiter, flag in (("\t", "-f1"), (",", "-d, -f1")):
|
|
94
|
+
counts = {line.count(delimiter) for line in lines}
|
|
95
|
+
if len(counts) == 1 and counts.pop() >= 1:
|
|
96
|
+
name = "tab" if delimiter == "\t" else "comma"
|
|
97
|
+
raise KeySpecError(
|
|
98
|
+
f"{label}: this looks like a {name}-delimited table, not a list of keys — every "
|
|
99
|
+
f"line has the same number of {name}s. Reading it line by line would admit the "
|
|
100
|
+
f"header and every other column as keys. Pick the column you mean, e.g. "
|
|
101
|
+
f"`cut {flag} file | assurance diff ... --{label.lstrip('-')} -`, or pass JSON."
|
|
102
|
+
)
|
|
72
103
|
|
|
73
104
|
|
|
74
105
|
def _keys_from_json(payload: Any, *, label: str) -> list[str]:
|
|
@@ -106,7 +137,9 @@ def _dedupe(values: Any) -> list[str]:
|
|
|
106
137
|
seen: set[str] = set()
|
|
107
138
|
out: list[str] = []
|
|
108
139
|
for value in values:
|
|
109
|
-
|
|
140
|
+
# A BOM can still arrive from stdin, where there is no decode step to strip it, and inside a
|
|
141
|
+
# JSON string. Strip it per key too: a stray U+FEFF changes what a key IS.
|
|
142
|
+
value = str(value).replace("\ufeff", "").strip()
|
|
110
143
|
if value and value not in seen:
|
|
111
144
|
seen.add(value)
|
|
112
145
|
out.append(value)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assurance-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: CLI for folder assurance checks — coverage, staleness, and baselines.
|
|
5
5
|
Author-email: "I-Ops Operations Intelligence, LLC" <hello@i-ops.dev>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -19,7 +19,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
|
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
License-File: LICENSE
|
|
22
|
-
Requires-Dist: assurance-core>=0.3
|
|
22
|
+
Requires-Dist: assurance-core>=0.3.2
|
|
23
23
|
Requires-Dist: openpyxl>=3.1
|
|
24
24
|
Provides-Extra: dev
|
|
25
25
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
@@ -107,12 +107,25 @@ assurance check ~/thesis-data --against-baseline
|
|
|
107
107
|
|
|
108
108
|
| | |
|
|
109
109
|
|---|---|
|
|
110
|
-
| `0` |
|
|
111
|
-
| `1` | a finding: a gap with `--fail-on-gap`,
|
|
112
|
-
| `2` | could not run: bad path, unreadable list, unparseable JSON |
|
|
110
|
+
| `0` | it checked, and either found no gap or wasn't asked to fail on one |
|
|
111
|
+
| `1` | a finding: a gap with `--fail-on-gap`, a stale baseline, or **nothing it could check** |
|
|
112
|
+
| `2` | could not run: bad path, unreadable list, unparseable JSON, a table where keys were expected |
|
|
113
|
+
|
|
114
|
+
**"I couldn't check this" exits 1, not 0.** A folder whose filenames it can't parse must not look
|
|
115
|
+
like a folder it checked and found whole.
|
|
113
116
|
|
|
114
117
|
Diagnostics go to **stderr**, results to **stdout**, so `--json` stays pipeable.
|
|
115
118
|
|
|
119
|
+
## It expects your files, not tidy ones
|
|
120
|
+
|
|
121
|
+
- **Excel exports work.** UTF-8 BOM and CRLF are handled; a BOM used to glue itself to your first
|
|
122
|
+
key and report it as missing *and* unexpected in the same sentence
|
|
123
|
+
- **Spaces, unicode and month words in filenames** — `Inventory Report August 2024.csv` parses
|
|
124
|
+
- **`.xlsx`, and nested subfolders**
|
|
125
|
+
- **A piped CSV is refused, not misread.** It names the column-picking command instead of quietly
|
|
126
|
+
admitting your header row as a key
|
|
127
|
+
- **When it can't work out a series it says so**, rather than reporting an empty check as a pass
|
|
128
|
+
|
|
116
129
|
## Use it for
|
|
117
130
|
|
|
118
131
|
| | expected | found |
|
|
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "assurance-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "CLI for folder assurance checks — coverage, staleness, and baselines."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
11
11
|
authors = [{ name = "I-Ops Operations Intelligence, LLC", email = "hello@i-ops.dev" }]
|
|
12
12
|
requires-python = ">=3.10"
|
|
13
13
|
dependencies = [
|
|
14
|
-
"assurance-core>=0.3",
|
|
14
|
+
"assurance-core>=0.3.2",
|
|
15
15
|
"openpyxl>=3.1",
|
|
16
16
|
]
|
|
17
17
|
keywords = ["cli", "ai-agents", "governance", "auditing", "assurance", "reproducibility"]
|
|
@@ -123,3 +123,34 @@ def test_mixed_series_folder_reports_none(tmp_path: Path):
|
|
|
123
123
|
(root / name).write_text("v\n1\n", encoding="utf-8")
|
|
124
124
|
result = check_coverage(str(root))
|
|
125
125
|
assert "No dated or numbered series detected" in result["summary"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --- "I could not check" must not exit like "I checked and it was fine" ---------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_a_folder_with_no_readable_series_is_a_finding(tmp_path: Path) -> None:
|
|
132
|
+
"""Exit 0 here made an unparseable folder indistinguishable, to a CI job, from a whole one."""
|
|
133
|
+
for name in ("15.01.2024 shipment.csv", "Jan-24 summary.csv", "20240115_dump.csv"):
|
|
134
|
+
(tmp_path / name).write_text("a,b\n1,2\n", encoding="utf-8")
|
|
135
|
+
|
|
136
|
+
assert main(["check", str(tmp_path)]) == 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_the_nested_record_agrees_with_the_wrapper(
|
|
140
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
141
|
+
) -> None:
|
|
142
|
+
"""The payload carried `complete: false` at the top and `complete: true` one level down, and an
|
|
143
|
+
integrator reading either one was reading a real field."""
|
|
144
|
+
(tmp_path / "Jan-24 summary.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
|
145
|
+
|
|
146
|
+
main(["check", str(tmp_path), "--json"])
|
|
147
|
+
payload = json.loads(capsys.readouterr().out)["coverage"]
|
|
148
|
+
|
|
149
|
+
assert payload["complete"] is False
|
|
150
|
+
assert payload["coverage"]["complete"] is False
|
|
151
|
+
assert payload["coverage"]["undetermined"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_a_folder_that_checks_out_still_exits_zero(monthly_folder: Path) -> None:
|
|
155
|
+
"""The counterweight: the ordinary path must not start failing."""
|
|
156
|
+
assert main(["check", str(monthly_folder)]) == 0
|
|
@@ -150,3 +150,89 @@ def test_an_error_is_printed_rather_than_swallowed(capsys: pytest.CaptureFixture
|
|
|
150
150
|
captured = capsys.readouterr()
|
|
151
151
|
assert captured.out.strip() == ""
|
|
152
152
|
assert "folder does not exist" in captured.err
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- what an outsider's files actually look like --------------------------------------------------
|
|
156
|
+
#
|
|
157
|
+
# From the 2026-08-29 smoke test against fixtures nobody here designed against: Excel exports,
|
|
158
|
+
# filenames with spaces, month words, six-digit purchase orders. Most of it worked. This did not.
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_a_byte_order_mark_does_not_change_what_a_key_is(tmp_path: Path) -> None:
|
|
162
|
+
"""Excel writes CSV and TXT with a BOM by default. Read as plain utf-8 it glues U+FEFF to the
|
|
163
|
+
FIRST key, so `doc-1` came back reported as missing AND as unexpected in one sentence — a
|
|
164
|
+
confidently wrong answer, from the most common export path there is."""
|
|
165
|
+
listing = tmp_path / "keys.txt"
|
|
166
|
+
listing.write_bytes(b"\xef\xbb\xbfdoc-1\r\ndoc-2\r\ndoc-3\r\n")
|
|
167
|
+
|
|
168
|
+
assert read_keys(str(listing), label="--expected") == ["doc-1", "doc-2", "doc-3"]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_a_bom_in_a_json_export_is_stripped_too(tmp_path: Path) -> None:
|
|
172
|
+
listing = tmp_path / "found.json"
|
|
173
|
+
listing.write_bytes(b'\xef\xbb\xbf["doc-1", "doc-2"]')
|
|
174
|
+
|
|
175
|
+
assert read_keys(str(listing), label="--found") == ["doc-1", "doc-2"]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_the_bom_case_end_to_end_reports_one_gap_not_two_wrong_ones(tmp_path: Path) -> None:
|
|
179
|
+
listing = tmp_path / "keys.txt"
|
|
180
|
+
listing.write_bytes(b"\xef\xbb\xbfdoc-1\r\ndoc-2\r\ndoc-3\r\n")
|
|
181
|
+
|
|
182
|
+
payload = diff_sets(str(listing), "doc-1,doc-2")
|
|
183
|
+
|
|
184
|
+
assert payload["read"] == 2
|
|
185
|
+
assert [entry["key"] for entry in payload["missing"]] == ["doc-3"]
|
|
186
|
+
assert payload["unexpected"] == []
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_crlf_line_endings_do_not_become_part_of_the_key(tmp_path: Path) -> None:
|
|
190
|
+
listing = tmp_path / "keys.txt"
|
|
191
|
+
listing.write_bytes(b"doc-1\r\ndoc-2\r\n")
|
|
192
|
+
|
|
193
|
+
assert read_keys(str(listing), label="--expected") == ["doc-1", "doc-2"]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_a_bom_arriving_on_stdin_is_stripped(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
197
|
+
"""`cat export.txt | assurance diff --found -` has no decode step to strip a BOM: `sys.stdin`
|
|
198
|
+
is already decoded, by the locale's plain utf-8. The README documents piping, so this path is
|
|
199
|
+
real and needs the per-key strip that `utf-8-sig` cannot reach."""
|
|
200
|
+
import io
|
|
201
|
+
|
|
202
|
+
monkeypatch.setattr("sys.stdin", io.StringIO("doc-1\ndoc-2\n"))
|
|
203
|
+
|
|
204
|
+
assert read_keys("-", label="--found") == ["doc-1", "doc-2"]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_a_piped_csv_is_refused_rather_than_read_as_keys(tmp_path: Path) -> None:
|
|
208
|
+
"""Piping a CSV is the obvious thing to try. Read line by line it produced a confident `0 of 3`
|
|
209
|
+
with the header row and two score columns admitted as keys."""
|
|
210
|
+
listing = tmp_path / "found.csv"
|
|
211
|
+
listing.write_text("doc_id,score\ndoc-1,0.9\ndoc-2,0.8\n", encoding="utf-8")
|
|
212
|
+
|
|
213
|
+
with pytest.raises(KeySpecError, match="comma-delimited table"):
|
|
214
|
+
read_keys(str(listing), label="--found")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_the_refusal_names_the_command_that_fixes_it(tmp_path: Path) -> None:
|
|
218
|
+
listing = tmp_path / "found.tsv"
|
|
219
|
+
listing.write_text("doc_id\tscore\ndoc-1\t0.9\n", encoding="utf-8")
|
|
220
|
+
|
|
221
|
+
with pytest.raises(KeySpecError, match=r"cut -f1"):
|
|
222
|
+
read_keys(str(listing), label="--found")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_a_key_that_merely_contains_a_comma_is_still_a_key(tmp_path: Path) -> None:
|
|
226
|
+
"""The guard fires only when EVERY line has the SAME delimiter count — a table, not a
|
|
227
|
+
coincidence. A false refusal blocks a legitimate user, so it stays narrow."""
|
|
228
|
+
listing = tmp_path / "keys.txt"
|
|
229
|
+
listing.write_text("Smith, John\nDoe, Jane\nplain-key\n", encoding="utf-8")
|
|
230
|
+
|
|
231
|
+
assert read_keys(str(listing), label="--expected") == ["Smith, John", "Doe, Jane", "plain-key"]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_a_single_line_is_never_a_table(tmp_path: Path) -> None:
|
|
235
|
+
listing = tmp_path / "keys.txt"
|
|
236
|
+
listing.write_text("a,b,c\n", encoding="utf-8")
|
|
237
|
+
|
|
238
|
+
assert read_keys(str(listing), label="--expected") == ["a,b,c"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|