TSVZ 4.4__tar.gz → 4.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TSVZ
3
- Version: 4.4
3
+ Version: 4.6
4
4
  Summary: Key-value store kept in a TSV / CSV / NSV / PSV file, with tsvz-spec-v1 support
5
5
  Home-page: https://github.com/yufei-pan/TSVZ
6
6
  Author: Yufei Pan
@@ -34,7 +34,7 @@ key-value store in a delimiter-separated text file**. The first column of each
34
34
  row is the key; the file behaves like an ordered dictionary that is persisted to
35
35
  disk as you change it.
36
36
 
37
- **Version 4.4**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
37
+ **Version 4.6**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
38
38
  next to your code and `import TSVZ`, or install it.
39
39
 
40
40
  ```bash
@@ -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.4**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
10
+ **Version 4.6**, 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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TSVZ
3
- Version: 4.4
3
+ Version: 4.6
4
4
  Summary: Key-value store kept in a TSV / CSV / NSV / PSV file, with tsvz-spec-v1 support
5
5
  Home-page: https://github.com/yufei-pan/TSVZ
6
6
  Author: Yufei Pan
@@ -34,7 +34,7 @@ key-value store in a delimiter-separated text file**. The first column of each
34
34
  row is the key; the file behaves like an ordered dictionary that is persisted to
35
35
  disk as you change it.
36
36
 
37
- **Version 4.4**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
37
+ **Version 4.6**, Python **3.6+**, one self-contained module (`TSVZ.py`): drop it
38
38
  next to your code and `import TSVZ`, or install it.
39
39
 
40
40
  ```bash
@@ -40,7 +40,7 @@ if os.name == 'nt':
40
40
  elif os.name == 'posix':
41
41
  import fcntl
42
42
 
43
- version = '4.4'
43
+ version = '4.6'
44
44
  __version__ = version
45
45
  author = 'pan@zopyr.us'
46
46
  COMMIT_DATE = '2026-10-12'
@@ -179,13 +179,18 @@ def get_terminal_size():
179
179
  try:
180
180
  import os
181
181
  _tsize = os.get_terminal_size()
182
+ if not (_tsize[0] and _tsize[1]):
183
+ raise OSError('the terminal has no size set')
182
184
  except Exception:
183
185
  try:
184
186
  import fcntl
185
187
  import struct
186
188
  import termios
187
189
  packed = fcntl.ioctl(0, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))
188
- _tsize = struct.unpack('HHHH', packed)[:2]
190
+ rows, columns = struct.unpack('HHHH', packed)[:2]
191
+ if not (rows and columns):
192
+ raise OSError('the terminal has no size set')
193
+ _tsize = (columns, rows)
189
194
  except Exception:
190
195
  import shutil
191
196
  _tsize = shutil.get_terminal_size(fallback=(240, 50))
@@ -1654,6 +1659,34 @@ def _specFormatRecord(cells, delimiter, marker=False):
1654
1659
  return delimiter.join([first] + [_specEncodeField(cell, delimiter) for cell in cells[1:]])
1655
1660
 
1656
1661
 
1662
+ def _specTombstones(keys, delimiter, strip):
1663
+ """``(cells, marker)`` records that delete ``keys`` (spec §9.2) from a store whose stripping is ``strip``.
1664
+
1665
+ While stripping is on a reader strips a tombstone's key too (spec §9.1): the
1666
+ tombstone of a key whose encoded form ends in a space or tab, stored while
1667
+ stripping was off, would delete the stripped key instead. A batch holding
1668
+ one is written with stripping off, and stripping is turned back on after it
1669
+ (spec §9.6).
1670
+ """
1671
+ rows = [([key], False) for key in keys]
1672
+ if strip and any(key.endswith((' ', '\t')) and _specEncodeField(key, delimiter, isKey=True).endswith((' ', '\t'))
1673
+ for key in keys):
1674
+ rows.insert(0, (['#_strip_trailing_whites_#', 'false'], True))
1675
+ rows.append((['#_strip_trailing_whites_#', 'true'], True))
1676
+ return rows
1677
+
1678
+
1679
+ def _specFormatRecords(rows, delimiter):
1680
+ """``rows`` (``[(cells, marker), ...]``) as record lines, each with its '\\n'."""
1681
+ return ''.join(_specFormatRecord(cells, delimiter, marker=marker) + '\n' for cells, marker in rows)
1682
+
1683
+
1684
+ def _specTombstoneLines(key, delimiter, strip):
1685
+ """The tombstone of ``key`` (spec §9.6) as lines joined by '\\n', without a last '\\n'."""
1686
+ return '\n'.join(_specFormatRecord(cells, delimiter, marker=marker)
1687
+ for cells, marker in _specTombstones([key], delimiter, strip))
1688
+
1689
+
1657
1690
  class _SpecState(object):
1658
1691
  """Reader state of spec §12, carried across records and parts."""
1659
1692
 
@@ -2007,9 +2040,10 @@ def _gzipUnpad(region):
2007
2040
  try:
2008
2041
  inflater.decompress(stripped[start:])
2009
2042
  if not inflater.eof: # its last bytes may be zero bytes of its own
2010
- # Fed only zeros, compressed data can end only inside a final stored block (65535 bytes, after a
2011
- # header of at most 5); anywhere else it fails or never ends. So 65544 zeros give the whole answer.
2012
- zeros = min(zeros, 65544)
2043
+ # Fed only zeros, compressed data in a Huffman block ends within a few hundred bytes, fails or
2044
+ # never ends. In a stored block it can end only if the block is final, after the rest of its
2045
+ # NLEN (both bytes zero only when LEN is 65535) and of its data: at most 2 + 65535 zeros.
2046
+ zeros = min(zeros, 2 + 65535)
2013
2047
  fed = 0
2014
2048
  while fed < zeros: # in pieces, with bounded output
2015
2049
  piece = b'\0' * min(1 << 16, zeros - fed)
@@ -2603,16 +2637,23 @@ def _specBackupPart(path, reporter):
2603
2637
  _lockFile(f)
2604
2638
  try:
2605
2639
  first = backup = '{}.damaged-{}'.format(path, time.strftime('%Y%m%dT%H%M%S'))
2640
+ # Readable by whoever could read the part, writable by its owner only, never setuid,
2641
+ # setgid, sticky or executable (the umask still applies on top).
2642
+ st = os.fstat(f.fileno())
2643
+ mode = st.st_mode & 0o644
2606
2644
  suffix = 0
2607
2645
  while True:
2608
2646
  try:
2609
- copy = open(backup, 'xb') # never overwrite an earlier backup
2647
+ # never overwrite an earlier backup; O_BINARY: no newline translation on Windows
2648
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_BINARY', 0)
2649
+ copy = os.fdopen(os.open(backup, flags, mode), 'wb')
2610
2650
  break
2611
2651
  except FileExistsError:
2612
2652
  suffix += 1
2613
2653
  backup = '{}.{}'.format(first, suffix)
2614
2654
  try:
2615
2655
  with copy:
2656
+ _matchOwner(copy.fileno(), st)
2616
2657
  shutil.copyfileobj(f, copy)
2617
2658
  copy.flush()
2618
2659
  os.fsync(copy.fileno())
@@ -2671,6 +2712,22 @@ def _specSnapshot(fileName):
2671
2712
  return parts, None
2672
2713
 
2673
2714
 
2715
+ def _matchOwner(fd, st):
2716
+ """Give a new copy the owner and group of the file ``st`` describes. Where that is not allowed,
2717
+ a copy left in another group loses its group bits, so no one reads it who could not read the file."""
2718
+ if not hasattr(os, 'fchown'):
2719
+ return
2720
+ for uid in (st.st_uid, -1):
2721
+ try:
2722
+ os.fchown(fd, uid, st.st_gid)
2723
+ return
2724
+ except OSError:
2725
+ pass
2726
+ created = os.fstat(fd)
2727
+ if created.st_gid != st.st_gid:
2728
+ os.fchmod(fd, created.st_mode & 0o7707) # what the umask left, without the group bits
2729
+
2730
+
2674
2731
  def _specRewriteInPlace(path, content, expect=None, memberSize=None):
2675
2732
  """Replace a part's bytes in place (same inode, spec §18.7 / §19.8).
2676
2733
 
@@ -2690,18 +2747,73 @@ def _specRewriteInPlace(path, content, expect=None, memberSize=None):
2690
2747
  st = os.fstat(f.fileno())
2691
2748
  if expect is not None and _specStamp(st) != expect:
2692
2749
  return None
2693
- if st.st_size == len(raw) and f.read() == raw:
2750
+ fd = f.fileno()
2751
+ # The whole part, for a failed rewrite to put back whenever it fails. Read unbuffered, as
2752
+ # the file is written below: a read-ahead in f would leave it a stale position to close at.
2753
+ old = _readFrom0(fd, st.st_size)
2754
+ if old == raw:
2694
2755
  return False
2695
- f.seek(0)
2696
- f.write(raw)
2697
- f.truncate()
2698
- f.flush()
2699
- os.fsync(f.fileno())
2756
+ try:
2757
+ _reserveSpace(fd, len(raw)) # spec §19.8: a full disk refuses the rewrite before any byte changes
2758
+ _writeFrom0(fd, raw)
2759
+ except BaseException as e:
2760
+ # Put the old bytes back rather than leave a half-rewritten part (or one a failed
2761
+ # reservation lengthened).
2762
+ try:
2763
+ _writeFrom0(fd, old)
2764
+ os.fsync(fd)
2765
+ except OSError as undo:
2766
+ damaged = '{}: the rewrite failed ({!r}) and the old bytes could not be put back ({}): the part is damaged'.format(
2767
+ path, e, undo)
2768
+ if isinstance(e, OSError):
2769
+ raise OSError(e.errno, damaged)
2770
+ _warn('TSVZ warning: ' + damaged) # an interrupt stays an interrupt
2771
+ raise
2772
+ os.fsync(fd)
2700
2773
  return True
2701
2774
  finally:
2775
+ try:
2776
+ _seekRawStart(f) # msvcrt unlocks from the raw position: the range it locked
2777
+ except OSError:
2778
+ pass
2702
2779
  _unlockFile(f)
2703
2780
 
2704
2781
 
2782
+ def _reserveSpace(fd, length):
2783
+ """Reserve ``length`` bytes from the start of the file before it is rewritten in place (spec
2784
+ §19.8). Where the platform or the file system cannot reserve space, nothing is reserved."""
2785
+ fallocate = getattr(os, 'posix_fallocate', None)
2786
+ if fallocate is None or length <= 0:
2787
+ return
2788
+ try:
2789
+ fallocate(fd, 0, length)
2790
+ except OSError as e:
2791
+ if e.errno not in (errno.EOPNOTSUPP, getattr(errno, 'ENOTSUP', errno.EOPNOTSUPP), errno.ENOSYS, errno.EINVAL):
2792
+ raise
2793
+
2794
+
2795
+ def _readFrom0(fd, length):
2796
+ """Up to ``length`` bytes from the start of the file (unbuffered)."""
2797
+ os.lseek(fd, 0, os.SEEK_SET)
2798
+ chunks = []
2799
+ while length > 0:
2800
+ chunk = os.read(fd, length)
2801
+ if not chunk:
2802
+ break
2803
+ chunks.append(chunk)
2804
+ length -= len(chunk)
2805
+ return b''.join(chunks)
2806
+
2807
+
2808
+ def _writeFrom0(fd, data):
2809
+ """Write ``data`` over the file from offset 0 and cut the file there (unbuffered)."""
2810
+ os.lseek(fd, 0, os.SEEK_SET)
2811
+ view = memoryview(data)
2812
+ while view:
2813
+ view = view[os.write(fd, view):]
2814
+ os.ftruncate(fd, len(data))
2815
+
2816
+
2705
2817
  def _specPreamble(state, delimiter, compensate=False, version=True):
2706
2818
  """Marker lines that re-create ``state`` (spec §19.2.3a): version, non-default markers, defaults last.
2707
2819
 
