TSVZ 4.5__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.5
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.5**, 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.5**, 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.5
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.5**, 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.5'
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
 
@@ -2604,16 +2637,23 @@ def _specBackupPart(path, reporter):
2604
2637
  _lockFile(f)
2605
2638
  try:
2606
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
2607
2644
  suffix = 0
2608
2645
  while True:
2609
2646
  try:
2610
- 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')
2611
2650
  break
2612
2651
  except FileExistsError:
2613
2652
  suffix += 1
2614
2653
  backup = '{}.{}'.format(first, suffix)
2615
2654
  try:
2616
2655
  with copy:
2656
+ _matchOwner(copy.fileno(), st)
2617
2657
  shutil.copyfileobj(f, copy)
2618
2658
  copy.flush()
2619
2659
  os.fsync(copy.fileno())
@@ -2672,6 +2712,22 @@ def _specSnapshot(fileName):
2672
2712
  return parts, None
2673
2713
 
2674
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
+
2675
2731
  def _specRewriteInPlace(path, content, expect=None, memberSize=None):
2676
2732
  """Replace a part's bytes in place (same inode, spec §18.7 / §19.8).
2677
2733
 
@@ -2691,18 +2747,73 @@ def _specRewriteInPlace(path, content, expect=None, memberSize=None):
2691
2747
  st = os.fstat(f.fileno())
2692
2748
  if expect is not None and _specStamp(st) != expect:
2693
2749
  return None
2694
- 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:
2695
2755
  return False
2696
- f.seek(0)
2697
- f.write(raw)
2698
- f.truncate()
2699
- f.flush()
2700
- 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)
2701
2773
  return True
2702
2774
  finally:
2775
+ try:
2776
+ _seekRawStart(f) # msvcrt unlocks from the raw position: the range it locked
2777
+ except OSError:
2778
+ pass
2703
2779
  _unlockFile(f)
2704
2780
 
2705
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
+
2706
2817
  def _specPreamble(state, delimiter, compensate=False, version=True):
2707
2818
  """Marker lines that re-create ``state`` (spec §19.2.3a): version, non-default markers, defaults last.
2708
2819
 
@@ -2905,7 +3016,7 @@ def _specClearTabularFile(fileName, teeLogger=None, header='', verifyHeader=Fals
2905
3016
  attempt.note('refused', None, 'clear skipped: a part of the store could not be read; nothing was written')
2906
3017
  return False
2907
3018
  if len(load.parts) > 1:
2908
- tombstones = ''.join(_specFormatRecord([key], delimiter) + '\n' for key in load.data)
3019
+ tombstones = _specFormatRecords(_specTombstones(load.data, delimiter, load.state.strip), delimiter)
2909
3020
  if tombstones:
2910
3021
  _specAppendPayload(load.active, tombstones.encode('utf-8'), attempt, memberSize=load.state.memberSize)
2911
3022
  attempt.note('multipart', None, 'cleared a multi-part store by appending tombstones; older parts are not compacted')
@@ -3558,8 +3669,8 @@ class TSVZed(OrderedDict):
3558
3669
  # the mapping by the caller, so self[key] must not be accessed.
3559
3670
  self.dirty = True
3560
3671
  if self._spec:
3561
- # Spec §9.2: a tombstone is the lone key, never padded.
3562
- 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))
3563
3674
  return self
3564
3675
  if self.correctColumnNum > 1:
3565
3676
  emptyLine = [key]+['']*(self.correctColumnNum-1)
@@ -3781,7 +3892,15 @@ memoryOnly:{self.memoryOnly}
3781
3892
  return self.close()
3782
3893
 
3783
3894
  def popitem(self, last=True):
3784
- 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)
3785
3904
  if not self.memoryOnly:
3786
3905
  self.__appendEmptyLine(key)
3787
3906
  self.lastUpdateTime = get_time_ns()
@@ -4486,7 +4605,7 @@ class TSVZedLite(MutableMapping):
4486
4605
  self._specWrite(_specFormatRecord(value, self.delimiter, marker=True))
4487
4606
 
4488
4607
  def _specWrite(self, line):
4489
- """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."""
4490
4609
  data = (line + '\n').encode('utf-8', errors='replace')
4491
4610
  if self.fileObj is None:
4492
4611
  reporter = _Reporter(self._activePath)
@@ -4659,7 +4778,7 @@ class TSVZedLite(MutableMapping):
4659
4778
 
4660
4779
  def __mapDeleteToFile(self,key):
4661
4780
  if self._spec:
4662
- self._specWrite(_specFormatRecord([key], self.delimiter))
4781
+ self._specWrite(_specTombstoneLines(key, self.delimiter, self._specState.strip))
4663
4782
  return
4664
4783
  # Persist a deletion by appending a tombstone (a lone key with no
4665
4784
  # values); on read such a row deletes the key (see _processLine). The
@@ -4803,7 +4922,7 @@ class TSVZedLite(MutableMapping):
4803
4922
  eprint(f"Key {key} not found")
4804
4923
  return
4805
4924
  self.indexes.pop(key, None)
4806
- self._specWrite(_specFormatRecord([key], self.delimiter))
4925
+ self.__mapDeleteToFile(key)
4807
4926
  return
4808
4927
  if key == DEFAULTS_INDICATOR_KEY:
4809
4928
  self.__mapDeleteToFile(key)
@@ -5380,11 +5499,14 @@ class _ServeStore(object):
5380
5499
  def _payload(self, rows, log):
5381
5500
  """Bytes for ``rows`` (``[(cells, marker), ...]``), formatted as the direct writers format them."""
5382
5501
  if self.spec:
5383
- return ''.join(_specFormatRecord(cells, self.delimiter, marker=marker) + '\n'
5384
- for cells, marker in rows).encode('utf-8')
5502
+ return _specFormatRecords(rows, self.delimiter).encode('utf-8')
5385
5503
  return _legacyFormatPayload(self.path, [cells for cells, marker in rows], log, [''], True, self.verbose,
5386
5504
  'utf8', self.strict, self.delimiter)[0]
5387
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
+
5388
5510
  def _durable(self, sync):
5389
5511
  return sync or (self.spec and self.state.writeAck == 'disk')
5390
5512
 
@@ -5422,7 +5544,7 @@ class _ServeStore(object):
5422
5544
  if row is None:
5423
5545
  return None
5424
5546
  row = list(row)
5425
- queued = self._submit([([key], False)], sync, log)
5547
+ queued = self._submit(self._tombstone(key), sync, log)
5426
5548
  self._ack(queued, written=True) # the row is only popped once its tombstone is written
5427
5549
  return row
5428
5550
 
@@ -5436,7 +5558,7 @@ class _ServeStore(object):
5436
5558
  return None
5437
5559
  key = next(reversed(self.data)) if last else next(iter(self.data))
5438
5560
  row = list(self.data[key])
5439
- queued = self._submit([([key], False)], sync, log)
5561
+ queued = self._submit(self._tombstone(key), sync, log)
5440
5562
  self._ack(queued, written=True)
5441
5563
  return row
5442
5564
 
@@ -5621,21 +5743,32 @@ def _serveDispatch(store, line, bulk, log):
5621
5743
  if operation in _SERVE_NEED_STORE and store.missing(log):
5622
5744
  return 1, [], 'no such store' # spec §20.2.2
5623
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.
5624
5748
  if operation == 'read':
5625
- rows = store.read(log)
5626
- status = unreadable if store.unreadable else (0, '')
5749
+ with store.lock:
5750
+ rows = store.read(log)
5751
+ status = unreadable if store.unreadable else (0, '')
5627
5752
  return status[0], [_specFormatRecord(row, '\t') for row in rows], status[1]
5628
5753
  if operation == 'get':
5629
- rows, missing = store.get(args, log)
5630
- 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, '')
5631
5757
  return status[0], [_specFormatRecord(row, '\t') for row in rows], status[1]
5632
5758
  if operation == 'has':
5633
- found = store.has(args[0], log)
5634
- 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, [], '')
5635
5762
  if operation == 'len':
5636
- 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]
5637
5767
  if operation == 'keys':
5638
- 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]
5639
5772
  if operation in ('set', 'append', 'delete'):
5640
5773
  keysOnly = operation == 'delete'
5641
5774
  if isBulk:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes