TSVZ 4.9__tar.gz → 5.0__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,13 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TSVZ
3
- Version: 4.9
3
+ Version: 5.0
4
4
  Summary: Key-value store kept in a TSV / CSV / NSV / PSV file, with tsvz-spec-v1 support
5
- Home-page: https://github.com/yufei-pan/TSVZ
5
+ Home-page: https://github.com/tsvz-org/TSVZ
6
6
  Author: Yufei Pan
7
7
  Author-email: pan@zopyr.us
8
8
  License: GPLv3+
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Programming Language :: Python :: 3.6
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.14
11
14
  Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
12
15
  Classifier: Operating System :: OS Independent
13
16
  Requires-Python: >=3.6
@@ -36,7 +39,7 @@ key-value store in a delimiter-separated text file**. The first column of each
36
39
  row is the key; the file behaves like an ordered dictionary that is persisted to
37
40
  disk as you change it.
38
41
 
39
- **Version 4.9**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
42
+ **Version 5.0**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
40
43
  next to your code and `import TSVZ`, or install it.
41
44
 
42
45
  ```bash
@@ -48,7 +51,7 @@ pip install -e . # editable, from this directory
48
51
 
49
52
  | Extension | Rules |
50
53
  |---|---|
51
- | `.tsv` `.csv` `.nsv` `.psv` (and any other) | The TSVZ **3.39** format, unchanged. A 3.39 host can read every file 4.x writes. |
54
+ | `.tsv` `.csv` `.nsv` `.psv` (and any other) | The TSVZ **3.39** format, unchanged. A 3.39 host can read every file this version writes. |
52
55
  | `.tsvz` `.csvz` `.nsvz` `.psvz` | The **[tsvz-spec-v1](tsvz-spec-v1.md)** append-only key-value log. |
53
56
 
54
57
  The delimiter follows the extension: tab, comma, NUL, pipe. A trailing
@@ -94,7 +97,7 @@ tsvz people.tsvz append carol Carol 5 # the 3.39 form still works
94
97
  tsvz -V
95
98
  ```
96
99
 
97
- ## What TSVZ 4 does with `.tsvz` files
100
+ ## What TSVZ does with `.tsvz` files
98
101
 
99
102
  - **Reads** everything a conformant writer can produce: markers
100
103
  (`#_defaults_#`, `#_strip_trailing_whites_#`, `#_fill_empty_with_default_#`,
@@ -121,7 +124,12 @@ tsvz -V
121
124
  `setdefault` and `pop` behave like a dict.
122
125
  - **`#` keys** are stored (`<#>key`). Keys of the form `#_name_#` are marker
123
126
  writes: `t['#_defaults_#'] = [...]` sets the defaults, and so does
124
- `setDefaults([...])` once the object is constructed.
127
+ `setDefaults([...])` once the object is constructed. `#_snapshot_#` is
128
+ refused (spec §19.10.2).
129
+ - **Other writers:** a `TSVZed` notices another process's change by the active
130
+ part's inode, size and times and by the list of parts, and reloads. Rows it
131
+ has queued but not yet written stay in memory across that reload, and a live
132
+ key is never missing while it runs.
125
133
 
126
134
  ## Command line
127
135
 
@@ -130,6 +138,9 @@ tsvz -V
130
138
  lone `--`. The 3.39 form `tsvz STORE [OPERATION] [ARG ...]` works too; a store
131
139
  named like an operation then needs a path prefix (`./read`).
132
140
 
141
+ - **One engine.** Every operation runs through the engine `tsvz serve` uses,
142
+ in its own process when no server runs, so a command prints the same with or
143
+ without a server.
133
144
  - **stdout carries only rows.** It gets a table on a terminal and records
134
145
  otherwise; `--format table|records` overrides. Records use the store's
135
146
  delimiter and escaping, so `tsvz read A | tsvz set B -` copies a store of
@@ -198,7 +209,8 @@ with TSVZ.TSVZClient('people.tsvz') as people: # served, or in this process whe
198
209
  `setDefaults`. Its writes return once they are written (raising `OSError` if they
199
210
  cannot be), one client may be shared between threads, and without a server it
200
211
  warns once and works on the files in-process, finishing its writes when the
201
- program exits.
212
+ program exits. A server of a `.tsv`-family file names its delimiter in
213
+ `STORE.serve`, and `TSVZClient` splits string values on it.
202
214
 
203
215
  ## Fault tolerance
204
216
 
@@ -219,9 +231,116 @@ recovered, skipped or stops the read, as the store's `#_on_corruption_#` says,
219
231
  part keeps the original as `<part>.damaged-<time>`), an unreadable part, and a