@@ -2904,7 +3016,7 @@ def _specClearTabularFile(fileName, teeLogger=None, header='', verifyHeader=Fals
2904
3016
  attempt.note('refused', None, 'clear skipped: a part of the store could not be read; nothing was written')
2905
3017
  return False
2906
3018
  if len(load.parts) > 1:
2907
- tombstones = ''.join(_specFormatRecord([key], delimiter) + '\n' for key in load.data)
3019
+ tombstones = _specFormatRecords(_specTombstones(load.data, delimiter, load.state.strip), delimiter)
2908
3020
  if tombstones:
2909
3021
  _specAppendPayload(load.active, tombstones.encode('utf-8'), attempt, memberSize=load.state.memberSize)
2910
3022
  attempt.note('multipart', None, 'cleared a multi-part store by appending tombstones; older parts are not compacted')
@@ -3557,8 +3669,8 @@ class TSVZed(OrderedDict):
3557
3669
  # the mapping by the caller, so self[key] must not be accessed.
3558
3670
  self.dirty = True
3559
3671
  if self._spec:
3560
- # Spec §9.2: a tombstone is the lone key, never padded.
3561
- self.appendQueue.append(_specFormatRecord([key], self.delimiter))
3672
+ # Spec §9.2: a tombstone is the lone key, never padded. One queue item, so it is committed whole.
3673
+ self.appendQueue.append(_specTombstoneLines(key, self.delimiter, self._specState.strip))
3562
3674
  return self
3563
3675
  if self.correctColumnNum > 1:
3564
3676
  emptyLine = [key]+['']*(self.correctColumnNum-1)
@@ -3780,7 +3892,15 @@ memoryOnly:{self.memoryOnly}
3780
3892
  return self.close()
3781
3893
 
3782
3894
  def popitem(self, last=True):
3783
- key, value = super().popitem(last)
3895
+ if sys.version_info >= (3, 11):
3896
+ key, value = OrderedDict.popitem(self, last)
3897
+ else:
3898
+ # Before Python 3.11 OrderedDict.popitem goes through self[key] and del self[key], which strip the key.
3899
+ if not OrderedDict.__len__(self):
3900
+ raise KeyError('dictionary is empty')
3901
+ key = next(OrderedDict.__reversed__(self)) if last else next(OrderedDict.__iter__(self))
3902
+ value = OrderedDict.__getitem__(self, key)
3903
+ OrderedDict.__delitem__(self, key)
3784
3904
  if not self.memoryOnly:
3785
3905
  self.__appendEmptyLine(key)
3786
3906
  self.lastUpdateTime = get_time_ns()
@@ -4485,7 +4605,7 @@ class TSVZedLite(MutableMapping):
4485
4605
  self._specWrite(_specFormatRecord(value, self.delimiter, marker=True))
4486
4606
 
4487
4607
  def _specWrite(self, line):
4488
- """Append one record to the active part; return its offset, or None for a compressed part."""
4608
+ """Append one record (or lines written together) to the active part; return its offset, or None for a compressed part."""
4489
4609
  data = (line + '\n').encode('utf-8', errors='replace')
4490
4610
  if self.fileObj is None:
4491
4611
  reporter = _Reporter(self._activePath)
@@ -4658,7 +4778,7 @@ class TSVZedLite(MutableMapping):
4658
4778
 
4659
4779
  def __mapDeleteToFile(self,key):
4660
4780
  if self._spec:
4661
- self._specWrite(_specFormatRecord([key], self.delimiter))
4781
+ self._specWrite(_specTombstoneLines(key, self.delimiter, self._specState.strip))
4662
4782
  return
4663
4783
  # Persist a deletion by appending a tombstone (a lone key with no
4664
4784
  # values); on read such a row deletes the key (see _processLine). The
@@ -4802,7 +4922,7 @@ class TSVZedLite(MutableMapping):
4802
4922
  eprint(f"Key {key} not found")
4803
4923
  return
4804
4924
  self.indexes.pop(key, None)
4805
- self._specWrite(_specFormatRecord([key], self.delimiter))
4925
+ self.__mapDeleteToFile(key)
4806
4926
  return
4807
4927
  if key == DEFAULTS_INDICATOR_KEY:
4808
4928
  self.__mapDeleteToFile(key)
@@ -5379,11 +5499,14 @@ class _ServeStore(object):
5379
5499
  def _payload(self, rows, log):
5380
5500
  """Bytes for ``rows`` (``[(cells, marker), ...]``), formatted as the direct writers format them."""
5381
5501
  if self.spec:
5382
- return ''.join(_specFormatRecord(cells, self.delimiter, marker=marker) + '\n'
5383
- for cells, marker in rows).encode('utf-8')
5502
+ return _specFormatRecords(rows, self.delimiter).encode('utf-8')
5384
5503
  return _legacyFormatPayload(self.path, [cells for cells, marker in rows], log, [''], True, self.verbose,
5385
5504
  'utf8', self.strict, self.delimiter)[0]
5386
5505
 
5506
+ def _tombstone(self, key):
5507
+ """Rows that delete ``key``: written with stripping off when it would strip ``key`` (spec §9.6)."""
5508
+ return _specTombstones([key], self.delimiter, self.state.strip) if self.spec else [([key], False)]
5509
+
5387
5510
  def _durable(self, sync):
5388
5511
  return sync or (self.spec and self.state.writeAck == 'disk')
5389
5512
 
@@ -5421,7 +5544,7 @@ class _ServeStore(object):
5421
5544
  if row is None:
5422
5545
  return None
5423
5546
  row = list(row)
5424
- queued = self._submit([([key], False)], sync, log)
5547
+ queued = self._submit(self._tombstone(key), sync, log)
5425
5548
  self._ack(queued, written=True) # the row is only popped once its tombstone is written
5426
5549
  return row
5427
5550
 
@@ -5435,7 +5558,7 @@ class _ServeStore(object):
5435
5558
  return None
5436
5559
  key = next(reversed(self.data)) if last else next(iter(self.data))
5437
5560
  row = list(self.data[key])
5438
- queued = self._submit([([key], False)], sync, log)
5561
+ queued = self._submit(self._tombstone(key), sync, log)
5439
5562
  self._ack(queued, written=True)
5440
5563
  return row
5441
5564
 
@@ -5620,21 +5743,32 @@ def _serveDispatch(store, line, bulk, log):
5620
5743
  if operation in _SERVE_NEED_STORE and store.missing(log):
5621
5744
  return 1, [], 'no such store' # spec §20.2.2
5622
5745
  unreadable = (1, _SERVE_UNREADABLE)
5746
+ # Each read takes its status under the store lock it read under: another request's reload may
5747
+ # change ``unreadable`` once the lock is free.
5623
5748
  if operation == 'read':
5624
- rows = store.read(log)
5625
- status = unreadable if store.unreadable else (0, '')
5749
+ with store.lock:
5750
+ rows = store.read(log)
5751
+ status = unreadable if store.unreadable else (0, '')
5626
5752
  return status[0], [_specFormatRecord(row, '\t') for row in rows], status[1]
5627
5753
  if operation == 'get':
5628
- rows, missing = store.get(args, log)
5629
- status = unreadable if store.unreadable else (3 if missing else 0, '')
5754
+ with store.lock:
5755
+ rows, missing = store.get(args, log)
5756
+ status = unreadable if store.unreadable else (3 if missing else 0, '')
5630
5757
  return status[0], [_specFormatRecord(row, '\t') for row in rows], status[1]
5631
5758
  if operation == 'has':
5632
- found = store.has(args[0], log)
5633
- return (1, [], _SERVE_UNREADABLE) if store.unreadable else (0 if found else 3, [], '')
5759
+ with store.lock:
5760
+ found = store.has(args[0], log)
5761
+ return (1, [], _SERVE_UNREADABLE) if store.unreadable else (0 if found else 3, [], '')
5634
5762
  if operation == 'len':
5635
- return 0, [str(store.length(log))], ''
5763
+ with store.lock:
5764
+ count = store.length(log)
5765
+ status = unreadable if store.unreadable else (0, '') # spec §4.10, §20.6: a stopped read failed
5766
+ return status[0], [str(count)], status[1]
5636
5767
  if operation == 'keys':
5637
- return 0, [_specEncodeField(key, '\t', isKey=True) for key in store.keys(log)], ''
5768
+ with store.lock:
5769
+ keys = store.keys(log)
5770
+ status = unreadable if store.unreadable else (0, '')
5771
+ return status[0], [_specEncodeField(key, '\t', isKey=True) for key in keys], status[1]
5638
5772
  if operation in ('set', 'append', 'delete'):
5639
5773
  keysOnly = operation == 'delete'
5640
5774
  if isBulk:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes