TSVZ 4.2__tar.gz → 4.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TSVZ
3
- Version: 4.2
3
+ Version: 4.3
4
4
  Summary: Key-value store kept in a TSV / CSV / NSV / PSV file, with tsvz-spec-v1 support
5
5
  Home-page: https://github.com/yufei-pan/TSVZ
6
6
  Author: Yufei Pan
@@ -12,7 +12,6 @@ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (G
12
12
  Classifier: Operating System :: OS Independent
13
13
  Requires-Python: >=3.6
14
14
  Description-Content-Type: text/markdown
15
- Requires-Dist: argparse
16
15
  Provides-Extra: completion
17
16
  Requires-Dist: argcomplete; extra == "completion"
18
17
  Dynamic: author
@@ -23,7 +22,6 @@ Dynamic: description-content-type
23
22
  Dynamic: home-page
24
23
  Dynamic: license
25
24
  Dynamic: provides-extra
26
- Dynamic: requires-dist
27
25
  Dynamic: requires-python
28
26
  Dynamic: summary
29
27
 
@@ -36,7 +34,7 @@ key-value store in a delimiter-separated text file**. The first column of each
36
34
  row is the key; the file behaves like an ordered dictionary that is persisted to
37
35
  disk as you change it.
38
36
 
39
- **Version 4.2**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
37
+ **Version 4.3**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
40
38
  next to your code and `import TSVZ`, or install it.
41
39
 
42
40
  ```bash
@@ -258,16 +256,14 @@ and `-d=,`, except the ones C5 and C9 list.
258
256
 
259
257
  ## Spec deviations and interpretations
260
258
 
261
- - §19.2.3c: scrub keeps the header comment (3.39 header verification needs it).
259
+ None of these fails a conformance case: `conformance/reference-baseline.json`,
260
+ which lists the cases this implementation does not pass, is empty (see
261
+ Conformance testing).
262
+
262
263
  - §17.5: a numbered part opened directly is read alone, with a warning.
263
- - §4.1: a leading byte order mark is tolerated (stripped, with a warning).
264
- - A leading byte order mark is not part of any `#_checksum_*_#` segment.
265
- - §12.4.1: boolean marker values also accept `yes/no/on/off/1/0`; TSVZ writes `true`/`false`.
266
264
  - §14.5: the defaults-on-missing read applies to `t[key]` only.
267
265
  - §18.6: locking is the 3.39 exclusive `lockf` per write plus an in-process lock; readers do not lock.
268
266
  - §19.4: a scrub whose rows would read differently under the final markers writes them with stripping and fill-empty off, then restores the final markers after the rows.
269
- - Invalid UTF-8 is replaced with U+FFFD (and reported).
270
- - v1 has no `<CR>` token, so a value whose last cell ends in `\r` loses it; values set through `TSVZed` are right-stripped, so this only affects rows passed raw to the append helpers.
271
267
  - §20.7: `-q` also silences informational messages ("Created …"); only errors remain.
272
268
  - §20.2: `scrub` of a multi-part store, and `clear` of one part of one, are refused with exit 1; `read`, `get` and `parts` of a numbered part read that part alone, with a warning.
273
269
  - §20.2.4: on `.tsv`-family files `get` prints nothing for a missing key, `verify` checks nothing, and `parts` lists the file.
@@ -276,7 +272,6 @@ and `-d=,`, except the ones C5 and C9 list.
276
272
  - §21: a served `.tsv` file with a header line shows that line as a row, as `tsvz read` does (`TSVZed` hides it when given the header).
277
273
  - §21.8: the server notices changes with `stat()`. It re-reads the part list while the store's directory changed less than 2 s ago, and reloads a part whose size stayed the same but whose inode or modification time changed. A rewrite in place that keeps a part's size and modification time is not seen until the part changes again.
278
274
  - §21.9: `tsvz` commands and `TSVZClient` ask the server to answer writes once they are written (`--x-written`), so a write the server cannot make fails as it would on the files. While writes fail, every answer carries a warning.
279
- - §17.7: the server appends to the store's current part; it does not start a new part when it starts.
280
275
  - §21.3: `tsvz serve` needs Unix-domain sockets. Where Python has none (Windows), it exits 1, and `tsvz` commands and `TSVZClient` work on the files.
281
276
  - §20.7: on `.tsv`-family files a `-d` longer than one character is used, as in 3.39, with a warning; a `-d` that does not decode is a usage error.
282
277
 
@@ -293,11 +288,67 @@ python3 website/tsvz_site.py build /tmp/site # static files for any host
293
288
 
294
289
  `deploy/tsvz-site.service` runs it under systemd; restart it to publish an edit.
295
290
 
291
+ ## Conformance testing
292
+
293
+ `tsvzconform.py` tests how closely an implementation of tsvz-spec-v1, in any
294
+ language, follows the spec. Its cases are JSON files in `conformance/cases/`,
295
+ each naming the § clauses it tests, and it drives an implementation only
296
+ through the spec's command line (§20) and handler socket (§21).
297
+
298
+ ```bash
299
+ python3 tsvzconform.py --impl ./tsvz --feature crc32,sha256,handler
300
+ python3 tsvzconform.py --impl ./tsvz --class reader --only 13
301
+ ```
302
+
303
+ Each failure is printed with its input, what was expected, what came back and
304
+ a command line that reruns it alone; the summary gives a verdict for each
305
+ conformance class (reader, writer, cli, handler). `conformance/README.md`
306
+ explains the options and the features to claim, and how to load the cases into
307
+ another language's tests.
308
+
309
+ ## Benchmarking implementations
310
+
311
+ `tsvzbench.py` compares implementations of tsvz-spec-v1, in any language, side
312
+ by side on one machine. It drives each one only through the spec's command
313
+ line (§20) and, when it has one, its handler socket (§21), so any conformant
314
+ `tsvz` tool can be measured as it is.
315
+
316
+ ```bash
317
+ python3 tsvzbench.py --impl ref='python3 TSVZ.py' --impl new='../tsvz-rs/target/release/tsvz' \
318
+ --suite quick --work-dir /mnt/fast-disk/tsvzbench
319
+ ```
320
+
321
+ - `--impl NAME=COMMAND`, repeatable: the first is the baseline, and every other
322
+ implementation gets a `×` ratio against it (below 1 is faster). A relative
323
+ COMMAND runs from the directory you start tsvzbench in.
324
+ - `--suite smoke|quick|standard|large`: `smoke` checks a setup in seconds,
325
+ `quick` takes about a minute, `standard` (the default, up to 1M rows) about
326
+ five, and `large` (up to 10M rows) twenty or more and ~5 GB of memory for the
327
+ harness itself.
328
+ - `--only cold,bulk,perop,maint` picks families: one-shot replays, `set -` and
329
+ `delete -` batches, single operations (CLI and handler), and scrub/clear.
330
+ - `--work-dir DIR` keeps the generated fixtures between runs. Put it on the disk
331
+ you mean to measure: not a tmpfs, and not a FUSE file system such as mergerfs
332
+ (the harness warns about those). The default is a `tsvzbench-*` directory here.
333
+ - `--json PATH` also writes every raw sample.
334
+
335
+ The harness writes its own input stores, so every implementation reads the same
336
+ bytes, and checks every result against its own replay model: `✓` right, `✗`
337
+ wrong output or files, `! N` an unexpected exit status, `⏱` timed out, `refused`
338
+ an allowed refusal. It exits 1 when any cell failed. Each command runs under a
339
+ small launcher that measures it with `wait4`; a peak RSS shown as `≤8.0 MiB` is
340
+ the launcher's own size, so the true peak is no higher. Compare an
341
+ implementation with itself to see the noise: a few percent per cell, but the
342
+ socket rows, whose latencies are tens of microseconds, can move 10–20% between
343
+ runs, because a single run can land in a slower mode as a whole.
344
+
296
345
  ## Tests
297
346
 
298
347
  ```bash
299
348
  python3 -m pytest TSVZ_test.py -q # everything, incl. 3.39 differential tests
300
349
  python3 -m pytest website/tsvz_site_test.py -q # the tsvz.org site
350
+ python3 -m pytest tsvzbench_test.py -q # the benchmark harness
351
+ python3 -m pytest tsvzconform_test.py -q # the conformance runner and cases, against the reference
301
352
  python3 benchTSVZ.py /tmp/b.tsv -n 100000 --compare-old # benchmark (not a test)
302
353
  ```
303
354
 
@@ -7,7 +7,7 @@ key-value store in a delimiter-separated text file**. The first column of each
7
7
  row is the key; the file behaves like an ordered dictionary that is persisted to
8
8
  disk as you change it.
9
9
 
10
- **Version 4.2**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
10
+ **Version 4.3**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
11
11
  next to your code and `import TSVZ`, or install it.
12
12
 
13
13
  ```bash
@@ -229,16 +229,14 @@ and `-d=,`, except the ones C5 and C9 list.
229
229
 
230
230
  ## Spec deviations and interpretations
231
231
 
232
- - §19.2.3c: scrub keeps the header comment (3.39 header verification needs it).
232
+ None of these fails a conformance case: `conformance/reference-baseline.json`,
233
+ which lists the cases this implementation does not pass, is empty (see
234
+ Conformance testing).
235
+
233
236
  - §17.5: a numbered part opened directly is read alone, with a warning.
234
- - §4.1: a leading byte order mark is tolerated (stripped, with a warning).
235
- - A leading byte order mark is not part of any `#_checksum_*_#` segment.
236
- - §12.4.1: boolean marker values also accept `yes/no/on/off/1/0`; TSVZ writes `true`/`false`.
237
237
  - §14.5: the defaults-on-missing read applies to `t[key]` only.
238
238
  - §18.6: locking is the 3.39 exclusive `lockf` per write plus an in-process lock; readers do not lock.
239
239
  - §19.4: a scrub whose rows would read differently under the final markers writes them with stripping and fill-empty off, then restores the final markers after the rows.
240
- - Invalid UTF-8 is replaced with U+FFFD (and reported).
241
- - v1 has no `<CR>` token, so a value whose last cell ends in `\r` loses it; values set through `TSVZed` are right-stripped, so this only affects rows passed raw to the append helpers.
242
240
  - §20.7: `-q` also silences informational messages ("Created …"); only errors remain.
243
241
  - §20.2: `scrub` of a multi-part store, and `clear` of one part of one, are refused with exit 1; `read`, `get` and `parts` of a numbered part read that part alone, with a warning.
244
242
  - §20.2.4: on `.tsv`-family files `get` prints nothing for a missing key, `verify` checks nothing, and `parts` lists the file.
@@ -247,7 +245,6 @@ and `-d=,`, except the ones C5 and C9 list.
247
245
  - §21: a served `.tsv` file with a header line shows that line as a row, as `tsvz read` does (`TSVZed` hides it when given the header).
248
246
  - §21.8: the server notices changes with `stat()`. It re-reads the part list while the store's directory changed less than 2 s ago, and reloads a part whose size stayed the same but whose inode or modification time changed. A rewrite in place that keeps a part's size and modification time is not seen until the part changes again.
249
247
  - §21.9: `tsvz` commands and `TSVZClient` ask the server to answer writes once they are written (`--x-written`), so a write the server cannot make fails as it would on the files. While writes fail, every answer carries a warning.
250
- - §17.7: the server appends to the store's current part; it does not start a new part when it starts.
251
248
  - §21.3: `tsvz serve` needs Unix-domain sockets. Where Python has none (Windows), it exits 1, and `tsvz` commands and `TSVZClient` work on the files.
252
249
  - §20.7: on `.tsv`-family files a `-d` longer than one character is used, as in 3.39, with a warning; a `-d` that does not decode is a usage error.
253
250
 
@@ -264,11 +261,67 @@ python3 website/tsvz_site.py build /tmp/site # static files for any host
264
261
 
265
262
  `deploy/tsvz-site.service` runs it under systemd; restart it to publish an edit.
266
263
 
264
+ ## Conformance testing
265
+
266
+ `tsvzconform.py` tests how closely an implementation of tsvz-spec-v1, in any
267
+ language, follows the spec. Its cases are JSON files in `conformance/cases/`,
268
+ each naming the § clauses it tests, and it drives an implementation only
269
+ through the spec's command line (§20) and handler socket (§21).
270
+
271
+ ```bash
272
+ python3 tsvzconform.py --impl ./tsvz --feature crc32,sha256,handler
273
+ python3 tsvzconform.py --impl ./tsvz --class reader --only 13
274
+ ```
275
+
276
+ Each failure is printed with its input, what was expected, what came back and
277
+ a command line that reruns it alone; the summary gives a verdict for each
278
+ conformance class (reader, writer, cli, handler). `conformance/README.md`
279
+ explains the options and the features to claim, and how to load the cases into
280
+ another language's tests.
281
+
282
+ ## Benchmarking implementations
283
+
284
+ `tsvzbench.py` compares implementations of tsvz-spec-v1, in any language, side
285
+ by side on one machine. It drives each one only through the spec's command
286
+ line (§20) and, when it has one, its handler socket (§21), so any conformant
287
+ `tsvz` tool can be measured as it is.
288
+
289
+ ```bash
290
+ python3 tsvzbench.py --impl ref='python3 TSVZ.py' --impl new='../tsvz-rs/target/release/tsvz' \
291
+ --suite quick --work-dir /mnt/fast-disk/tsvzbench
292
+ ```
293
+
294
+ - `--impl NAME=COMMAND`, repeatable: the first is the baseline, and every other
295
+ implementation gets a `×` ratio against it (below 1 is faster). A relative
296
+ COMMAND runs from the directory you start tsvzbench in.
297
+ - `--suite smoke|quick|standard|large`: `smoke` checks a setup in seconds,
298
+ `quick` takes about a minute, `standard` (the default, up to 1M rows) about
299
+ five, and `large` (up to 10M rows) twenty or more and ~5 GB of memory for the
300
+ harness itself.
301
+ - `--only cold,bulk,perop,maint` picks families: one-shot replays, `set -` and
302
+ `delete -` batches, single operations (CLI and handler), and scrub/clear.
303
+ - `--work-dir DIR` keeps the generated fixtures between runs. Put it on the disk
304
+ you mean to measure: not a tmpfs, and not a FUSE file system such as mergerfs
305
+ (the harness warns about those). The default is a `tsvzbench-*` directory here.
306
+ - `--json PATH` also writes every raw sample.
307
+
308
+ The harness writes its own input stores, so every implementation reads the same
309
+ bytes, and checks every result against its own replay model: `✓` right, `✗`
310
+ wrong output or files, `! N` an unexpected exit status, `⏱` timed out, `refused`
311
+ an allowed refusal. It exits 1 when any cell failed. Each command runs under a
312
+ small launcher that measures it with `wait4`; a peak RSS shown as `≤8.0 MiB` is
313
+ the launcher's own size, so the true peak is no higher. Compare an
314
+ implementation with itself to see the noise: a few percent per cell, but the
315
+ socket rows, whose latencies are tens of microseconds, can move 10–20% between
316
+ runs, because a single run can land in a slower mode as a whole.
317
+
267
318
  ## Tests
268
319
 
269
320
  ```bash
270
321
  python3 -m pytest TSVZ_test.py -q # everything, incl. 3.39 differential tests
271
322
  python3 -m pytest website/tsvz_site_test.py -q # the tsvz.org site
323
+ python3 -m pytest tsvzbench_test.py -q # the benchmark harness
324
+ python3 -m pytest tsvzconform_test.py -q # the conformance runner and cases, against the reference
272
325
  python3 benchTSVZ.py /tmp/b.tsv -n 100000 --compare-old # benchmark (not a test)
273
326
  ```
274
327
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TSVZ
3
- Version: 4.2
3
+ Version: 4.3
4
4
  Summary: Key-value store kept in a TSV / CSV / NSV / PSV file, with tsvz-spec-v1 support
5
5
  Home-page: https://github.com/yufei-pan/TSVZ
6
6
  Author: Yufei Pan
@@ -12,7 +12,6 @@ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (G
12
12
  Classifier: Operating System :: OS Independent
13
13
  Requires-Python: >=3.6
14
14
  Description-Content-Type: text/markdown
15
- Requires-Dist: argparse
16
15
  Provides-Extra: completion
17
16
  Requires-Dist: argcomplete; extra == "completion"
18
17
  Dynamic: author
@@ -23,7 +22,6 @@ Dynamic: description-content-type
23
22
  Dynamic: home-page
24
23
  Dynamic: license
25
24
  Dynamic: provides-extra
26
- Dynamic: requires-dist
27
25
  Dynamic: requires-python
28
26
  Dynamic: summary
29
27
 
@@ -36,7 +34,7 @@ key-value store in a delimiter-separated text file**. The first column of each
36
34
  row is the key; the file behaves like an ordered dictionary that is persisted to
37
35
  disk as you change it.
38
36
 
39
- **Version 4.2**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
37
+ **Version 4.3**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
40
38
  next to your code and `import TSVZ`, or install it.
41
39
 
42
40
  ```bash
@@ -258,16 +256,14 @@ and `-d=,`, except the ones C5 and C9 list.
258
256
 
259
257
  ## Spec deviations and interpretations
260
258
 
261
- - §19.2.3c: scrub keeps the header comment (3.39 header verification needs it).
259
+ None of these fails a conformance case: `conformance/reference-baseline.json`,
260
+ which lists the cases this implementation does not pass, is empty (see
261
+ Conformance testing).
262
+
262
263
  - §17.5: a numbered part opened directly is read alone, with a warning.
263
- - §4.1: a leading byte order mark is tolerated (stripped, with a warning).
264
- - A leading byte order mark is not part of any `#_checksum_*_#` segment.
265
- - §12.4.1: boolean marker values also accept `yes/no/on/off/1/0`; TSVZ writes `true`/`false`.
266
264
  - §14.5: the defaults-on-missing read applies to `t[key]` only.
267
265
  - §18.6: locking is the 3.39 exclusive `lockf` per write plus an in-process lock; readers do not lock.
268
266
  - §19.4: a scrub whose rows would read differently under the final markers writes them with stripping and fill-empty off, then restores the final markers after the rows.
269
- - Invalid UTF-8 is replaced with U+FFFD (and reported).
270
- - v1 has no `<CR>` token, so a value whose last cell ends in `\r` loses it; values set through `TSVZed` are right-stripped, so this only affects rows passed raw to the append helpers.
271
267
  - §20.7: `-q` also silences informational messages ("Created …"); only errors remain.
272
268
  - §20.2: `scrub` of a multi-part store, and `clear` of one part of one, are refused with exit 1; `read`, `get` and `parts` of a numbered part read that part alone, with a warning.
273
269
  - §20.2.4: on `.tsv`-family files `get` prints nothing for a missing key, `verify` checks nothing, and `parts` lists the file.
@@ -276,7 +272,6 @@ and `-d=,`, except the ones C5 and C9 list.
276
272
  - §21: a served `.tsv` file with a header line shows that line as a row, as `tsvz read` does (`TSVZed` hides it when given the header).
277
273
  - §21.8: the server notices changes with `stat()`. It re-reads the part list while the store's directory changed less than 2 s ago, and reloads a part whose size stayed the same but whose inode or modification time changed. A rewrite in place that keeps a part's size and modification time is not seen until the part changes again.
278
274
  - §21.9: `tsvz` commands and `TSVZClient` ask the server to answer writes once they are written (`--x-written`), so a write the server cannot make fails as it would on the files. While writes fail, every answer carries a warning.
279
- - §17.7: the server appends to the store's current part; it does not start a new part when it starts.
280
275
  - §21.3: `tsvz serve` needs Unix-domain sockets. Where Python has none (Windows), it exits 1, and `tsvz` commands and `TSVZClient` work on the files.
281
276
  - §20.7: on `.tsv`-family files a `-d` longer than one character is used, as in 3.39, with a warning; a `-d` that does not decode is a usage error.
282
277
 
@@ -293,11 +288,67 @@ python3 website/tsvz_site.py build /tmp/site # static files for any host
293
288
 
294
289
  `deploy/tsvz-site.service` runs it under systemd; restart it to publish an edit.
295
290
 
291
+ ## Conformance testing
292
+
293
+ `tsvzconform.py` tests how closely an implementation of tsvz-spec-v1, in any
294
+ language, follows the spec. Its cases are JSON files in `conformance/cases/`,
295
+ each naming the § clauses it tests, and it drives an implementation only
296
+ through the spec's command line (§20) and handler socket (§21).
297
+
298
+ ```bash
299
+ python3 tsvzconform.py --impl ./tsvz --feature crc32,sha256,handler
300
+ python3 tsvzconform.py --impl ./tsvz --class reader --only 13
301
+ ```
302
+
303
+ Each failure is printed with its input, what was expected, what came back and
304
+ a command line that reruns it alone; the summary gives a verdict for each
305
+ conformance class (reader, writer, cli, handler). `conformance/README.md`
306
+ explains the options and the features to claim, and how to load the cases into
307
+ another language's tests.
308
+
309
+ ## Benchmarking implementations
310
+
311
+ `tsvzbench.py` compares implementations of tsvz-spec-v1, in any language, side
312
+ by side on one machine. It drives each one only through the spec's command
313
+ line (§20) and, when it has one, its handler socket (§21), so any conformant
314
+ `tsvz` tool can be measured as it is.
315
+
316
+ ```bash
317
+ python3 tsvzbench.py --impl ref='python3 TSVZ.py' --impl new='../tsvz-rs/target/release/tsvz' \
318
+ --suite quick --work-dir /mnt/fast-disk/tsvzbench
319
+ ```
320
+
321
+ - `--impl NAME=COMMAND`, repeatable: the first is the baseline, and every other
322
+ implementation gets a `×` ratio against it (below 1 is faster). A relative
323
+ COMMAND runs from the directory you start tsvzbench in.
324
+ - `--suite smoke|quick|standard|large`: `smoke` checks a setup in seconds,
325
+ `quick` takes about a minute, `standard` (the default, up to 1M rows) about
326
+ five, and `large` (up to 10M rows) twenty or more and ~5 GB of memory for the
327
+ harness itself.
328
+ - `--only cold,bulk,perop,maint` picks families: one-shot replays, `set -` and
329
+ `delete -` batches, single operations (CLI and handler), and scrub/clear.
330
+ - `--work-dir DIR` keeps the generated fixtures between runs. Put it on the disk
331
+ you mean to measure: not a tmpfs, and not a FUSE file system such as mergerfs
332
+ (the harness warns about those). The default is a `tsvzbench-*` directory here.
333
+ - `--json PATH` also writes every raw sample.
334
+
335
+ The harness writes its own input stores, so every implementation reads the same
336
+ bytes, and checks every result against its own replay model: `✓` right, `✗`
337
+ wrong output or files, `! N` an unexpected exit status, `⏱` timed out, `refused`
338
+ an allowed refusal. It exits 1 when any cell failed. Each command runs under a
339
+ small launcher that measures it with `wait4`; a peak RSS shown as `≤8.0 MiB` is
340
+ the launcher's own size, so the true peak is no higher. Compare an
341
+ implementation with itself to see the noise: a few percent per cell, but the
342
+ socket rows, whose latencies are tens of microseconds, can move 10–20% between
343
+ runs, because a single run can land in a slower mode as a whole.
344
+
296
345
  ## Tests
297
346
 
298
347
  ```bash
299
348
  python3 -m pytest TSVZ_test.py -q # everything, incl. 3.39 differential tests
300
349
  python3 -m pytest website/tsvz_site_test.py -q # the tsvz.org site
350
+ python3 -m pytest tsvzbench_test.py -q # the benchmark harness
351
+ python3 -m pytest tsvzconform_test.py -q # the conformance runner and cases, against the reference
301
352
  python3 benchTSVZ.py /tmp/b.tsv -n 100000 --compare-old # benchmark (not a test)
302
353
  ```
303
354
 
@@ -1,4 +1,3 @@
1
- argparse
2
1
 
3
2
  [completion]
4
3
  argcomplete
@@ -40,10 +40,10 @@ if os.name == 'nt':
40
40
  elif os.name == 'posix':
41
41
  import fcntl
42
42
 
43
- version = '4.2'
43
+ version = '4.3'
44
44
  __version__ = version
45
45
  author = 'pan@zopyr.us'
46
- COMMIT_DATE = '2026-10-09'
46
+ COMMIT_DATE = '2026-10-11'
47
47
 
48
48
  DEFAULT_DELIMITER = '\t'
49
49
  DEFAULTS_INDICATOR_KEY = '#_defaults_#'
@@ -121,6 +121,11 @@ def eprint(*args, **kwargs):
121
121
 
122
122
  def openFileAsCompressed(fileName,mode = 'rb',encoding = 'utf8',teeLogger = None,compressLevel = 1):
123
123
  lowerFileName = fileName.lower()
124
+ if any(c in mode for c in 'wax+'):
125
+ # Writing plain bytes into a compressed file destroys it: refuse when this Python lacks the codec.
126
+ suffix = lowerFileName.rpartition('.')[2]
127
+ if suffix in COMPRESSED_FILE_EXTENSIONS:
128
+ _requireCodec(suffix)
124
129
  if 'b' not in mode:
125
130
  mode += 't'
126
131
  kwargs = {}
@@ -683,6 +688,29 @@ def _lockedAppend(path, payload, tailPolicy, reporter, fsync=False):
683
688
  _unlockFile(f)
684
689
 
685
690
 
691
+ #: Compression suffix -> codec name (spec §16.2), as ``parts`` and error messages say it.
692
+ _CODEC_NAMES = {'gz': 'gzip', 'gzip': 'gzip', 'bz2': 'bzip2', 'bzip2': 'bzip2', 'xz': 'xz', 'lzma': 'xz', 'zst': 'zstd', 'zstd': 'zstd'}
693
+ _CODEC_SUPPORT = {}
694
+
695
+
696
+ def _codecSupported(codec):
697
+ """True when this Python has ``codec`` (zstd needs Python 3.14; a minimal build may lack lzma or bz2). Checked once per codec."""
698
+ name = _CODEC_NAMES.get(codec, codec)
699
+ if name not in _CODEC_SUPPORT:
700
+ try:
701
+ _newDecompressor(codec)
702
+ _CODEC_SUPPORT[name] = True
703
+ except ImportError:
704
+ _CODEC_SUPPORT[name] = False
705
+ return _CODEC_SUPPORT[name]
706
+
707
+
708
+ def _requireCodec(codec):
709
+ """Raise ImportError before any write to a part in a codec this Python lacks (spec §16.5): nothing is written."""
710
+ if codec and not _codecSupported(codec):
711
+ raise ImportError('no {} support in this Python'.format(_CODEC_NAMES.get(codec, codec)))
712
+
713
+
686
714
  def _compressBytes(codec, data, level=1):
687
715
  """Compress ``data`` as one complete member / stream for ``codec``."""
688
716
  if codec in ('gz', 'gzip'):
@@ -694,6 +722,7 @@ def _compressBytes(codec, data, level=1):
694
722
  if codec in ('xz', 'lzma'):
695
723
  import lzma
696
724
  return lzma.compress(data, preset=level)
725
+ _requireCodec(codec)
697
726
  from compression import zstd # Python 3.14+
698
727
  return zstd.compress(data, level=level)
699
728
 
@@ -1488,8 +1517,8 @@ MAX_SPEC_VERSION = 1
1488
1517
  _MARKER_RE = re.compile(r'^#_[A-Za-z0-9_-]+_#$')
1489
1518
  _CHECKSUM_RE = re.compile(r'^#_checksum_([A-Za-z0-9_-]+)_#$', re.IGNORECASE)
1490
1519
  _ASCII_DIGITS_RE = re.compile(r'^[0-9]+$')
1491
- _SPEC_DECODE_RE = re.compile(r'<(?:sep|LF|lt|#)>')
1492
- _SPEC_TOKENS = {'<LF>': '\n', '<lt>': '<', '<#>': '#'}
1520
+ _SPEC_DECODE_RE = re.compile(r'<(?:sep|LF|CR|lt|#)>')
1521
+ _SPEC_TOKENS = {'<LF>': '\n', '<CR>': '\r', '<lt>': '<', '<#>': '#'}
1493
1522
  _RowState = namedtuple('_RowState', 'defaults strip fillEmpty')
1494
1523
  _BOOL_VALUES = {'true': True, 'yes': True, 'on': True, '1': True,
1495
1524
  'false': False, 'no': False, 'off': False, '0': False}
@@ -1518,6 +1547,8 @@ def _specEncodeField(value, delimiter, isKey=False):
1518
1547
  value = value.replace(delimiter, '<sep>')
1519
1548
  if '\n' in value:
1520
1549
  value = value.replace('\n', '<LF>')
1550
+ if '\r' in value:
1551
+ value = value.replace('\r', '<CR>')
1521
1552
  if isKey and value.startswith('#'):
1522
1553
  value = '<#>' + value[1:]
1523
1554
  return value
@@ -1563,7 +1594,7 @@ class _SpecState(object):
1563
1594
 
1564
1595
  def _specApplyMarker(state, keyLower, values, reporter, where):
1565
1596
  """Apply an official marker (spec §12.4). Returns False when its value was rejected."""
1566
- value = values[0].strip() if values else ''
1597
+ value = values[0] if values else '' # marker lines are not stripped (spec §10.4)
1567
1598
  if keyLower == DEFAULTS_INDICATOR_KEY:
1568
1599
  state.defaults = [DEFAULTS_INDICATOR_KEY] + list(values)
1569
1600
  state.sawDefaults = True
@@ -1741,11 +1772,7 @@ def _decompressChunks(f, info):
1741
1772
  (``'corrupt (...)'``). ``info.goodRawEnd`` is the raw offset after the last
1742
1773
  complete member.
1743
1774
  """
1744
- try:
1745
- decompressor = _newDecompressor(info.codec)
1746
- except ImportError as e:
1747
- info.damaged = 'cannot decompress ({})'.format(e)
1748
- return
1775
+ decompressor = _newDecompressor(info.codec)
1749
1776
  isGzip = info.codec in ('gz', 'gzip')
1750
1777
  rawPos = 0
1751
1778
  inMember = False
@@ -1792,6 +1819,11 @@ def _iterPartLines(info, reporter):
1792
1819
  except OSError as e:
1793
1820
  reporter.note('unreadable', None, 'skipped unreadable part {} ({})'.format(info.path, e))
1794
1821
  return
1822
+ if info.codec and not _codecSupported(info.codec):
1823
+ # Spec §16.5: a codec this Python lacks makes the part unreadable; it is not damage to repair.
1824
+ f.close()
1825
+ reporter.note('unreadable', None, 'cannot read part {}: no {} support in this Python'.format(info.path, _CODEC_NAMES.get(info.codec, info.codec)))
1826
+ return
1795
1827
  rest = b''
1796
1828
  with f:
1797
1829
  chunks = _decompressChunks(f, info) if info.codec else iter(lambda: f.read(_CHUNK), b'')
@@ -1842,7 +1874,7 @@ def _specChecksum(state, algo, text, delimiter, reporter, where, position=None):
1842
1874
  """
1843
1875
  if algo in state.digests:
1844
1876
  fields = text.split(delimiter)
1845
- expected = fields[1].strip().lower() if len(fields) > 1 else ''
1877
+ expected = fields[1].lower() if len(fields) > 1 else '' # not stripped (spec §10.4)
1846
1878
  if expected:
1847
1879
  actual = state.digests[algo].hexdigest()
1848
1880
  if actual != expected:
@@ -2109,6 +2141,13 @@ def _specEnsureStore(fileName, createIfNotExist, header, defaults, strict, teeLo
2109
2141
  lines.append(_specFormatRecord(defaults, delimiter, marker=True))
2110
2142
  content = ''.join(line + '\n' for line in lines).encode('utf-8')
2111
2143
  codec = _parsePartName(fileName).codec
2144
+ try:
2145
+ _requireCodec(codec) # not even an empty file in a codec this Python lacks
2146
+ except ImportError as e:
2147
+ __teePrintOrNot('Failed to create {}: {}'.format(fileName, e),'error',teeLogger=teeLogger)
2148
+ if strict:
2149
+ raise
2150
+ return False
2112
2151
  try:
2113
2152
  with open(fileName, 'xb') as f:
2114
2153
  f.write(_compressBytes(codec, content) if codec and content else content)
@@ -2163,6 +2202,7 @@ def _specRepairCompressed(path, reporter):
2163
2202
  intact.
2164
2203
  """
2165
2204
  info = _PartInfo(path)
2205
+ _requireCodec(info.codec) # a part this Python cannot decode is not damaged; it is left alone
2166
2206
  with _pathLock(path):
2167
2207
  with open(path, 'r+b') as f:
2168
2208
  _lockFile(f)
@@ -2208,6 +2248,7 @@ def _specAppendPayload(path, payload, reporter, repair=False, fsync=False):
2208
2248
  if not codec:
2209
2249
  _lockedAppend(path, payload, 'truncate', reporter, fsync=fsync)
2210
2250
  return
2251
+ _requireCodec(codec)
2211
2252
  if repair and os.path.exists(path):
2212
2253
  _specRepairCompressed(path, reporter)
2213
2254
  with _pathLock(path):
@@ -2252,6 +2293,7 @@ def _specRewriteInPlace(path, content, expect=None):
2252
2293
  writer committed in between, so nothing is written and None is returned.
2253
2294
  """
2254
2295
  codec = _parsePartName(path).codec
2296
+ _requireCodec(codec)
2255
2297
  raw = _compressBytes(codec, content) if codec and content else content
2256
2298
  with _pathLock(path):
2257
2299
  with open(path, 'r+b') as f:
@@ -2398,6 +2440,11 @@ def _specClearTabularFile(fileName, teeLogger=None, header='', verifyHeader=Fals
2398
2440
  parts, stamp = _specSnapshot(fileName)
2399
2441
  load = _specLoad(fileName, delimiter, header=header, verifyHeader=False, strict=False,
2400
2442
  reporter=attempt, teeLogger=teeLogger, verbose=verbose)
2443
+ if attempt.has('unreadable'):
2444
+ if _storeParts(fileName)[0] != parts:
2445
+ continue # a part went away during the read (a rotation): read again
2446
+ attempt.note('refused', None, 'clear skipped: a part of the store could not be read; nothing was written')
2447
+ return False
2401
2448
  if len(load.parts) > 1:
2402
2449
  tombstones = ''.join(_specFormatRecord([key], delimiter) + '\n' for key in load.data)
2403
2450
  if tombstones:
@@ -2513,6 +2560,11 @@ def _specScrub(fileName, teeLogger=None, header='', createIfNotExist=False, veri
2513
2560
  load = _specLoad(fileName, delimiter, header=header, verifyHeader=verifyHeader, strict=strict,
2514
2561
  defaults=initial, correctColumnNum=correctColumnNum, taskDic=taskDic,
2515
2562
  reporter=attempt, teeLogger=teeLogger, verbose=verbose)
2563
+ if attempt.has('unreadable'):
2564
+ if _storeParts(fileName)[0] != parts:
2565
+ continue # a part went away during the read (a rotation): read again
2566
+ attempt.note('refused', None, 'scrub skipped: a part of the store could not be read; nothing was written')
2567
+ return taskDic, False
2516
2568
  if len(load.parts) != 1 or name.ordinal is not None or name.rotated:
2517
2569
  attempt.note('multipart', None, 'scrub skipped: TSVZ does not compact multi-part stores or single parts of them; nothing was written')
2518
2570
  return taskDic, False
@@ -2826,6 +2878,7 @@ class TSVZed(OrderedDict):
2826
2878
  load = _specLoad(self._fileName, self.delimiter, header=self.header, verifyHeader=self.verifyHeader,
2827
2879
  strict=self.strict, defaults=self._constructorDefaults, taskDic=OrderedDict(),
2828
2880
  reporter=reporter, teeLogger=self.teeLogger, verbose=self.verbose)
2881
+ unreadable = reporter.has('unreadable')
2829
2882
  finally:
2830
2883
  reporter.flush()
2831
2884
  OrderedDict.clear(self)
@@ -2849,7 +2902,7 @@ class TSVZed(OrderedDict):
2849
2902
  self.lastUpdateTime = self.externalFileUpdateTime
2850
2903
  self._dirMtimeNs = _dirMtimeNs(self._fileName)
2851
2904
  if (load is not None and not load.state.sawDefaults and any(self._constructorDefaults[1:])
2852
- and not self.memoryOnly):
2905
+ and not self.memoryOnly and not unreadable): # the store's own defaults may be in the unread part
2853
2906
  # Design §5.3: persist constructor defaults by appending, as 3.39 did by rewriting.
2854
2907
  self.appendQueue.append(_specFormatRecord(self._constructorDefaults, self.delimiter, marker=True))
2855
2908
  self._specState.sawDefaults = True
@@ -3865,6 +3918,7 @@ class TSVZedLite(MutableMapping):
3865
3918
  strict=self.strict, defaults=self._constructorDefaults,
3866
3919
  correctColumnNum=self.correctColumnNum, storeOffset=True, taskDic=target,
3867
3920
  reporter=reporter, verbose=self.verbose)
3921
+ unreadable = reporter.has('unreadable')
3868
3922
  finally:
3869
3923
  reporter.flush()
3870
3924
  self._specState = load.state
@@ -3881,7 +3935,7 @@ class TSVZedLite(MutableMapping):
3881
3935
  held = sum(1 for value in target.values() if isinstance(value, list))
3882
3936
  if held:
3883
3937
  _warnOnce(self, 'held', self._fileName, f'{held} keys from older or compressed parts are held in memory (offsets cannot address them)')
3884
- if not load.state.sawDefaults and any(self._constructorDefaults[1:]):
3938
+ if not load.state.sawDefaults and any(self._constructorDefaults[1:]) and not unreadable:
3885
3939
  self._pendingDefaults = True
3886
3940
  return self
3887
3941
 
@@ -4474,7 +4528,7 @@ def _partRows(store, reporter):
4474
4528
  if name.codec:
4475
4529
  base = base[:-len(name.codec) - 1]
4476
4530
  ordinal = base.rpartition('.')[2]
4477
- flags = (['active'] if path == active else []) + ([name.codec] if name.codec else [])
4531
+ flags = (['active'] if path == active else []) + ([_CODEC_NAMES.get(name.codec, name.codec)] if name.codec else [])
4478
4532
  rows.append([str(index), ordinal, path, ','.join(flags)])
4479
4533
  return rows
4480
4534
 
@@ -4890,6 +4944,8 @@ class _ServeStore(object):
4890
4944
  """Atomically return the row of ``key`` and append its tombstone; None when it is missing."""
4891
4945
  with self.lock:
4892
4946
  self._fresh(log)
4947
+ if self.unreadable:
4948
+ return _SERVE_PARTIAL
4893
4949
  key = self.resolve(key)
4894
4950
  row = self.data.get(key)
4895
4951
  if row is None:
@@ -4903,6 +4959,8 @@ class _ServeStore(object):
4903
4959
  """Atomically return the row of the last (or first) live key and delete it; None when empty."""
4904
4960
  with self.lock:
4905
4961
  self._fresh(log)
4962
+ if self.unreadable:
4963
+ return _SERVE_PARTIAL
4906
4964
  if not self.data:
4907
4965
  return None
4908
4966
  key = next(reversed(self.data)) if last else next(iter(self.data))
@@ -4915,6 +4973,8 @@ class _ServeStore(object):
4915
4973
  """Atomically return the row of ``cells[0]``, appending ``cells`` first when the key is missing."""
4916
4974
  with self.lock:
4917
4975
  self._fresh(log)
4976
+ if self.unreadable:
4977
+ return _SERVE_PARTIAL
4918
4978
  key = self.resolve(cells[0])
4919
4979
  row = self.data.get(key)
4920
4980
  if row is not None:
@@ -4999,6 +5059,8 @@ _SERVE_ARITY = {
4999
5059
  'delete': (1, None), 'has': (1, 1), 'pop': (1, 1), 'popitem': (0, 1), 'setdefault': (2, None),
5000
5060
  }
5001
5061
  _SERVE_UNREADABLE = 'could not read every part of the store'
5062
+ #: What pop, popitem and setdefault return instead of acting on a store they could not read whole.
5063
+ _SERVE_PARTIAL = object()
5002
5064
  #: Operations that fail on a missing store (spec §20.2.2).
5003
5065
  _SERVE_NEED_STORE = ('read', 'get', 'has', 'len', 'keys', 'pop', 'popitem', 'scrub', 'verify', 'parts')
5004
5066
  #: A pointer file larger than this is not one (spec §21.2).
@@ -5122,11 +5184,15 @@ def _serveDispatch(store, line, bulk, log):
5122
5184
  if args and args[0] not in ('first', 'last'):
5123
5185
  raise _ServeUsage('popitem takes first or last')
5124
5186
  row = store.popitem(not args or args[0] == 'last', sync, log)
5187
+ if row is _SERVE_PARTIAL:
5188
+ return 1, [], _SERVE_UNREADABLE
5125
5189
  return (3, [], '') if row is None else (0, [_specFormatRecord(row, '\t')], '')
5126
5190
  if operation == 'setdefault':
5127
5191
  if _MARKER_RE.match(raw[0]):
5128
5192
  raise _ServeUsage('setdefault takes a data KEY, not a marker')
5129
5193
  row = store.setdefault(args, sync, log)
5194
+ if row is _SERVE_PARTIAL:
5195
+ return 1, [], _SERVE_UNREADABLE
5130
5196
  return (3, [], '') if row is None else (0, [_specFormatRecord(row, '\t')], '')
5131
5197
  if operation in ('clear', 'scrub'):
5132
5198
  done = store.clear(log) if operation == 'clear' else store.scrub(log)
@@ -15,9 +15,6 @@ setup(
15
15
  'tsvz = TSVZ:__main__',
16
16
  ],
17
17
  },
18
- install_requires=[
19
- 'argparse',
20
- ],
21
18
  extras_require={
22
19
  'completion': [
23
20
  'argcomplete',
File without changes
File without changes
File without changes
File without changes