220
232
  delimiter or encoding argument that conflicts with the extension.
221
233
 
234
+ Writing is stricter than reading: a key, value, header or marker that cannot be
235
+ encoded as UTF-8, such as the lone surrogate `os.fsdecode` gives for a
236
+ non-UTF-8 file name, raises a `ValueError` (`TSVZEncodeError`) that names it
237
+ and writes nothing, instead of storing `?`. Pass `errors='replace'` (or any codec
238
+ error handler) to the writing functions or to `TSVZed`, `TSVZedLite` and `TSVZClient`
239
+ to get the old `?`. `TSVZed` and `TSVZedLite` refuse such a row when it is set. A
240
+ rewrite that meets such text leaves the file as it is: `mapToFile()` and
241
+ `hardMapToFile()` raise the error, and the rewrites `TSVZed` makes itself report it
242
+ once. With a non-UTF-8 `encoding=`, U+FFFD (what the reader puts for a byte it could
243
+ not decode) is text that encoding may lack, and it is refused like any other: such a
244
+ store is not rewritten (`rewrite_on_load` leaves the file as it is, without failing),
245
+ appends of rows that can be written still work, and the delete or update of a key that
246
+ holds U+FFFD raises. Such a key stays until the file is edited, the
247
+ store cleared, or the store opened with the encoding it was written in; `errors='replace'`
248
+ would write `?` for U+FFFD, losing the bytes the file holds there. The `tsvz` command
249
+ exits 1 with the message.
250
+
251
+ A `TSVZed` writes a `.tsv`, `.csv`, `.nsv` or `.psv` file (compressed or not) from
252
+ its background thread only: `rewrite()`, `mapToFile()`, `hardMapToFile()`,
253
+ `commitAppendToFile()`, `clear()`, `clear_file()`, `reload()`, `close()` and the
254
+ constructor's load hand it the job and wait, so a Ctrl-C stops the wait, never the
255
+ write (the call still raises `KeyboardInterrupt`). At exit a write in progress is
256
+ finished: a Ctrl-C then says so and keeps waiting, and a second one abandons it. A
257
+ closed store is freed. (`get_file_obj()` and `release_file_obj()`, kept from 3.39,
258
+ hand the locked file to the calling thread.) A file another process removed is made
259
+ again from memory by the store's next write, an append included (a removal in the
260
+ microseconds as a write starts is followed as a rotation instead); while that rewrite
261
+ fails or is refused, the rows are appended to a new file there, and memory keeps all of
262
+ them for the rewrite that gets through. A compressed file another writer left damaged
263
+ is made again too (L5: every read stops at the damage, so rows
264
+ appended after it would be read by no one); the bytes after the damage go. While that
265
+ rewrite fails, the rows are appended after the damage and memory keeps them for the
266
+ next rewrite that gets through; while it is refused (a row changed in place to text
267
+ the encoding cannot hold), they are appended after it as in 4.9, and the next read
268
+ drops them. Either way `close()` tries the rewrite once more, whatever
269
+ `rewrite_on_exit` says. Appends that do not read the store first (`tsvz set`,
270
+ `appendTabularFile`, `TSVZedLite`, and a `TSVZed` with `monitor_external_changes=False`
271
+ for damage that lands after it loaded the file) still land after it, and so do a
272
+ `TSVZed`'s rows when a writer that takes no lock leaves the damage between the store's read
273
+ and its append ([docs/limitations.md](docs/limitations.md)).
274
+
275
+ It knows the size and SHA-256 of the bytes it last read or wrote. When another process
276
+ has changed the file, and it is the same file (device and inode) and still begins with
277
+ those bytes, nothing this store set is still waiting to be written, and
278
+ `setDefaults()` has not changed its defaults since, only what follows is read and
279
+ applied to memory, as a read of the file applies it: memory keeps an order set by
280
+ `move_to_end`, and its `#` keys. Any other change reloads the file, changing memory
281
+ in place, so a live key is never missing meanwhile.
282
+
283
+ A rewrite writes over the file and then cuts it to its new length, so a reader never
284
+ meets it emptied. One that fails part way (a full disk, an I/O error), or that cannot
285
+ make again a file that was removed, leaves the file damaged: the new bytes it wrote,
286
+ then the old ones after them. Memory then holds the only good copy, as it does when
287
+ the store appends after damage another writer left that it cannot make the file again
288
+ over yet (that rewrite failed, or waits out a failed one's back-off). Until a rewrite gets through, the file is not
289
+ reloaded over memory; the background writer still appends the rows set meanwhile, and
290
+ the next rewrite (the background writer's, `rewrite()`, `close()`) makes the file
291
+ again from memory. A change another process
292
+ made meanwhile is merged into memory first, as above, when the file is still the one
293
+ the damage was left in (for a file that was removed, any file made there since) and
294
+ still begins with exactly the bytes it left; after a damage that left the file empty
295
+ (or removed it), what follows must also begin with this store's header.
296
+ Otherwise that change is overwritten, with the warning `Overwriting external changes`.
297
+ What cannot be told from an append is merged as one: another process's rewrite of the
298
+ same file in place that begins with exactly the bytes the damage left, and, for a store
299
+ with no header (or `verifyHeader=False`), anything written to a file the damage left
300
+ empty; the keys it sets or deletes then take its values, and the keys it left out keep
301
+ memory's. An explicit `reload()` while the file is damaged replaces memory with the
302
+ damaged file, as asked, and warns.
303
+
304
+ ## Upgrading to 5.0 from 4.9
305
+
306
+ - **Writes refuse text they cannot encode.** A key, value, header, defaults line or
307
+ marker the file's encoding cannot hold (UTF-8 for `.tsvz`; `encoding=` for a
308
+ `.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 `?`.
310
+ `errors='replace'` brings `?` back. Under a non-UTF-8 `encoding=`, a key read as
311
+ U+FFFD cannot be deleted or updated. `tsvz` exits 1 with the message. See Fault
312
+ tolerance.
313
+ - **A Ctrl-C no longer cuts a `.tsv` write.** Every write of a `.tsv`-family file
314
+ runs on the store's background thread; a call waits for it, a Ctrl-C raises in the
315
+ caller and the write still finishes, and at exit a write in progress is finished
316
+ (a second Ctrl-C abandons it). In 4.9 a Ctrl-C while `TSVZed()` opened a
317
+ compressed store (its rewrite on load) could leave the file with no readable row.
318
+ A closed `TSVZed` or `TSVZedLite` is freed; 4.9 kept every one until exit.
319
+ - **Another process's change is taken in, not reloaded over memory.** An append to
320
+ a `.tsv`-family file is read from where the store left off, so an order set by
321
+ `move_to_end` and `#` keys survive it; any reload (both dialects) changes memory
322
+ in place, so another thread never finds a live key missing. After a rewrite of
323
+ the store's own fails part way, memory is kept and another process's appends are
324
+ merged into it (Fault tolerance).
325
+ - **`.tsvz` stores** see another process's append made right after their load, a
326
+ 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.
329
+ - **`.tsv` files:** an append to an existing empty file writes the header first, and
330
+ reading one gives an empty store; see L8 to L14 for the rest.
331
+ - **`tsvz`** runs every operation through the engine `tsvz serve` uses, with the same
332
+ output. A server of a `.tsv` (or other tab- or NUL-delimited) file is now found by
333
+ `tsvz` commands, `TSVZClient` and `tsvz stop`; 4.9's clients rejected its pointer
334
+ file. The 3.39 warning that a file name does not end in `.tsv` is printed only
335
+ when a command creates such a file, and a write that cannot be made reports the
336
+ 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
339
+ ([docs/limitations.md](docs/limitations.md)).
340
+
222
341
  ## Breaking changes from 3.39
223
342
 
224
- ### `.tsv`, `.csv`, `.nsv`, `.psv`: bug fixes only
343
+ ### `.tsv`, `.csv`, `.nsv`, `.psv`: the format is unchanged
225
344
 
226
345
  | # | Change |
227
346
  |---|---|
@@ -232,6 +351,13 @@ delimiter or encoding argument that conflicts with the extension.
232
351
  | L5 | A damaged compressed file is read up to the damage instead of raising. |
233
352
  | L6 | `mapToFile` no longer glues a row it adds at the end of the file to the next one. |
234
353
  | L7 | `TSVZed`'s `in` and `get` find a key as `t[key]` does: the live key equal to it, else the key right-stripped. 3.39 matched them exactly, so `'x ' in t` was false while `t['x ']` read `x`, and `t.setdefault('x ', …)` overwrote `x`. |
354
+ | L8 | Appending (`TSVZed`, `TSVZedLite`, `appendTabularFile`, `appendLinesTabularFile`) to an existing empty file (no byte, or a compressed file holding only empty streams) writes the store's header first, as creating the file does; 3.39 wrote the rows alone (a read then took the first one for the header), and `appendTabularFile` with `strict` refused the file. |
355
+ | L9 | Reading such an empty file with a header (and `verifyHeader`) gives an empty store with that header: `readTabularFile`, `TSVZed` and `TSVZedLite` with `strict` no longer refuse it with `Header mismatch` (a `TSVZed` restarted after a rewrite that left the file empty failed in its constructor), and without `strict` no longer print a header mismatch. |
356
+ | L10 | Writing text the file's encoding cannot hold (a lone surrogate from `os.fsdecode`, a character the `encoding=` lacks) raises `TSVZEncodeError`, a `ValueError`, and writes nothing; 3.39 and 4.9 wrote `?`, which `errors='replace'` brings back. |
357
+ | L11 | `pop()` and `popitem()` of a `#` key, which lives in memory only, write no tombstone line to the file. |
358
+ | 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
+ | 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
+ | 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. |
235
361
 
236
362
  ### `.tsvz`, `.csvz`, `.nsvz`, `.psvz`: now tsvz-spec-v1
237
363
 
@@ -281,7 +407,7 @@ Conformance testing).
281
407
 
282
408
  - §17.5: a numbered part opened directly is read alone, with a warning.
283
409
  - §14.5: the defaults-on-missing read applies to `t[key]` only.
284
- - §18.6: locking is one exclusive whole-file lock per write plus an in-process lock; readers do not lock. On Linux it is an open file description lock, which still excludes 3.39's `lockf`; elsewhere (macOS and other systems, or a Linux file system that refuses those locks) it is `lockf`, which closing any descriptor of the file in the process drops: there a read in another thread of the same process, such as a handler's or a `TSVZed`'s, can drop a write's lock while it is held. A known limitation, to be fixed separately.
410
+ - §18.6: locking is one exclusive whole-file lock per write plus an in-process lock; readers do not lock. On Linux it is an open file description lock, which still excludes 3.39's `lockf`; elsewhere (macOS and other systems, or a Linux file system that refuses those locks) it is `lockf`, which closing any descriptor of the file in the process drops: there a read in another thread of the same process, such as a handler's or a `TSVZed`'s, can drop a write's lock while it is held. A known limitation, deferred: see [docs/limitations.md](docs/limitations.md).
285
411
  - §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.
286
412
  - §20.7: `-q` also silences informational messages ("Created …"); only errors remain.
287
413
  - §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.
@@ -296,16 +422,12 @@ Conformance testing).
296
422
 
297
423
  ## Website
298
424
 
299
- `website/` holds the tsvz.org site. `website/tsvz_site.py` (standard library
300
- only, Python 3.8+) renders `website/index.md`, `website/spec-glance.md` and
301
- `tsvz-spec-v1.md`, and serves HTML to browsers and markdown to agents.
302
-
303
- ```bash
304
- python3 website/tsvz_site.py serve -p 8765 # http://127.0.0.1:8765/
305
- python3 website/tsvz_site.py build /tmp/site # static files for any host
306
- ```
307
-
308
- `deploy/tsvz-site.service` runs it under systemd; restart it to publish an edit.
425
+ The tsvz.org site lives in [tsvz-org/tsvz.org](https://github.com/tsvz-org/tsvz.org).
426
+ It renders `tsvz-spec-v1.md` from a checkout of this repository next to its own,
427
+ and its tests also check the spec: every § reference names a real section, and
428
+ the spec uses only markdown the site renders. Run them after editing the spec
429
+ (see Tests); CI runs them on every push here, and the site publishes `main`'s
430
+ spec each morning.
309
431
 
310
432
  ## Conformance testing
311
433
 
@@ -365,14 +487,16 @@ runs, because a single run can land in a slower mode as a whole.
365
487
 
366
488
  ```bash
367
489
  python3 -m pytest TSVZ_test.py -q # everything, incl. 3.39 differential tests
368
- python3 -m pytest website/tsvz_site_test.py -q # the tsvz.org site
490
+ python3 -m pytest ../tsvz.org/tsvz_site_test.py -q # the spec as tsvz.org renders it (needs the tsvz.org checkout next to this one)
369
491
  python3 -m pytest tsvzbench_test.py -q # the benchmark harness
370
492
  python3 -m pytest tsvzconform_test.py -q # the conformance runner and cases, against the reference
371
493
  python3 benchTSVZ.py /tmp/b.tsv -n 100000 --compare-old # benchmark (not a test)
372
494
  ```
373
495
 
374
496
  `TSVZ_old.py` is the frozen 3.39 module the differential tests compare against;
375
- it is not installed.
497
+ it is not installed. CI (`.github/workflows/test.yml`) runs the three test files on
498
+ Python 3.6, 3.9, 3.12 and 3.14. Accepted limitations are listed in
499
+ [docs/limitations.md](docs/limitations.md).
376
500
 
377
501
  ## License
378
502
 
@@ -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.9**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
10
+ **Version 5.0**, 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
@@ -19,7 +19,7 @@ pip install -e . # editable, from this directory
19
19
 
20
20
  | Extension | Rules |
21
21
  |---|---|
22
- | `.tsv` `.csv` `.nsv` `.psv` (and any other) | The TSVZ **3.39** format, unchanged. A 3.39 host can read every file 4.x writes. |
22
+ | `.tsv` `.csv` `.nsv` `.psv` (and any other) | The TSVZ **3.39** format, unchanged. A 3.39 host can read every file this version writes. |
23
23
  | `.tsvz` `.csvz` `.nsvz` `.psvz` | The **[tsvz-spec-v1](tsvz-spec-v1.md)** append-only key-value log. |
24
24
 
25
25
  The delimiter follows the extension: tab, comma, NUL, pipe. A trailing
@@ -65,7 +65,7 @@ tsvz people.tsvz append carol Carol 5 # the 3.39 form still works
65
65
  tsvz -V
66
66
  ```
67
67
 
68
- ## What TSVZ 4 does with `.tsvz` files
68
+ ## What TSVZ does with `.tsvz` files
69
69
 
70
70
  - **Reads** everything a conformant writer can produce: markers
71
71
  (`#_defaults_#`, `#_strip_trailing_whites_#`, `#_fill_empty_with_default_#`,
@@ -92,7 +92,12 @@ tsvz -V
92
92
  `setdefault` and `pop` behave like a dict.
93
93
  - **`#` keys** are stored (`<#>key`). Keys of the form `#_name_#` are marker
94
94
  writes: `t['#_defaults_#'] = [...]` sets the defaults, and so does
95
- `setDefaults([...])` once the object is constructed.
95
+ `setDefaults([...])` once the object is constructed. `#_snapshot_#` is
96
+ refused (spec §19.10.2).
97
+ - **Other writers:** a `TSVZed` notices another process's change by the active
98
+ part's inode, size and times and by the list of parts, and reloads. Rows it
99
+ has queued but not yet written stay in memory across that reload, and a live
100
+ key is never missing while it runs.
96
101
 
97
102
  ## Command line
98
103
 
@@ -101,6 +106,9 @@ tsvz -V
101
106
  lone `--`. The 3.39 form `tsvz STORE [OPERATION] [ARG ...]` works too; a store
102
107
  named like an operation then needs a path prefix (`./read`).
103
108
 
109
+ - **One engine.** Every operation runs through the engine `tsvz serve` uses,
110
+ in its own process when no server runs, so a command prints the same with or
111
+ without a server.
104
112
  - **stdout carries only rows.** It gets a table on a terminal and records
105
113
  otherwise; `--format table|records` overrides. Records use the store's
106
114
  delimiter and escaping, so `tsvz read A | tsvz set B -` copies a store of
@@ -169,7 +177,8 @@ with TSVZ.TSVZClient('people.tsvz') as people: # served, or in this process whe
169
177
  `setDefaults`. Its writes return once they are written (raising `OSError` if they
170
178
  cannot be), one client may be shared between threads, and without a server it
171
179
  warns once and works on the files in-process, finishing its writes when the
172
- program exits.
180
+ program exits. A server of a `.tsv`-family file names its delimiter in
181
+ `STORE.serve`, and `TSVZClient` splits string values on it.
173
182
 
174
183
  ## Fault tolerance
175
184
 
@@ -190,9 +199,116 @@ recovered, skipped or stops the read, as the store's `#_on_corruption_#` says,
190
199
  part keeps the original as `<part>.damaged-<time>`), an unreadable part, and a
191
200
  delimiter or encoding argument that conflicts with the extension.
192
201
 
202
+ Writing is stricter than reading: a key, value, header or marker that cannot be
203
+ encoded as UTF-8, such as the lone surrogate `os.fsdecode` gives for a
204
+ non-UTF-8 file name, raises a `ValueError` (`TSVZEncodeError`) that names it
205
+ and writes nothing, instead of storing `?`. Pass `errors='replace'` (or any codec
206
+ error handler) to the writing functions or to `TSVZed`, `TSVZedLite` and `TSVZClient`
207
+ to get the old `?`. `TSVZed` and `TSVZedLite` refuse such a row when it is set. A
208
+ rewrite that meets such text leaves the file as it is: `mapToFile()` and
209
+ `hardMapToFile()` raise the error, and the rewrites `TSVZed` makes itself report it
210
+ once. With a non-UTF-8 `encoding=`, U+FFFD (what the reader puts for a byte it could
211
+ not decode) is text that encoding may lack, and it is refused like any other: such a
212
+ store is not rewritten (`rewrite_on_load` leaves the file as it is, without failing),
213
+ appends of rows that can be written still work, and the delete or update of a key that
214
+ holds U+FFFD raises. Such a key stays until the file is edited, the
215
+ store cleared, or the store opened with the encoding it was written in; `errors='replace'`
216
+ would write `?` for U+FFFD, losing the bytes the file holds there. The `tsvz` command
217
+ exits 1 with the message.
218
+
219
+ A `TSVZed` writes a `.tsv`, `.csv`, `.nsv` or `.psv` file (compressed or not) from
220
+ its background thread only: `rewrite()`, `mapToFile()`, `hardMapToFile()`,
221
+ `commitAppendToFile()`, `clear()`, `clear_file()`, `reload()`, `close()` and the
222
+ constructor's load hand it the job and wait, so a Ctrl-C stops the wait, never the
223
+ write (the call still raises `KeyboardInterrupt`). At exit a write in progress is
224
+ finished: a Ctrl-C then says so and keeps waiting, and a second one abandons it. A
225
+ closed store is freed. (`get_file_obj()` and `release_file_obj()`, kept from 3.39,
226
+ hand the locked file to the calling thread.) A file another process removed is made
227
+ again from memory by the store's next write, an append included (a removal in the
228
+ microseconds as a write starts is followed as a rotation instead); while that rewrite
229
+ fails or is refused, the rows are appended to a new file there, and memory keeps all of
230
+ them for the rewrite that gets through. A compressed file another writer left damaged
231
+ is made again too (L5: every read stops at the damage, so rows
232
+ appended after it would be read by no one); the bytes after the damage go. While that
233
+ rewrite fails, the rows are appended after the damage and memory keeps them for the
234
+ next rewrite that gets through; while it is refused (a row changed in place to text
235
+ the encoding cannot hold), they are appended after it as in 4.9, and the next read
236
+ drops them. Either way `close()` tries the rewrite once more, whatever
237
+ `rewrite_on_exit` says. Appends that do not read the store first (`tsvz set`,
238
+ `appendTabularFile`, `TSVZedLite`, and a `TSVZed` with `monitor_external_changes=False`
239
+ for damage that lands after it loaded the file) still land after it, and so do a
240
+ `TSVZed`'s rows when a writer that takes no lock leaves the damage between the store's read
241
+ and its append ([docs/limitations.md](docs/limitations.md)).
242
+
243
+ It knows the size and SHA-256 of the bytes it last read or wrote. When another process
244
+ has changed the file, and it is the same file (device and inode) and still begins with
245
+ those bytes, nothing this store set is still waiting to be written, and
246
+ `setDefaults()` has not changed its defaults since, only what follows is read and
247
+ applied to memory, as a read of the file applies it: memory keeps an order set by
248
+ `move_to_end`, and its `#` keys. Any other change reloads the file, changing memory
249
+ in place, so a live key is never missing meanwhile.
250
+
251
+ A rewrite writes over the file and then cuts it to its new length, so a reader never
252
+ meets it emptied. One that fails part way (a full disk, an I/O error), or that cannot
253
+ make again a file that was removed, leaves the file damaged: the new bytes it wrote,
254
+ then the old ones after them. Memory then holds the only good copy, as it does when
255
+ the store appends after damage another writer left that it cannot make the file again
256
+ over yet (that rewrite failed, or waits out a failed one's back-off). Until a rewrite gets through, the file is not
257
+ reloaded over memory; the background writer still appends the rows set meanwhile, and
258
+ the next rewrite (the background writer's, `rewrite()`, `close()`) makes the file
259
+ again from memory. A change another process
260
+ made meanwhile is merged into memory first, as above, when the file is still the one
261
+ the damage was left in (for a file that was removed, any file made there since) and
262
+ still begins with exactly the bytes it left; after a damage that left the file empty
263
+ (or removed it), what follows must also begin with this store's header.
264
+ Otherwise that change is overwritten, with the warning `Overwriting external changes`.
265
+ What cannot be told from an append is merged as one: another process's rewrite of the
266
+ same file in place that begins with exactly the bytes the damage left, and, for a store
267
+ with no header (or `verifyHeader=False`), anything written to a file the damage left
268
+ empty; the keys it sets or deletes then take its values, and the keys it left out keep
269
+ memory's. An explicit `reload()` while the file is damaged replaces memory with the
270
+ damaged file, as asked, and warns.
271
+
272
+ ## Upgrading to 5.0 from 4.9
273
+
274
+ - **Writes refuse text they cannot encode.** A key, value, header, defaults line or
275
+ marker the file's encoding cannot hold (UTF-8 for `.tsvz`; `encoding=` for a
276
+ `.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 `?`.
278
+ `errors='replace'` brings `?` back. Under a non-UTF-8 `encoding=`, a key read as
279
+ U+FFFD cannot be deleted or updated. `tsvz` exits 1 with the message. See Fault
280
+ tolerance.
281
+ - **A Ctrl-C no longer cuts a `.tsv` write.** Every write of a `.tsv`-family file
282
+ runs on the store's background thread; a call waits for it, a Ctrl-C raises in the
283
+ caller and the write still finishes, and at exit a write in progress is finished
284
+ (a second Ctrl-C abandons it). In 4.9 a Ctrl-C while `TSVZed()` opened a
285
+ compressed store (its rewrite on load) could leave the file with no readable row.
286
+ A closed `TSVZed` or `TSVZedLite` is freed; 4.9 kept every one until exit.
287
+ - **Another process's change is taken in, not reloaded over memory.** An append to
288
+ a `.tsv`-family file is read from where the store left off, so an order set by
289
+ `move_to_end` and `#` keys survive it; any reload (both dialects) changes memory
290
+ in place, so another thread never finds a live key missing. After a rewrite of
291
+ the store's own fails part way, memory is kept and another process's appends are
292
+ merged into it (Fault tolerance).
293
+ - **`.tsvz` stores** see another process's append made right after their load, a
294
+ 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.
297
+ - **`.tsv` files:** an append to an existing empty file writes the header first, and
298
+ reading one gives an empty store; see L8 to L14 for the rest.
299
+ - **`tsvz`** runs every operation through the engine `tsvz serve` uses, with the same
300
+ output. A server of a `.tsv` (or other tab- or NUL-delimited) file is now found by
301
+ `tsvz` commands, `TSVZClient` and `tsvz stop`; 4.9's clients rejected its pointer
302
+ file. The 3.39 warning that a file name does not end in `.tsv` is printed only
303
+ when a command creates such a file, and a write that cannot be made reports the
304
+ 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
307
+ ([docs/limitations.md](docs/limitations.md)).
308
+
193
309
  ## Breaking changes from 3.39
194
310
 
195
- ### `.tsv`, `.csv`, `.nsv`, `.psv`: bug fixes only
311
+ ### `.tsv`, `.csv`, `.nsv`, `.psv`: the format is unchanged
196
312
 
197
313
  | # | Change |
198
314
  |---|---|
@@ -203,6 +319,13 @@ delimiter or encoding argument that conflicts with the extension.
203
319
  | L5 | A damaged compressed file is read up to the damage instead of raising. |
204
320
  | L6 | `mapToFile` no longer glues a row it adds at the end of the file to the next one. |
205
321
  | L7 | `TSVZed`'s `in` and `get` find a key as `t[key]` does: the live key equal to it, else the key right-stripped. 3.39 matched them exactly, so `'x ' in t` was false while `t['x ']` read `x`, and `t.setdefault('x ', …)` overwrote `x`. |
322
+ | L8 | Appending (`TSVZed`, `TSVZedLite`, `appendTabularFile`, `appendLinesTabularFile`) to an existing empty file (no byte, or a compressed file holding only empty streams) writes the store's header first, as creating the file does; 3.39 wrote the rows alone (a read then took the first one for the header), and `appendTabularFile` with `strict` refused the file. |
323
+ | L9 | Reading such an empty file with a header (and `verifyHeader`) gives an empty store with that header: `readTabularFile`, `TSVZed` and `TSVZedLite` with `strict` no longer refuse it with `Header mismatch` (a `TSVZed` restarted after a rewrite that left the file empty failed in its constructor), and without `strict` no longer print a header mismatch. |
324
+ | L10 | Writing text the file's encoding cannot hold (a lone surrogate from `os.fsdecode`, a character the `encoding=` lacks) raises `TSVZEncodeError`, a `ValueError`, and writes nothing; 3.39 and 4.9 wrote `?`, which `errors='replace'` brings back. |
325
+ | L11 | `pop()` and `popitem()` of a `#` key, which lives in memory only, write no tombstone line to the file. |
326
+ | 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
+ | 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
+ | 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. |
206
329
 
207
330
  ### `.tsvz`, `.csvz`, `.nsvz`, `.psvz`: now tsvz-spec-v1
208
331
 
@@ -252,7 +375,7 @@ Conformance testing).
252
375
 
253
376
  - §17.5: a numbered part opened directly is read alone, with a warning.
254
377
  - §14.5: the defaults-on-missing read applies to `t[key]` only.
255
- - §18.6: locking is one exclusive whole-file lock per write plus an in-process lock; readers do not lock. On Linux it is an open file description lock, which still excludes 3.39's `lockf`; elsewhere (macOS and other systems, or a Linux file system that refuses those locks) it is `lockf`, which closing any descriptor of the file in the process drops: there a read in another thread of the same process, such as a handler's or a `TSVZed`'s, can drop a write's lock while it is held. A known limitation, to be fixed separately.
378
+ - §18.6: locking is one exclusive whole-file lock per write plus an in-process lock; readers do not lock. On Linux it is an open file description lock, which still excludes 3.39's `lockf`; elsewhere (macOS and other systems, or a Linux file system that refuses those locks) it is `lockf`, which closing any descriptor of the file in the process drops: there a read in another thread of the same process, such as a handler's or a `TSVZed`'s, can drop a write's lock while it is held. A known limitation, deferred: see [docs/limitations.md](docs/limitations.md).
256
379
  - §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.
257
380
  - §20.7: `-q` also silences informational messages ("Created …"); only errors remain.
258
381
  - §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.
@@ -267,16 +390,12 @@ Conformance testing).
267
390
 
268
391
  ## Website
269
392
 
270
- `website/` holds the tsvz.org site. `website/tsvz_site.py` (standard library
271
- only, Python 3.8+) renders `website/index.md`, `website/spec-glance.md` and
272
- `tsvz-spec-v1.md`, and serves HTML to browsers and markdown to agents.
273
-
274
- ```bash
275
- python3 website/tsvz_site.py serve -p 8765 # http://127.0.0.1:8765/
276
- python3 website/tsvz_site.py build /tmp/site # static files for any host
277
- ```
278
-
279
- `deploy/tsvz-site.service` runs it under systemd; restart it to publish an edit.
393
+ The tsvz.org site lives in [tsvz-org/tsvz.org](https://github.com/tsvz-org/tsvz.org).
394
+ It renders `tsvz-spec-v1.md` from a checkout of this repository next to its own,
395
+ and its tests also check the spec: every § reference names a real section, and
396
+ the spec uses only markdown the site renders. Run them after editing the spec
397
+ (see Tests); CI runs them on every push here, and the site publishes `main`'s
398
+ spec each morning.
280
399
 
281
400
  ## Conformance testing
282
401
 
@@ -336,14 +455,16 @@ runs, because a single run can land in a slower mode as a whole.
336
455
 
337
456
  ```bash
338
457
  python3 -m pytest TSVZ_test.py -q # everything, incl. 3.39 differential tests
339
- python3 -m pytest website/tsvz_site_test.py -q # the tsvz.org site
458
+ python3 -m pytest ../tsvz.org/tsvz_site_test.py -q # the spec as tsvz.org renders it (needs the tsvz.org checkout next to this one)
340
459
  python3 -m pytest tsvzbench_test.py -q # the benchmark harness
341
460
  python3 -m pytest tsvzconform_test.py -q # the conformance runner and cases, against the reference
342
461
  python3 benchTSVZ.py /tmp/b.tsv -n 100000 --compare-old # benchmark (not a test)
343
462
  ```
344
463
 
345
464
  `TSVZ_old.py` is the frozen 3.39 module the differential tests compare against;
346
- it is not installed.
465
+ it is not installed. CI (`.github/workflows/test.yml`) runs the three test files on
466
+ Python 3.6, 3.9, 3.12 and 3.14. Accepted limitations are listed in
467
+ [docs/limitations.md](docs/limitations.md).
347
468
 
348
469
  ## License
349
470