TSVZ 5.0__tar.gz → 5.1__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: 5.0
3
+ Version: 5.1
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/tsvz-org/TSVZ
6
6
  Author: Yufei Pan
@@ -39,7 +39,7 @@ key-value store in a delimiter-separated text file**. The first column of each
39
39
  row is the key; the file behaves like an ordered dictionary that is persisted to
40
40
  disk as you change it.
41
41
 
42
- **Version 5.0**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
42
+ **Version 5.1**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
43
43
  next to your code and `import TSVZ`, or install it.
44
44
 
45
45
  ```bash
@@ -301,12 +301,85 @@ empty; the keys it sets or deletes then take its values, and the keys it left ou
301
301
  memory's. An explicit `reload()` while the file is damaged replaces memory with the
302
302
  damaged file, as asked, and warns.
303
303
 
304
+ ## Upgrading to 5.1 from 5.0
305
+
306
+ The file formats are unchanged, and a 3.39 host still reads every `.tsv`-family file
307
+ 5.1 writes. Most of the changes are in scrub and clear.
308
+
309
+ - **Scrub and clear of a `.tsv` write over the file.** `scrubTabularFile`,
310
+ `clearTabularFile`, `tsvz scrub` and `tsvz clear` (on the command line or through a
311
+ `tsvz serve` handler), `TSVZClient.clear()` and `TSVZedLite.clear_file()` of a
312
+ `.tsv`-family file write the new content over the file and then cut it, as
313
+ `TSVZed`'s rewrites do, so a reader that takes no lock never meets the file emptied.
314
+ They take the file's lock, so they wait while another writer holds it. 5.0 emptied
315
+ the file first, with no lock (`TSVZedLite` truncated its own descriptor).
316
+ - **A scrub keeps another process's rows.** A scrub of a `.tsv` writes only if the
317
+ file is still what its read saw. Otherwise it reads again, up to three times, and if
318
+ the file kept changing it writes nothing and warns: `tsvz scrub` exits 1 (`scrub
319
+ refused; nothing was written`), as for a `.tsvz` (C6), and a handler answers refused.
320
+ `scrubTabularFile` still returns the rows it read. 5.0 wrote over a row appended
321
+ after its read.
322
+ - **`scrubTabularFile(lastLineOnly=True)` is a read.** It returns the last row and
323
+ writes nothing, as for a `.tsvz`. 5.0 and 3.39 wrote that row back as deletion lines
324
+ (`id\tv\na\t\n3\t\n`), and the store was gone.
325
+ - **A scrub keeps the file's `#_defaults_#` lines.** The defaults in effect at the end
326
+ of the read are written after the rows: the last non-empty defaults line, its empty
327
+ cells filled from the line before it as a reader fills them. An empty defaults line
328
+ that was read (it turns defaults off) is written before the rows, padded to their
329
+ width. Every row reads as it did to a reader given the scrub's defaults and
330
+ `strict`. Defaults a caller passes still fill the rows' empty cells, as in 5.0, but
331
+ are not written as a defaults line, and the caller's list is not changed. 5.0 and
332
+ 3.39 dropped the lines, so rows appended later got no defaults.
333
+ - **The header stays on line 1.** A scrub given no header (`tsvz scrub` of a `.tsv`,
334
+ `scrubTabularFile(path)`) and a `TSVZed` given none, of a file
335
+ that has a header line, keep that line first, with a `TSVZed`'s defaults line right
336
+ after it. 5.0 and 3.39 put an empty line in front of it, so a reader given the header
337
+ read the header as a row. A header-less file keeps the empty first line it begins
338
+ with, and new files and clears given no header still get one, as in 5.0.
339
+ - **Interrupts.** A scrub or clear of either dialect keeps a copy of only the bytes it
340
+ overwrites, to put back if it is interrupted before the file is cut to its new
341
+ length; once the cut is made the rewrite is done and the new content stays. 5.0 kept
342
+ a copy of the whole `.tsvz` part and put it all back. (A scrub still reads the whole
343
+ store, and a `.tsvz` clear reads it too, to keep its markers.)
344
+ - **A compressed store cleared to nothing is one empty stream.** A `.tsvz.gz` (or
345
+ `.bz2`, `.xz`, `.zst`) cleared or scrubbed to nothing holds one empty compressed
346
+ stream, which `bzcat`, `xzcat` and Python's `bz2` and `lzma` can read; 5.0 left 0
347
+ bytes.
348
+ - **A header-checking clear of an empty file writes the header it was given.** A clear
349
+ that checks the header (`verifyHeader=True`, `tsvz clear --x-header H --x-strict`) of a
350
+ 0-byte `.tsv`, or of a compressed file with no byte or only empty streams (another
351
+ tool's), takes it for an empty store with that header, as every reader does (L9), and
352
+ writes H without a "Header mismatch" warning. 5.0 wrote an empty line and warned of a
353
+ mismatch, so a strict `TSVZed` given H then refused the file, and raised on a 0-byte
354
+ `.bz2`, `.xz` or `.zst`.
355
+ - **`tsvz --help`** says what `clear` keeps: a `.tsvz` its header and markers, a
356
+ `.tsv` the `--x-header` it is given. (With `--x-strict` too, a `.tsv` clear keeps the
357
+ file's own header line.)
358
+ `TSVZed`'s message for a refused rewrite lists `hardMapToFile()` among the rewrites
359
+ a program asks for.
360
+ - **Known: a scrub of a `.tsv` whose rows were all deleted does not compact it** when
361
+ the scrub is given the file's header (`scrubTabularFile(path, header=H)`) or the file
362
+ has no header line: it leaves the file as it is, deletion lines included; reads are
363
+ right, as in 3.39 and 5.0. (`tsvz scrub` of a headed file reads the header line as a
364
+ row, so it does compact it.)
365
+ ([limitations](docs/limitations.md#a-scrub-of-a-tsv-whose-rows-were-all-deleted-does-not-compact-it)).
366
+ - **Known: a scrub run with other defaults than the store's program can change how
367
+ early rows read.** In a `.tsv` with an empty `#_defaults_#` line and rows with empty
368
+ cells before any defaults line, the reset line the scrub writes before the rows keeps
369
+ those cells empty for a program that opens the store with its own defaults; run the
370
+ scrub with the defaults that program uses
371
+ ([limitations](docs/limitations.md#a-scrub-run-with-other-defaults-than-the-stores-program-can-change-how-early-rows-read)).
372
+ - **Running programs keep 5.0** until they are restarted: long-running programs that
373
+ import TSVZ, and running `tsvz serve` handlers.
374
+
304
375
  ## Upgrading to 5.0 from 4.9
305
376
 
306
377
  - **Writes refuse text they cannot encode.** A key, value, header, defaults line or
307
378
  marker the file's encoding cannot hold (UTF-8 for `.tsvz`; `encoding=` for a
308
379
  `.tsv`), such as the lone surrogate `os.fsdecode` gives for a non-UTF-8 file name,
309
- raises `TSVZEncodeError` (a `ValueError`) and writes nothing; 4.9 stored `?`.
380
+ raises `TSVZEncodeError` (a `ValueError`) and writes nothing; 4.9 stored `?` (its
381
+ `tsvz` stored U+FFFD for a non-UTF-8 argument, and its `.tsvz` file functions raised
382
+ `UnicodeEncodeError`).
310
383
  `errors='replace'` brings `?` back. Under a non-UTF-8 `encoding=`, a key read as
311
384
  U+FFFD cannot be deleted or updated. `tsvz` exits 1 with the message. See Fault
312
385
  tolerance.
@@ -324,8 +397,7 @@ damaged file, as asked, and warns.
324
397
  merged into it (Fault tolerance).
325
398
  - **`.tsvz` stores** see another process's append made right after their load, a
326
399
  commit or a clear (4.9 missed it until the next change), keep the rows set while
327
- they reload, refuse `#_snapshot_#` (spec §19.10.2), and raise a strict header
328
- mismatch at the first read.
400
+ they reload, and raise a strict header mismatch at the first read.
329
401
  - **`.tsv` files:** an append to an existing empty file writes the header first, and
330
402
  reading one gives an empty store; see L8 to L14 for the rest.
331
403
  - **`tsvz`** runs every operation through the engine `tsvz serve` uses, with the same
@@ -334,8 +406,15 @@ damaged file, as asked, and warns.
334
406
  file. The 3.39 warning that a file name does not end in `.tsv` is printed only
335
407
  when a command creates such a file, and a write that cannot be made reports the
336
408
  operating system's error.
337
- - **macOS and other systems that lock with `lockf`:** 4.9's close gate is gone, so
338
- a read in another thread of the process can drop a write's lock
409
+ - **Running programs keep 4.9.** Long-running programs that import TSVZ, and running
410
+ `tsvz serve` handlers, keep 4.9 until they are restarted. 5.0's `tsvz stop` cannot
411
+ stop a 4.9 handler of a `.tsv` (or other tab- or NUL-delimited) file: 4.9 wrote the
412
+ delimiter into the pointer file's `x-delimiter` as `<sep>` (a NUL as `<NUL>`), which
413
+ 5.0 decodes to the TAB or NUL itself and refuses as a control character. Send that
414
+ handler SIGTERM instead: its pid is on the `pid` line of its pointer file
415
+ (`STORE.serve`), and it finishes its writes and exits.
416
+ - **macOS and other systems that lock with `lockf`:** as in 4.9, a read in another
417
+ thread of the process can still drop a write's lock
339
418
  ([docs/limitations.md](docs/limitations.md)).
340
419
 
341
420
  ## Breaking changes from 3.39
@@ -358,6 +437,11 @@ damaged file, as asked, and warns.
358
437
  | L12 | A rewrite of a file whose header line has no newline writes the rows on lines of their own; 3.39 wrote the first one onto the header line. |
359
438
  | L13 | A compressed file is rewritten in members of at most 1 MiB, as appends already were: the bytes differ, the rows read are the same, and 3.39 reads them. |
360
439
  | L14 | `TSVZed` opening a compressed file in a codec this Python lacks (zstd before Python 3.14) raises `ImportError` instead of reading the compressed bytes as text. |
440
+ | L15 | `scrubTabularFile`, `clearTabularFile`, `tsvz scrub`, `tsvz clear` and `TSVZedLite.clear_file()` write the new content over the file and then cut it, under the file's lock; 3.39 emptied the file first, with no lock, so a reader could meet it empty. |
441
+ | L16 | A scrub writes only if the file is still what its read saw: it reads again up to three times, then writes nothing, with a warning (`tsvz scrub` exits 1, C6); 3.39 wrote over rows appended after its read. |
442
+ | L17 | `scrubTabularFile(lastLineOnly=True)` returns the last row and writes nothing; 3.39 wrote that row back as deletion lines, and the store was gone. |
443
+ | L18 | A scrub keeps the file's `#_defaults_#` lines (an empty one before the rows, the defaults in effect after them), so every row reads as it did; 3.39 dropped them, so rows appended later got no defaults. |
444
+ | L19 | A scrub given no header, and a `TSVZed` given none, keep a file's header line first; 3.39 put an empty line in front of it, and a reader given the header then read the header as a row. |
361
445
 
362
446
  ### `.tsvz`, `.csvz`, `.nsvz`, `.psvz`: now tsvz-spec-v1
363
447
 
@@ -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 5.0**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
10
+ **Version 5.1**, 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
@@ -269,12 +269,85 @@ empty; the keys it sets or deletes then take its values, and the keys it left ou
269
269
  memory's. An explicit `reload()` while the file is damaged replaces memory with the
270
270
  damaged file, as asked, and warns.
271
271
 
272
+ ## Upgrading to 5.1 from 5.0
273
+
274
+ The file formats are unchanged, and a 3.39 host still reads every `.tsv`-family file
275
+ 5.1 writes. Most of the changes are in scrub and clear.
276
+
277
+ - **Scrub and clear of a `.tsv` write over the file.** `scrubTabularFile`,
278
+ `clearTabularFile`, `tsvz scrub` and `tsvz clear` (on the command line or through a
279
+ `tsvz serve` handler), `TSVZClient.clear()` and `TSVZedLite.clear_file()` of a
280
+ `.tsv`-family file write the new content over the file and then cut it, as
281
+ `TSVZed`'s rewrites do, so a reader that takes no lock never meets the file emptied.
282
+ They take the file's lock, so they wait while another writer holds it. 5.0 emptied
283
+ the file first, with no lock (`TSVZedLite` truncated its own descriptor).
284
+ - **A scrub keeps another process's rows.** A scrub of a `.tsv` writes only if the
285
+ file is still what its read saw. Otherwise it reads again, up to three times, and if
286
+ the file kept changing it writes nothing and warns: `tsvz scrub` exits 1 (`scrub
287
+ refused; nothing was written`), as for a `.tsvz` (C6), and a handler answers refused.
288
+ `scrubTabularFile` still returns the rows it read. 5.0 wrote over a row appended
289
+ after its read.
290
+ - **`scrubTabularFile(lastLineOnly=True)` is a read.** It returns the last row and
291
+ writes nothing, as for a `.tsvz`. 5.0 and 3.39 wrote that row back as deletion lines
292
+ (`id\tv\na\t\n3\t\n`), and the store was gone.
293
+ - **A scrub keeps the file's `#_defaults_#` lines.** The defaults in effect at the end
294
+ of the read are written after the rows: the last non-empty defaults line, its empty
295
+ cells filled from the line before it as a reader fills them. An empty defaults line
296
+ that was read (it turns defaults off) is written before the rows, padded to their
297
+ width. Every row reads as it did to a reader given the scrub's defaults and
298
+ `strict`. Defaults a caller passes still fill the rows' empty cells, as in 5.0, but
299
+ are not written as a defaults line, and the caller's list is not changed. 5.0 and
300
+ 3.39 dropped the lines, so rows appended later got no defaults.
301
+ - **The header stays on line 1.** A scrub given no header (`tsvz scrub` of a `.tsv`,
302
+ `scrubTabularFile(path)`) and a `TSVZed` given none, of a file
303
+ that has a header line, keep that line first, with a `TSVZed`'s defaults line right
304
+ after it. 5.0 and 3.39 put an empty line in front of it, so a reader given the header
305
+ read the header as a row. A header-less file keeps the empty first line it begins
306
+ with, and new files and clears given no header still get one, as in 5.0.
307
+ - **Interrupts.** A scrub or clear of either dialect keeps a copy of only the bytes it
308
+ overwrites, to put back if it is interrupted before the file is cut to its new
309
+ length; once the cut is made the rewrite is done and the new content stays. 5.0 kept
310
+ a copy of the whole `.tsvz` part and put it all back. (A scrub still reads the whole
311
+ store, and a `.tsvz` clear reads it too, to keep its markers.)
312
+ - **A compressed store cleared to nothing is one empty stream.** A `.tsvz.gz` (or
313
+ `.bz2`, `.xz`, `.zst`) cleared or scrubbed to nothing holds one empty compressed
314
+ stream, which `bzcat`, `xzcat` and Python's `bz2` and `lzma` can read; 5.0 left 0
315
+ bytes.
316
+ - **A header-checking clear of an empty file writes the header it was given.** A clear
317
+ that checks the header (`verifyHeader=True`, `tsvz clear --x-header H --x-strict`) of a
318
+ 0-byte `.tsv`, or of a compressed file with no byte or only empty streams (another
319
+ tool's), takes it for an empty store with that header, as every reader does (L9), and
320
+ writes H without a "Header mismatch" warning. 5.0 wrote an empty line and warned of a
321
+ mismatch, so a strict `TSVZed` given H then refused the file, and raised on a 0-byte
322
+ `.bz2`, `.xz` or `.zst`.
323
+ - **`tsvz --help`** says what `clear` keeps: a `.tsvz` its header and markers, a
324
+ `.tsv` the `--x-header` it is given. (With `--x-strict` too, a `.tsv` clear keeps the
325
+ file's own header line.)
326
+ `TSVZed`'s message for a refused rewrite lists `hardMapToFile()` among the rewrites
327
+ a program asks for.
328
+ - **Known: a scrub of a `.tsv` whose rows were all deleted does not compact it** when
329
+ the scrub is given the file's header (`scrubTabularFile(path, header=H)`) or the file
330
+ has no header line: it leaves the file as it is, deletion lines included; reads are
331
+ right, as in 3.39 and 5.0. (`tsvz scrub` of a headed file reads the header line as a
332
+ row, so it does compact it.)
333
+ ([limitations](docs/limitations.md#a-scrub-of-a-tsv-whose-rows-were-all-deleted-does-not-compact-it)).
334
+ - **Known: a scrub run with other defaults than the store's program can change how
335
+ early rows read.** In a `.tsv` with an empty `#_defaults_#` line and rows with empty
336
+ cells before any defaults line, the reset line the scrub writes before the rows keeps
337
+ those cells empty for a program that opens the store with its own defaults; run the
338
+ scrub with the defaults that program uses
339
+ ([limitations](docs/limitations.md#a-scrub-run-with-other-defaults-than-the-stores-program-can-change-how-early-rows-read)).
340
+ - **Running programs keep 5.0** until they are restarted: long-running programs that
341
+ import TSVZ, and running `tsvz serve` handlers.
342
+
272
343
  ## Upgrading to 5.0 from 4.9
273
344
 
274
345
  - **Writes refuse text they cannot encode.** A key, value, header, defaults line or
275
346
  marker the file's encoding cannot hold (UTF-8 for `.tsvz`; `encoding=` for a
276
347
  `.tsv`), such as the lone surrogate `os.fsdecode` gives for a non-UTF-8 file name,
277
- raises `TSVZEncodeError` (a `ValueError`) and writes nothing; 4.9 stored `?`.
348
+ raises `TSVZEncodeError` (a `ValueError`) and writes nothing; 4.9 stored `?` (its
349
+ `tsvz` stored U+FFFD for a non-UTF-8 argument, and its `.tsvz` file functions raised
350
+ `UnicodeEncodeError`).
278
351
  `errors='replace'` brings `?` back. Under a non-UTF-8 `encoding=`, a key read as
279
352
  U+FFFD cannot be deleted or updated. `tsvz` exits 1 with the message. See Fault
280
353
  tolerance.
@@ -292,8 +365,7 @@ damaged file, as asked, and warns.
292
365
  merged into it (Fault tolerance).
293
366
  - **`.tsvz` stores** see another process's append made right after their load, a
294
367
  commit or a clear (4.9 missed it until the next change), keep the rows set while
295
- they reload, refuse `#_snapshot_#` (spec §19.10.2), and raise a strict header
296
- mismatch at the first read.
368
+ they reload, and raise a strict header mismatch at the first read.
297
369
  - **`.tsv` files:** an append to an existing empty file writes the header first, and
298
370
  reading one gives an empty store; see L8 to L14 for the rest.
299
371
  - **`tsvz`** runs every operation through the engine `tsvz serve` uses, with the same
@@ -302,8 +374,15 @@ damaged file, as asked, and warns.
302
374
  file. The 3.39 warning that a file name does not end in `.tsv` is printed only
303
375
  when a command creates such a file, and a write that cannot be made reports the
304
376
  operating system's error.
305
- - **macOS and other systems that lock with `lockf`:** 4.9's close gate is gone, so
306
- a read in another thread of the process can drop a write's lock
377
+ - **Running programs keep 4.9.** Long-running programs that import TSVZ, and running
378
+ `tsvz serve` handlers, keep 4.9 until they are restarted. 5.0's `tsvz stop` cannot
379
+ stop a 4.9 handler of a `.tsv` (or other tab- or NUL-delimited) file: 4.9 wrote the
380
+ delimiter into the pointer file's `x-delimiter` as `<sep>` (a NUL as `<NUL>`), which
381
+ 5.0 decodes to the TAB or NUL itself and refuses as a control character. Send that
382
+ handler SIGTERM instead: its pid is on the `pid` line of its pointer file
383
+ (`STORE.serve`), and it finishes its writes and exits.
384
+ - **macOS and other systems that lock with `lockf`:** as in 4.9, a read in another
385
+ thread of the process can still drop a write's lock
307
386
  ([docs/limitations.md](docs/limitations.md)).
308
387
 
309
388
  ## Breaking changes from 3.39
@@ -326,6 +405,11 @@ damaged file, as asked, and warns.
326
405
  | L12 | A rewrite of a file whose header line has no newline writes the rows on lines of their own; 3.39 wrote the first one onto the header line. |
327
406
  | L13 | A compressed file is rewritten in members of at most 1 MiB, as appends already were: the bytes differ, the rows read are the same, and 3.39 reads them. |
328
407
  | L14 | `TSVZed` opening a compressed file in a codec this Python lacks (zstd before Python 3.14) raises `ImportError` instead of reading the compressed bytes as text. |
408
+ | L15 | `scrubTabularFile`, `clearTabularFile`, `tsvz scrub`, `tsvz clear` and `TSVZedLite.clear_file()` write the new content over the file and then cut it, under the file's lock; 3.39 emptied the file first, with no lock, so a reader could meet it empty. |
409
+ | L16 | A scrub writes only if the file is still what its read saw: it reads again up to three times, then writes nothing, with a warning (`tsvz scrub` exits 1, C6); 3.39 wrote over rows appended after its read. |
410
+ | L17 | `scrubTabularFile(lastLineOnly=True)` returns the last row and writes nothing; 3.39 wrote that row back as deletion lines, and the store was gone. |
411
+ | L18 | A scrub keeps the file's `#_defaults_#` lines (an empty one before the rows, the defaults in effect after them), so every row reads as it did; 3.39 dropped them, so rows appended later got no defaults. |
412
+ | L19 | A scrub given no header, and a `TSVZed` given none, keep a file's header line first; 3.39 put an empty line in front of it, and a reader given the header then read the header as a row. |
329
413
 
330
414
  ### `.tsvz`, `.csvz`, `.nsvz`, `.psvz`: now tsvz-spec-v1
331
415
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TSVZ
3
- Version: 5.0
3
+ Version: 5.1
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/tsvz-org/TSVZ
6
6
  Author: Yufei Pan
@@ -39,7 +39,7 @@ key-value store in a delimiter-separated text file**. The first column of each
39
39
  row is the key; the file behaves like an ordered dictionary that is persisted to
40
40
  disk as you change it.
41
41
 
42
- **Version 5.0**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
42
+ **Version 5.1**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
43
43
  next to your code and `import TSVZ`, or install it.
44
44
 
45
45
  ```bash
@@ -301,12 +301,85 @@ empty; the keys it sets or deletes then take its values, and the keys it left ou
301
301
  memory's. An explicit `reload()` while the file is damaged replaces memory with the
302
302
  damaged file, as asked, and warns.
303
303
 
304
+ ## Upgrading to 5.1 from 5.0
305
+
306
+ The file formats are unchanged, and a 3.39 host still reads every `.tsv`-family file
307
+ 5.1 writes. Most of the changes are in scrub and clear.
308
+
309
+ - **Scrub and clear of a `.tsv` write over the file.** `scrubTabularFile`,
310
+ `clearTabularFile`, `tsvz scrub` and `tsvz clear` (on the command line or through a
311
+ `tsvz serve` handler), `TSVZClient.clear()` and `TSVZedLite.clear_file()` of a
312
+ `.tsv`-family file write the new content over the file and then cut it, as
313
+ `TSVZed`'s rewrites do, so a reader that takes no lock never meets the file emptied.
314
+ They take the file's lock, so they wait while another writer holds it. 5.0 emptied
315
+ the file first, with no lock (`TSVZedLite` truncated its own descriptor).
316
+ - **A scrub keeps another process's rows.** A scrub of a `.tsv` writes only if the
317
+ file is still what its read saw. Otherwise it reads again, up to three times, and if
318
+ the file kept changing it writes nothing and warns: `tsvz scrub` exits 1 (`scrub
319
+ refused; nothing was written`), as for a `.tsvz` (C6), and a handler answers refused.
320
+ `scrubTabularFile` still returns the rows it read. 5.0 wrote over a row appended
321
+ after its read.
322
+ - **`scrubTabularFile(lastLineOnly=True)` is a read.** It returns the last row and
323
+ writes nothing, as for a `.tsvz`. 5.0 and 3.39 wrote that row back as deletion lines
324
+ (`id\tv\na\t\n3\t\n`), and the store was gone.
325
+ - **A scrub keeps the file's `#_defaults_#` lines.** The defaults in effect at the end
326
+ of the read are written after the rows: the last non-empty defaults line, its empty
327
+ cells filled from the line before it as a reader fills them. An empty defaults line
328
+ that was read (it turns defaults off) is written before the rows, padded to their
329
+ width. Every row reads as it did to a reader given the scrub's defaults and
330
+ `strict`. Defaults a caller passes still fill the rows' empty cells, as in 5.0, but
331
+ are not written as a defaults line, and the caller's list is not changed. 5.0 and
332
+ 3.39 dropped the lines, so rows appended later got no defaults.
333
+ - **The header stays on line 1.** A scrub given no header (`tsvz scrub` of a `.tsv`,
334
+ `scrubTabularFile(path)`) and a `TSVZed` given none, of a file
335
+ that has a header line, keep that line first, with a `TSVZed`'s defaults line right
336
+ after it. 5.0 and 3.39 put an empty line in front of it, so a reader given the header
337
+ read the header as a row. A header-less file keeps the empty first line it begins
338
+ with, and new files and clears given no header still get one, as in 5.0.
339
+ - **Interrupts.** A scrub or clear of either dialect keeps a copy of only the bytes it
340
+ overwrites, to put back if it is interrupted before the file is cut to its new
341
+ length; once the cut is made the rewrite is done and the new content stays. 5.0 kept
342
+ a copy of the whole `.tsvz` part and put it all back. (A scrub still reads the whole
343
+ store, and a `.tsvz` clear reads it too, to keep its markers.)
344
+ - **A compressed store cleared to nothing is one empty stream.** A `.tsvz.gz` (or
345
+ `.bz2`, `.xz`, `.zst`) cleared or scrubbed to nothing holds one empty compressed
346
+ stream, which `bzcat`, `xzcat` and Python's `bz2` and `lzma` can read; 5.0 left 0
347
+ bytes.
348
+ - **A header-checking clear of an empty file writes the header it was given.** A clear
349
+ that checks the header (`verifyHeader=True`, `tsvz clear --x-header H --x-strict`) of a
350
+ 0-byte `.tsv`, or of a compressed file with no byte or only empty streams (another
351
+ tool's), takes it for an empty store with that header, as every reader does (L9), and
352
+ writes H without a "Header mismatch" warning. 5.0 wrote an empty line and warned of a
353
+ mismatch, so a strict `TSVZed` given H then refused the file, and raised on a 0-byte
354
+ `.bz2`, `.xz` or `.zst`.
355
+ - **`tsvz --help`** says what `clear` keeps: a `.tsvz` its header and markers, a
356
+ `.tsv` the `--x-header` it is given. (With `--x-strict` too, a `.tsv` clear keeps the
357
+ file's own header line.)
358
+ `TSVZed`'s message for a refused rewrite lists `hardMapToFile()` among the rewrites
359
+ a program asks for.
360
+ - **Known: a scrub of a `.tsv` whose rows were all deleted does not compact it** when
361
+ the scrub is given the file's header (`scrubTabularFile(path, header=H)`) or the file
362
+ has no header line: it leaves the file as it is, deletion lines included; reads are
363
+ right, as in 3.39 and 5.0. (`tsvz scrub` of a headed file reads the header line as a
364
+ row, so it does compact it.)
365
+ ([limitations](docs/limitations.md#a-scrub-of-a-tsv-whose-rows-were-all-deleted-does-not-compact-it)).
366
+ - **Known: a scrub run with other defaults than the store's program can change how
367
+ early rows read.** In a `.tsv` with an empty `#_defaults_#` line and rows with empty
368
+ cells before any defaults line, the reset line the scrub writes before the rows keeps
369
+ those cells empty for a program that opens the store with its own defaults; run the
370
+ scrub with the defaults that program uses
371
+ ([limitations](docs/limitations.md#a-scrub-run-with-other-defaults-than-the-stores-program-can-change-how-early-rows-read)).
372
+ - **Running programs keep 5.0** until they are restarted: long-running programs that
373
+ import TSVZ, and running `tsvz serve` handlers.
374
+
304
375
  ## Upgrading to 5.0 from 4.9
305
376
 
306
377
  - **Writes refuse text they cannot encode.** A key, value, header, defaults line or
307
378
  marker the file's encoding cannot hold (UTF-8 for `.tsvz`; `encoding=` for a
308
379
  `.tsv`), such as the lone surrogate `os.fsdecode` gives for a non-UTF-8 file name,
309
- raises `TSVZEncodeError` (a `ValueError`) and writes nothing; 4.9 stored `?`.
380
+ raises `TSVZEncodeError` (a `ValueError`) and writes nothing; 4.9 stored `?` (its
381
+ `tsvz` stored U+FFFD for a non-UTF-8 argument, and its `.tsvz` file functions raised
382
+ `UnicodeEncodeError`).
310
383
  `errors='replace'` brings `?` back. Under a non-UTF-8 `encoding=`, a key read as
311
384
  U+FFFD cannot be deleted or updated. `tsvz` exits 1 with the message. See Fault
312
385
  tolerance.
@@ -324,8 +397,7 @@ damaged file, as asked, and warns.
324
397
  merged into it (Fault tolerance).
325
398
  - **`.tsvz` stores** see another process's append made right after their load, a
326
399
  commit or a clear (4.9 missed it until the next change), keep the rows set while
327
- they reload, refuse `#_snapshot_#` (spec §19.10.2), and raise a strict header
328
- mismatch at the first read.
400
+ they reload, and raise a strict header mismatch at the first read.
329
401
  - **`.tsv` files:** an append to an existing empty file writes the header first, and
330
402
  reading one gives an empty store; see L8 to L14 for the rest.
331
403
  - **`tsvz`** runs every operation through the engine `tsvz serve` uses, with the same
@@ -334,8 +406,15 @@ damaged file, as asked, and warns.
334
406
  file. The 3.39 warning that a file name does not end in `.tsv` is printed only
335
407
  when a command creates such a file, and a write that cannot be made reports the
336
408
  operating system's error.
337
- - **macOS and other systems that lock with `lockf`:** 4.9's close gate is gone, so
338
- a read in another thread of the process can drop a write's lock
409
+ - **Running programs keep 4.9.** Long-running programs that import TSVZ, and running
410
+ `tsvz serve` handlers, keep 4.9 until they are restarted. 5.0's `tsvz stop` cannot
411
+ stop a 4.9 handler of a `.tsv` (or other tab- or NUL-delimited) file: 4.9 wrote the
412
+ delimiter into the pointer file's `x-delimiter` as `<sep>` (a NUL as `<NUL>`), which
413
+ 5.0 decodes to the TAB or NUL itself and refuses as a control character. Send that
414
+ handler SIGTERM instead: its pid is on the `pid` line of its pointer file
415
+ (`STORE.serve`), and it finishes its writes and exits.
416
+ - **macOS and other systems that lock with `lockf`:** as in 4.9, a read in another
417
+ thread of the process can still drop a write's lock
339
418
  ([docs/limitations.md](docs/limitations.md)).
340
419
 
341
420
  ## Breaking changes from 3.39
@@ -358,6 +437,11 @@ damaged file, as asked, and warns.
358
437
  | L12 | A rewrite of a file whose header line has no newline writes the rows on lines of their own; 3.39 wrote the first one onto the header line. |
359
438
  | L13 | A compressed file is rewritten in members of at most 1 MiB, as appends already were: the bytes differ, the rows read are the same, and 3.39 reads them. |
360
439
  | L14 | `TSVZed` opening a compressed file in a codec this Python lacks (zstd before Python 3.14) raises `ImportError` instead of reading the compressed bytes as text. |
440
+ | L15 | `scrubTabularFile`, `clearTabularFile`, `tsvz scrub`, `tsvz clear` and `TSVZedLite.clear_file()` write the new content over the file and then cut it, under the file's lock; 3.39 emptied the file first, with no lock, so a reader could meet it empty. |
441
+ | L16 | A scrub writes only if the file is still what its read saw: it reads again up to three times, then writes nothing, with a warning (`tsvz scrub` exits 1, C6); 3.39 wrote over rows appended after its read. |
442
+ | L17 | `scrubTabularFile(lastLineOnly=True)` returns the last row and writes nothing; 3.39 wrote that row back as deletion lines, and the store was gone. |
443
+ | L18 | A scrub keeps the file's `#_defaults_#` lines (an empty one before the rows, the defaults in effect after them), so every row reads as it did; 3.39 dropped them, so rows appended later got no defaults. |
444
+ | L19 | A scrub given no header, and a `TSVZed` given none, keep a file's header line first; 3.39 put an empty line in front of it, and a reader given the header then read the header as a row. |
361
445
 
362
446
  ### `.tsvz`, `.csvz`, `.nsvz`, `.psvz`: now tsvz-spec-v1
363
447
 
@@ -42,10 +42,10 @@ if os.name == 'nt':
42
42
  elif os.name == 'posix':
43
43
  import fcntl
44
44
 
45
- version = '5.0'
45
+ version = '5.1'
46
46
  __version__ = version
47
47
  author = 'pan@zopyr.us'
48
- COMMIT_DATE = '2026-10-29'
48
+ COMMIT_DATE = '2026-11-01'
49
49
 
50
50
  DEFAULT_DELIMITER = '\t'
51
51
  DEFAULTS_INDICATOR_KEY = '#_defaults_#'
@@ -203,8 +203,9 @@ class _RewriteRows(object):
203
203
  in place after the snapshot is written as it was then.
204
204
  """
205
205
 
206
- def __init__(self, first, values, delimiter, encoding, errors, header):
206
+ def __init__(self, first, values, delimiter, encoding, errors, header, later=()):
207
207
  self.first = first
208
+ self.later = later # lines written after the first row (or alone, when there is none)
208
209
  cells = []
209
210
  lengths = array.array('I')
210
211
  extend, append = cells.extend, lengths.append
@@ -249,8 +250,14 @@ class _RewriteRows(object):
249
250
  for line in self.first:
250
251
  yield line
251
252
  encode, delimiter = self.encode, self.delimiter
253
+ later = self.later
252
254
  for row in self._rows():
253
255
  yield encode(_sanitize(row,delimiter=delimiter))
256
+ for line in later:
257
+ yield line
258
+ later = ()
259
+ for line in later:
260
+ yield line
254
261
 
255
262
 
256
263
  def _fileIdentity(st):
@@ -1330,6 +1337,7 @@ def _processLine(line,taskDic,correctColumnNum,strict = True,delimiter = ...,def
1330
1337
  lineCache = _unsanitize(line.split(delimiter),delimiter)
1331
1338
  if not lineCache or not lineCache[0]:
1332
1339
  return correctColumnNum , []
1340
+ asRead = lineCache[:] if lineCache[0] == DEFAULTS_INDICATOR_KEY else None # (filled and fitted to the columns below)
1333
1341
  if correctColumnNum == -1:
1334
1342
  if defaults and len(defaults) > 1:
1335
1343
  correctColumnNum = len(defaults)
@@ -1345,6 +1353,9 @@ def _processLine(line,taskDic,correctColumnNum,strict = True,delimiter = ...,def
1345
1353
  # __teePrintOrNot(f"Empty defaults line found: {line}",teeLogger=teeLogger)
1346
1354
  defaults.clear()
1347
1355
  defaults[:] = [DEFAULTS_INDICATOR_KEY]
1356
+ if isinstance(defaults, _DefaultsList):
1357
+ defaults.line = [DEFAULTS_INDICATOR_KEY]
1358
+ defaults.reset = True
1348
1359
  else:
1349
1360
  # if verbose:
1350
1361
  # __teePrintOrNot(f"Key {lineCache[0]} found with empty value, deleting such key's representaion",teeLogger=teeLogger)
@@ -1373,6 +1384,8 @@ def _processLine(line,taskDic,correctColumnNum,strict = True,delimiter = ...,def
1373
1384
  # if verbose:
1374
1385
  # __teePrintOrNot(f"Defaults line found: {line}",teeLogger=teeLogger)
1375
1386
  defaults[:] = lineCache
1387
+ if isinstance(defaults, _DefaultsList):
1388
+ defaults.line = _chainFill(asRead, defaults.line)
1376
1389
  return correctColumnNum , []
1377
1390
  taskDic[lineCache[0]] = lineCache if not storeOffset else offset
1378
1391
  # if verbose:
@@ -1995,9 +2008,14 @@ def _legacyRows(linesToAppend, delimiter):
1995
2008
  return formatedLines
1996
2009
 
1997
2010
 
1998
- def _legacyFormatPayload(fileName, linesToAppend, teeLogger, header, verifyHeader, verbose, encoding, strict, delimiter,
1999
- errors='strict', rows=None, assume=None):
2000
- """Format rows as 3.39's ``appendLinesTabularFile`` writes them; return ``(payload, count)``.
2011
+ def _legacyFormatPayload(*args, **kwargs):
2012
+ """Format rows as 3.39's ``appendLinesTabularFile`` writes them; return ``(payload, count)`` (_legacyFormatRows)."""
2013
+ return _legacyFormatRows(*args, **kwargs)[:2]
2014
+
2015
+ def _legacyFormatRows(fileName, linesToAppend, teeLogger, header, verifyHeader, verbose, encoding, strict, delimiter,
2016
+ errors='strict', rows=None, assume=None):
2017
+ """Format rows as 3.39's ``appendLinesTabularFile`` writes them; return ``(payload, count, columns)``: ``columns``
2018
+ is the column count they are padded or cut to (0 for no row).
2001
2019
 
2002
2020
  ``header`` is a formatted header list and ``delimiter`` a resolved
2003
2021
  delimiter. Rows are sanitised and padded or cut to the column count: the
@@ -2010,14 +2028,13 @@ def _legacyFormatPayload(fileName, linesToAppend, teeLogger, header, verifyHeade
2010
2028
  """
2011
2029
  formatedLines = _legacyRows(linesToAppend, delimiter) if rows is None else rows
2012
2030
  if not formatedLines:
2013
- return b'', 0
2031
+ return b'', 0, 0
2014
2032
  correctColumnNum = max([len(line) for line in formatedLines])
2015
2033
  if any(header) and verifyHeader:
2016
2034
  if assume is not None:
2017
2035
  line = delimiter.join(assume)
2018
2036
  else:
2019
- with openFileAsCompressed(fileName, mode ='rb',encoding=encoding,teeLogger=teeLogger) as file:
2020
- line = file.readline().decode(encoding=encoding,errors='replace')
2037
+ line = _legacyFirstLine(fileName, encoding, teeLogger)
2021
2038
  if _lineContainHeader(header,line,verbose = verbose,teeLogger = teeLogger,strict = strict,delimiter = delimiter):
2022
2039
  correctColumnNum = len(header)
2023
2040
  if verbose:
@@ -2031,17 +2048,27 @@ def _legacyFormatPayload(fileName, linesToAppend, teeLogger, header, verifyHeade
2031
2048
  line = line[:correctColumnNum]
2032
2049
  fitted.append(line)
2033
2050
  payload = b'\n'.join([_encodeRow(line,delimiter,encoding,errors,header) for line in fitted]) + b'\n'
2034
- return payload, len(fitted)
2051
+ return payload, len(fitted), correctColumnNum
2052
+
2053
+ def _legacyFirstLine(fileName, encoding, teeLogger):
2054
+ """The first line of the legacy file ``fileName`` (which exists) as text; '' for a file that holds nothing: no byte,
2055
+ or, compressed, only empty streams (a 0-byte .bz2 or .xz is no stream to read, as _fileHoldsNothing's readers know)."""
2056
+ if _isCompressedFile(fileName) and _fileHoldsNothing(fileName):
2057
+ return ''
2058
+ with openFileAsCompressed(fileName, mode ='rb',encoding=encoding,teeLogger=teeLogger) as file:
2059
+ return file.readline().decode(encoding=encoding,errors='replace')
2035
2060
 
2036
2061
  def _legacyKeptHeader(fileName, header, verifyHeader, verbose, teeLogger, encoding, strict, delimiter, warn=True):
2037
- """The header ``clearTabularFile`` leaves in the file: the file's own when a header is given and ``verifyHeader``, else ``header``."""
2062
+ """The header ``clearTabularFile`` leaves in the file: the file's own first line when a header is given and
2063
+ ``verifyHeader`` (the header given for a file that holds nothing), else ``header``; [] when it has no cell: the file
2064
+ is then left with no header line."""
2038
2065
  if any(header) and verifyHeader:
2039
- with openFileAsCompressed(fileName, mode ='rb',encoding=encoding,teeLogger=teeLogger) as file:
2040
- line = file.readline().decode(encoding=encoding,errors='replace')
2041
- if warn and not _lineContainHeader(header,line,verbose = verbose,teeLogger = teeLogger,strict = strict,delimiter = delimiter):
2042
- __teePrintOrNot(f'Warning: Header mismatch in {fileName}. Keeping original header in file...','warning',teeLogger)
2043
- return _formatHeader(line,verbose = verbose,teeLogger = teeLogger,delimiter=delimiter)
2044
- return header
2066
+ line = _legacyFirstLine(fileName, encoding, teeLogger)
2067
+ if line: # a file that holds nothing ('') is an empty store with any header, as the readers take it: the one given is kept
2068
+ if warn and not _lineContainHeader(header,line,verbose = verbose,teeLogger = teeLogger,strict = strict,delimiter = delimiter):
2069
+ __teePrintOrNot(f'Warning: Header mismatch in {fileName}. Keeping original header in file...','warning',teeLogger)
2070
+ header = _formatHeader(line,verbose = verbose,teeLogger = teeLogger,delimiter=delimiter)
2071
+ return header if any(header) else [] # no header: no header line (3.39 wrote an empty one, read back as a row)
2045
2072
 
2046
2073
 
2047
2074
  def clearTSV(fileName,teeLogger = None,header = '',verifyHeader = False,verbose = False,encoding = 'utf8',strict = False,delimiter = '\t',errors = 'strict'):
@@ -2082,16 +2109,74 @@ def clearTabularFile(fileName,teeLogger = None,header = '',verifyHeader = False,
2082
2109
  header = _formatHeader(header,verbose = verbose,teeLogger = teeLogger,delimiter=delimiter)
2083
2110
  if not _verifyFileExistence(fileName,createIfNotExist = True,teeLogger = teeLogger,header = header,encoding = encoding,strict = False,delimiter=delimiter,errors = errors):
2084
2111
  raise FileNotFoundError("Something catastrophic happened! File still not found after creation")
2085
- else:
2086
- header = _legacyKeptHeader(fileName, header, verifyHeader, verbose, teeLogger, encoding, strict, delimiter)
2087
- # Encode before the file is opened for writing, which empties it.
2088
- headerBytes = _encodeRow(_sanitize(header,delimiter=delimiter),delimiter,encoding,errors,what='header') if header else None
2089
- with openFileAsCompressed(fileName, mode ='wb',encoding=encoding,teeLogger=teeLogger) as file:
2090
- if headerBytes is not None:
2091
- file.write(headerBytes+b'\n')
2112
+ kept = _legacyKeptHeader(fileName, header, verifyHeader, verbose, teeLogger, encoding, strict, delimiter)
2113
+ # With no header the file keeps the empty first line that stands for it, as 3.39 and 5.0 left it (a reader given a
2114
+ # header the file lacks takes that line for it, rather than a row).
2115
+ _legacyReplace(fileName, kept, True, encoding, delimiter, errors)
2092
2116
  if verbose:
2093
2117
  __teePrintOrNot(f"Cleared {fileName}",teeLogger=teeLogger)
2094
2118
 
2119
+ def _isRowLine(text, delimiter):
2120
+ """True when the line ``text`` is a data row to a reader told no header: not what ``_processLine`` skips (empty, a
2121
+ comment, a line with no key) and not a defaults line. A first line that is not one is where a reader given a header
2122
+ the file lacks loses nothing: it takes that line for the header."""
2123
+ line = text.strip('\x00').rstrip('\r\n')
2124
+ if not line or (line.startswith('#') and not line.startswith(DEFAULTS_INDICATOR_KEY)):
2125
+ return False
2126
+ cells = _unsanitize(line.split(delimiter),delimiter)
2127
+ return bool(cells and cells[0] and cells[0] != DEFAULTS_INDICATOR_KEY)
2128
+
2129
+ def _legacyBeginsBlank(fileName, encoding, teeLogger, delimiter):
2130
+ """True when the legacy file ``fileName`` holds nothing, cannot be read, or begins with a line that is not a data row
2131
+ (_isRowLine): then a header-less whole write puts the empty line that stands for a header in front of its rows, as
2132
+ 3.39 and 5.0 always do; never in front of a first line that is a row (it would push a header to line 2)."""
2133
+ try:
2134
+ return not _isRowLine(_legacyFirstLine(fileName, encoding, teeLogger), delimiter)
2135
+ except Exception:
2136
+ return True
2137
+
2138
+ def _legacyBeginsBlankIn(f, encoding, delimiter):
2139
+ """_legacyBeginsBlank for the open binary legacy file ``f`` (at its raw level), read through that descriptor and left
2140
+ open: a lockf lock is dropped by closing any descriptor of the file, so a writer that holds one must not open the
2141
+ file again to look. ``f`` is left at offset 0."""
2142
+ codec = _fileCodec(f.name)
2143
+ try:
2144
+ f.flush()
2145
+ if _holdsNothing(f, codec, os.fstat(f.fileno()).st_size):
2146
+ return True
2147
+ f.seek(0)
2148
+ stream = _openStreams(codec, f) if codec else f
2149
+ try:
2150
+ line = stream.readline()
2151
+ finally:
2152
+ if codec:
2153
+ stream.close()
2154
+ return not _isRowLine(line.decode(encoding or 'utf8', errors='replace'), delimiter)
2155
+ except Exception:
2156
+ return True
2157
+ finally:
2158
+ f.seek(0)
2159
+
2160
+ def _legacyReplace(fileName, kept, blank, encoding, delimiter, errors, payload=b'', expect=None):
2161
+ """Make the existing legacy file ``fileName`` hold ``kept``, the header list it keeps (_legacyKeptHeader), and then
2162
+ ``payload`` (whole lines): the one write of ``clearTabularFile`` and ``scrubTabularFile``. ``delimiter`` is a
2163
+ resolved delimiter. Where ``kept`` is empty, ``blank`` says whether the file begins with an empty line in its
2164
+ place (never in front of a first line that is a row).
2165
+
2166
+ The new bytes are made whole first, then written over the file from its start and the file is cut there, under
2167
+ its locks (_specRewriteInPlace: spec §18.7 / §19.8, as ``TSVZed``'s whole rewrites do), so a reader that takes no
2168
+ lock never meets the file emptied. A compressed file is written as whole members of at most 1 MiB. Nothing is
2169
+ written when the header cannot be encoded. ``expect``, the ``_specStamp`` of the file as a caller read it: the
2170
+ file is left alone when it no longer matches under the lock (another writer committed since), and None is
2171
+ returned; else the result is not None."""
2172
+ # Encoded before the file is written to, which would leave it cut short.
2173
+ headerBytes = _encodeRow(_sanitize(kept,delimiter=delimiter),delimiter,encoding,errors,what='header') + b'\n' if kept else b'\n' if blank else b''
2174
+ if not stat.S_ISREG(os.stat(fileName).st_mode):
2175
+ with open(fileName, 'wb') as f: # /dev/null holds nothing to empty, and cannot be cut or given space
2176
+ f.write(headerBytes + payload)
2177
+ return True, None
2178
+ return _specRewriteInPlace(fileName, headerBytes + payload, expect=expect)
2179
+
2095
2180
  def getFileUpdateTimeNs(fileName):
2096
2181
  # return 0 if the file does not exist
2097
2182
  if not os.path.isfile(fileName):
@@ -2179,22 +2264,96 @@ def scrubTabularFile(fileName,teeLogger = None,header = '',createIfNotExist = Fa
2179
2264
  lastLineOnly=lastLineOnly, verifyHeader=verifyHeader, verbose=verbose,
2180
2265
  taskDic=taskDic, encoding=encoding, strict=strict, delimiter=delimiter,
2181
2266
  defaults=defaults, correctColumnNum=correctColumnNum, errors=errors)
2182
- file = readTabularFile(fileName,teeLogger = teeLogger,header = header,createIfNotExist = createIfNotExist,
2183
- lastLineOnly = lastLineOnly,verifyHeader = verifyHeader,verbose = verbose,taskDic = taskDic,
2184
- encoding = encoding,strict = strict,delimiter = delimiter,defaults=defaults,correctColumnNum = correctColumnNum,errors = errors)
2185
- if file:
2186
- # Everything that is to be written is looked at, and encoded, before the file is touched: the clear
2187
- # below empties it, and rows that took the caller's defaults can hold text it cannot write.
2267
+ if lastLineOnly: # a read option, as for a .tsvz (a scrub of the last line alone would leave the file holding that)
2268
+ return readTabularFile(fileName,teeLogger = teeLogger,header = header,createIfNotExist = createIfNotExist,
2269
+ lastLineOnly = True,verifyHeader = verifyHeader,verbose = verbose,taskDic = taskDic,
2270
+ encoding = encoding,strict = strict,delimiter = delimiter,defaults=defaults,correctColumnNum = correctColumnNum,errors = errors)
2271
+ return _legacyScrub(fileName,teeLogger = teeLogger,header = header,createIfNotExist = createIfNotExist,verifyHeader = verifyHeader,
2272
+ verbose = verbose,taskDic = taskDic,encoding = encoding,strict = strict,delimiter = delimiter,
2273
+ defaults=defaults,correctColumnNum = correctColumnNum,errors = errors)[0]
2274
+
2275
+
2276
+ def _chainFill(line, previous):
2277
+ """The cells of a ``#_defaults_#`` line as they stand once its empty cells are filled from the line before it in the
2278
+ file (``previous``, None for the first, or after an empty one): the defaults in effect after it."""
2279
+ if not previous or len(previous) < 2:
2280
+ return line
2281
+ cells = list(line) + [''] * (len(previous) - len(line)) # (a shorter line has the empty cells it lacks)
2282
+ return [cell if cell or i == 0 or i >= len(previous) else previous[i] for i, cell in enumerate(cells)]
2283
+
2284
+
2285
+ class _DefaultsList(list):
2286
+ """The defaults list of a read: ``found`` says whether the file's own ``#_defaults_#`` line set it (_processLine
2287
+ replaces its cells in place), not the caller's defaults it began with."""
2288
+ found = False
2289
+ reset = False # an empty defaults line was read (it stays one whatever follows it)
2290
+ line = None # the cells of that line as the file has them (``defaults`` holds them filled and fitted to the columns)
2291
+
2292
+ def __setitem__(self, index, value):
2293
+ self.found = True
2294
+ list.__setitem__(self, index, value)
2295
+
2296
+
2297
+ def _legacyScrub(fileName,teeLogger = None,header = '',createIfNotExist = False,verifyHeader = True,verbose = False,
2298
+ taskDic = None,encoding = 'utf8',strict = False,delimiter = ...,defaults = ...,correctColumnNum = -1,errors = 'strict'):
2299
+ """Compact a legacy file in place; return ``(taskDic, done)``: the rows read, and whether the file was written (or
2300
+ had nothing to write). ``done`` is False when nothing was written because the file kept changing (the shape of
2301
+ ``_specScrub``): it is rewritten only if it is what the read saw (spec §19.8), so a row another process appends
2302
+ after the read is not scrubbed away; the read is made again, up to ``_SPEC_REWRITE_ATTEMPTS`` times.
2303
+
2304
+ The file's own ``#_defaults_#`` lines are written as the defaults in effect at the end of the read: the last
2305
+ non-empty one after the rows (those before it keep their empty cells, those after it were filled when read), so
2306
+ that every row reads as it did and the header, if any, stays on the first line. An empty one that was read is
2307
+ written before the rows (_legacyScrub, ``reset``), as wide as they are. The defaults the caller passed are not
2308
+ written. With no header the file begins with an empty line only if the first line of the file was not a row."""
2309
+ given = list(taskDic.items()) if taskDic else []
2310
+ givenDefaults = list(defaults) if defaults not in (..., None) else []
2311
+ for tries in range(_SPEC_REWRITE_ATTEMPTS):
2312
+ if tries and taskDic is not None:
2313
+ # Read again as the caller passed it.
2314
+ taskDic.clear()
2315
+ taskDic.update(given)
2316
+ readDefaults = _DefaultsList(givenDefaults)
2317
+ # The stamp is taken before the read, and compared under the file lock.
2318
+ stamp = _specFileStamp(fileName)
2319
+ file = readTabularFile(fileName,teeLogger = teeLogger,header = header,createIfNotExist = createIfNotExist,
2320
+ verifyHeader = verifyHeader,verbose = verbose,taskDic = taskDic,
2321
+ encoding = encoding,strict = strict,delimiter = delimiter,defaults=readDefaults,correctColumnNum = correctColumnNum,errors = errors)
2322
+ if not file:
2323
+ return file, True
2188
2324
  dlm = get_delimiter(delimiter,file_name=fileName)
2189
2325
  formatted = _formatHeader(header,delimiter=dlm)
2190
- kept = _legacyKeptHeader(fileName, formatted, verifyHeader, verbose, teeLogger, encoding, strict, dlm, warn=False)
2191
- if kept:
2192
- _checkRow(_sanitize(kept,delimiter=dlm), dlm, encoding, errors, what='header')
2193
- payload, count = _legacyFormatPayload(fileName, file, teeLogger, formatted, verifyHeader, verbose, encoding,
2194
- strict, dlm, errors, assume=_sanitize(kept,delimiter=dlm) if kept else [])
2195
- clearTabularFile(fileName,teeLogger = teeLogger,header = header,verifyHeader = verifyHeader,verbose = verbose,encoding = encoding,strict = strict,delimiter = delimiter,errors = errors)
2196
- _legacyWritePayload(fileName, payload, count, teeLogger, verbose, encoding)
2197
- return file
2326
+ if not _verifyFileExistence(fileName,createIfNotExist = True,teeLogger = teeLogger,header = formatted,encoding = encoding,strict = False,delimiter=dlm,errors = errors):
2327
+ raise FileNotFoundError("Something catastrophic happened! File still not found after creation") # (removed since the read: made again)
2328
+ kept = _legacyKeptHeader(fileName, formatted, verifyHeader, verbose, teeLogger, encoding, strict, dlm)
2329
+ # Everything that is to be written is looked at, and encoded, before the file is touched:
2330
+ # rows that took the caller's defaults can hold text it cannot write.
2331
+ payload, count, columns = _legacyFormatRows(fileName, file, teeLogger, formatted, verifyHeader, verbose, encoding,
2332
+ strict, dlm, errors, assume=_sanitize(kept,delimiter=dlm) if kept else [])
2333
+ blank = not kept and _legacyBeginsBlank(fileName, encoding, teeLogger, dlm)
2334
+ if readDefaults.reset and columns > 1:
2335
+ # An empty defaults line was read: no defaults reach the rows that follow it, whoever reads them (the rows
2336
+ # were read with it, and any the caller of that read gave are not written). Written before the rows, so
2337
+ # that none are filled: after the header, or the empty line standing for one, or else the first row (a
2338
+ # header, to the store owning the file); as wide as the rows, which is the column count a reader takes from
2339
+ # the first line it reads, and still an empty defaults line to one that reads it anywhere.
2340
+ reset = _encodeRow([DEFAULTS_INDICATOR_KEY] + [''] * (columns - 1),dlm,encoding,errors,what='defaults') + b'\n'
2341
+ if kept or blank:
2342
+ payload = reset + payload
2343
+ else:
2344
+ first, newline, rest = payload.partition(b'\n')
2345
+ payload = first + newline + reset + rest
2346
+ last = readDefaults.line if readDefaults.found else None
2347
+ if last and len(last) > 1:
2348
+ payload += _encodeRow(_sanitize(last,delimiter=dlm),dlm,encoding,errors,what='defaults') + b'\n'
2349
+ if _legacyReplace(fileName, kept, blank, encoding, dlm, errors, payload, expect=stamp) is None:
2350
+ continue # another writer changed the file after the read
2351
+ if verbose:
2352
+ __teePrintOrNot(f"Cleared {fileName}",teeLogger=teeLogger)
2353
+ __teePrintOrNot(f"Appended {count} lines to {fileName}" if count else f"No lines to append to {fileName}",teeLogger=teeLogger)
2354
+ return file, True
2355
+ __teePrintOrNot(f'Warning: scrub of {fileName} skipped: the file kept changing while it was being scrubbed; nothing was written','warning',teeLogger)
2356
+ return file, False
2198
2357
 
2199
2358
  # ===========================================================================
2200
2359
  # TSVZ spec v1 dialect (.tsvz / .csvz / .nsvz / .psvz) -- see tsvz-spec-v1.md
@@ -3586,7 +3745,8 @@ def _matchOwner(fd, st):
3586
3745
 
3587
3746
 
3588
3747
  def _specRewriteInPlace(path, content, expect=None, memberSize=None):
3589
- """Replace a part's bytes in place (same inode, spec §18.7 / §19.8).
3748
+ """Replace a part's bytes in place (same inode, spec §18.7 / §19.8): the write of a spec store's scrub and
3749
+ clear, and of a legacy file's (_legacyReplace).
3590
3750
 
3591
3751
  ``content`` is uncompressed; it is compressed for a compressed part. The
3592
3752
  file is left alone when its bytes already match. Returns ``(written, stamp)``:
@@ -3597,7 +3757,7 @@ def _specRewriteInPlace(path, content, expect=None, memberSize=None):
3597
3757
  """
3598
3758
  codec = _parsePartName(path).codec
3599
3759
  _requireCodec(codec)
3600
- raw = _compressBytes(codec, content, memberSize=memberSize) if codec and content else content
3760
+ raw = _compressBytes(codec, content, memberSize=memberSize) if codec else content # (nothing: one empty member)
3601
3761
  with _pathLock(path):
3602
3762
  with open(path, 'r+b') as f:
3603
3763
  _lockFile(f)
@@ -3606,19 +3766,29 @@ def _specRewriteInPlace(path, content, expect=None, memberSize=None):
3606
3766
  if expect is not None and _specStamp(st) != expect:
3607
3767
  return None
3608
3768
  fd = f.fileno()
3609
- # The whole part, for a failed rewrite to put back whenever it fails. Read unbuffered, as
3610
- # the file is written below: a read-ahead in f would leave it a stale position to close at.
3611
- old = _readFrom0(fd, st.st_size)
3612
- if old == raw:
3769
+ # Read unbuffered, as the file is written below: a read-ahead in f would leave it a stale position to
3770
+ # close at. What a failed rewrite puts back is kept: the bytes the write overwrites (from the start to
3771
+ # the shorter end) and the old size to cut back to. A clear of a huge file reads none of it.
3772
+ size = st.st_size
3773
+ old = _readFrom0(fd, min(size, len(raw)))
3774
+ if size == len(raw) and old == raw:
3613
3775
  return False, _specStamp(st)
3614
3776
  try:
3615
3777
  _reserveSpace(fd, len(raw)) # spec §19.8: a full disk refuses the rewrite before any byte changes
3616
3778
  _writeFrom0(fd, raw)
3617
3779
  except BaseException as e:
3780
+ # Once the cut is made the rewrite is done: only the cut makes the file shorter, and it follows the
3781
+ # whole write, so the new bytes are complete (what was cut off is not kept to put back).
3782
+ if os.fstat(fd).st_size < size:
3783
+ raise
3618
3784
  # Put the old bytes back rather than leave a half-rewritten part (or one a failed
3619
- # reservation lengthened).
3785
+ # reservation lengthened): the write left what it did not overwrite as it was.
3620
3786
  try:
3621
- _writeFrom0(fd, old)
3787
+ os.lseek(fd, 0, os.SEEK_SET)
3788
+ view = memoryview(old)
3789
+ while view:
3790
+ view = view[os.write(fd, view):]
3791
+ os.ftruncate(fd, size)
3622
3792
  os.fsync(fd)
3623
3793
  except OSError as undo:
3624
3794
  damaged = '{}: the rewrite failed ({!r}) and the old bytes could not be put back ({}): the part is damaged'.format(
@@ -4970,7 +5140,7 @@ class TSVZed(OrderedDict):
4970
5140
  """clear_file() for a legacy file; ``dropRows`` (clear()) also empties memory and drops the queued rows, once the
4971
5141
  file is open and locked (when it cannot be, nothing changed), under _memLock: no set is half done, and no row
4972
5142
  set before it is kept or appended after the emptied file. Queued defaults lines are kept: the defaults stay."""
4973
- header = self._headerLine()
5143
+ header = self._headerLine(None, clearing=True)
4974
5144
 
4975
5145
  def emptied():
4976
5146
  if dropRows:
@@ -5224,7 +5394,7 @@ memoryOnly:{self.memoryOnly}
5224
5394
  f"in a row up to a minute.")
5225
5395
  else:
5226
5396
  retry = ("With rewrite_interval=0 none is tried again on its own, only one asked for (mapToFile(), "
5227
- "rewrite(force=True), rewrite_on_exit), one made after another process changes the file, or, while "
5397
+ "hardMapToFile(), rewrite(force=True), rewrite_on_exit), one made after another process changes the file, or, while "
5228
5398
  "damage another writer left is to be made good, one at a later append.")
5229
5399
  damaged = (" The file is damaged (a rewrite of this object's failed) or was removed (rows are appended to a new "
5230
5400
  "one): until a rewrite gets through, memory holds the only good copy." if self._damaged else "")
@@ -5281,31 +5451,38 @@ memoryOnly:{self.memoryOnly}
5281
5451
  now = _specClock()
5282
5452
  return any(state is not None and now < state[1] for state in map(self._failures.get, kinds))
5283
5453
 
5284
- def _headerLine(self):
5454
+ def _headerLine(self, f, clearing=False):
5285
5455
  """The header line a legacy file of this store begins with, as bytes without its newline; None for no header.
5286
5456
 
5287
- Checked by the constructor: it can be written."""
5457
+ Checked by the constructor: it can be written. A store with no header (['']) begins the file with the empty line
5458
+ that stands for one (3.39 and 5.0 did, always) when the file holds nothing or begins with a line that is not a
5459
+ data row, and when it is cleared; it never puts one in front of a first line that is a row. ``f`` is the open,
5460
+ locked file a rewrite is to write: looked at through it (not by name, which would drop a lockf lock)."""
5288
5461
  if not self.header:
5289
5462
  return None
5463
+ if not clearing and not any(self.header) and not _legacyBeginsBlankIn(f, self.encoding, self.delimiter):
5464
+ return None
5290
5465
  return _encodeRow(_sanitize(list(self.header),delimiter=self.delimiter),self.delimiter,self.encoding,self.errors,what='header')
5291
5466
 
5292
- def _snapshotForRewrite(self):
5467
+ def _snapshotForRewrite(self, f):
5293
5468
  """``(header, lines, queued)``: the header line (None for none), the defaults line and the rows (_RewriteRows, a
5294
5469
  flat snapshot taken under _memLock and checked: a TSVZEncodeError changes nothing), and the queued rows, taken
5295
5470
  from the queue: memory holds them, so the rewrite writes them; _writeFile puts them back if it stops."""
5296
5471
  delimiter, encoding, errors = self.delimiter, self.encoding, self.errors
5297
- header = self._headerLine()
5472
+ header = self._headerLine(f)
5298
5473
  with self._memLock:
5299
5474
  values = list(self.values())
5300
5475
  defaults = list(self.defaults)
5301
5476
  queued, self.appendQueue = self.appendQueue, deque()
5302
5477
  try:
5303
- first = []
5478
+ first = later = []
5304
5479
  if defaults and len(defaults) > 1:
5305
5480
  # The defaults line comes right after the header, so it survives a
5306
- # full rewrite -- it is not a regular mapping key.
5307
- first.append(_encodeRow(_sanitize(defaults,delimiter=delimiter),delimiter,encoding,errors,what='defaults'))
5308
- rows = _RewriteRows(first, values, delimiter, encoding, errors, self.header)
5481
+ # full rewrite -- it is not a regular mapping key. With no header line written the first line
5482
+ # is a row (a header, to the store that owns the file): after that.
5483
+ line = _encodeRow(_sanitize(defaults,delimiter=delimiter),delimiter,encoding,errors,what='defaults')
5484
+ first, later = ([line], []) if header is not None else ([], [line])
5485
+ rows = _RewriteRows(first, values, delimiter, encoding, errors, self.header, later)
5309
5486
  del values # the snapshot holds the cells
5310
5487
  rows.check()
5311
5488
  except BaseException:
@@ -5362,7 +5539,7 @@ memoryOnly:{self.memoryOnly}
5362
5539
  self._syncFrom(f, True)
5363
5540
  elif sync is False and self._externalChange():
5364
5541
  self.__teePrintOrNot(f"Warning: Overwriting external changes in {self._fileName}",'warning')
5365
- header, lines, queued = (snapshot or self._snapshotForRewrite)()
5542
+ header, lines, queued = snapshot() if snapshot else self._snapshotForRewrite(f)
5366
5543
  if not sync or self._damaged or attempt == 2 or not self._changedSince(f):
5367
5544
  break
5368
5545
  # A writer that takes no lock appended while the rows were taken: taken in too, and taken again.
@@ -5857,7 +6034,7 @@ memoryOnly:{self.memoryOnly}
5857
6034
  reporter = _Reporter(self._fileName, self.teeLogger)
5858
6035
  written = []
5859
6036
  try:
5860
- _appendOpen(f, b''.join(chunks), codec, self._headerLine if any(self.header) else None, 'newline',
6037
+ _appendOpen(f, b''.join(chunks), codec, (lambda: self._headerLine(f)) if any(self.header) else None, 'newline',
5861
6038
  reporter, written=written)
5862
6039
  _fsync(f.fileno()) # a sync that fails fails the append: the rows stay queued
5863
6040
  finally:
@@ -6363,23 +6540,15 @@ class TSVZedLite(MutableMapping):
6363
6540
  if not cleared:
6364
6541
  raise OSError('{} was not cleared: the clear was refused (see the warning above)'.format(self._fileName))
6365
6542
  return self
6366
- if self.fileObj is None:
6367
- clearTabularFile(self._fileName, header=self.header, verbose=self.verbose,
6368
- encoding=self.encoding, delimiter=self.delimiter, errors=self.errors)
6369
- return self
6370
6543
  if self.verbose:
6371
6544
  eprint(f"Clearing {self._fileName}")
6372
- self.fileObj.seek(0)
6373
- self.fileObj.truncate()
6374
- if self.verbose:
6375
- eprint(f"File {self._fileName} cleared empty")
6376
- if self.header:
6377
- location = self.__writeValues(self.header, headerFirst=False)
6378
- if self.verbose:
6379
- eprint(f"Header {self.header} written to {self._fileName}")
6380
- eprint(f"At {location} size: {self.fileObj.tell()}")
6545
+ # Written over the file and cut under its lock, as TSVZed's rewrites are (the file object stays the file's).
6546
+ clearTabularFile(self._fileName, header=self.header, verbose=self.verbose,
6547
+ encoding=self.encoding, delimiter=self.delimiter, errors=self.errors)
6548
+ if self.fileObj is not None:
6549
+ self.fileObj.seek(0, os.SEEK_END) # (and drops what it had read ahead)
6381
6550
  return self
6382
-
6551
+
6383
6552
  def switchFile(self,newFileName,createIfNotExist = ...,verifyHeader = ...):
6384
6553
  if createIfNotExist is ...:
6385
6554
  createIfNotExist = self.createIfNotExist
@@ -7349,9 +7518,8 @@ class _ServeStore(object):
7349
7518
  if self.spec:
7350
7519
  return _specScrub(self.path, teeLogger=log, verifyHeader=False, verbose=self.verbose,
7351
7520
  strict=self.strict, delimiter=self.delimiter, defaults=self.defaults)[1]
7352
- scrubTabularFile(self.path, teeLogger=log, verifyHeader=False, verbose=self.verbose,
7353
- strict=self.strict, delimiter=self.delimiter, defaults=self.defaults)
7354
- return True
7521
+ return _legacyScrub(self.path, teeLogger=log, verifyHeader=False, verbose=self.verbose,
7522
+ strict=self.strict, delimiter=self.delimiter, defaults=self.defaults)[1]
7355
7523
  finally:
7356
7524
  self.parts = None # read again at the next request
7357
7525
 
@@ -8424,7 +8592,8 @@ operations:
8424
8592
  set STORE KEY [VALUE ...] write a row; KEY alone deletes it; '-' reads rows from stdin
8425
8593
  append ... same as set
8426
8594
  delete STORE KEY [KEY ...] delete KEYs; '-' reads keys from stdin
8427
- clear STORE empty the store, keeping its header and markers
8595
+ clear STORE empty the store; a .tsvz keeps its header and
8596
+ markers, a .tsv the --x-header it is given
8428
8597
  scrub STORE compact the store in place (archival maintenance)
8429
8598
  verify STORE check #_checksum_*_# segments (exit 4 on a mismatch)
8430
8599
  parts STORE list the parts of a multi-part store
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes