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.
Files changed (22) hide show
  1. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/PKG-INFO +18 -5
  2. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/README.md +16 -3
  3. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/cli.py +6 -1
  4. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/gather.py +13 -4
  5. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/setdiff.py +36 -3
  6. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/PKG-INFO +18 -5
  7. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/requires.txt +1 -1
  8. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/pyproject.toml +2 -2
  9. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/tests/test_cli.py +31 -0
  10. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/tests/test_setdiff.py +86 -0
  11. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/LICENSE +0 -0
  12. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/__init__.py +0 -0
  13. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/__main__.py +0 -0
  14. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/baseline.py +0 -0
  15. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/paths.py +0 -0
  16. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli/profile.py +0 -0
  17. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/SOURCES.txt +0 -0
  18. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/dependency_links.txt +0 -0
  19. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/entry_points.txt +0 -0
  20. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/assurance_cli.egg-info/top_level.txt +0 -0
  21. {assurance_cli-0.2.1 → assurance_cli-0.2.2}/setup.cfg +0 -0
  22. {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.1
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` | ran, no gap — or not asked to fail on one |
111
- | `1` | a finding: a gap with `--fail-on-gap`, or a baseline that no longer holds |
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` | ran, no gap — or not asked to fail on one |
84
- | `1` | a finding: a gap with `--fail-on-gap`, or a baseline that no longer holds |
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
- return bool(coverage.get("error"))
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
- text = candidate.read_text(encoding="utf-8")
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
- return [
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
- value = str(value).strip()
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.1
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` | ran, no gap — or not asked to fail on one |
111
- | `1` | a finding: a gap with `--fail-on-gap`, or a baseline that no longer holds |
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 |
@@ -1,4 +1,4 @@
1
- assurance-core>=0.3
1
+ assurance-core>=0.3.2
2
2
  openpyxl>=3.1
3
3
 
4
4
  [dev]
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assurance-cli"
7
- version = "0.2.1"
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