TSVZ 3.36__tar.gz → 3.38__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: 3.36
3
+ Version: 3.38
4
4
  Summary: An simple in memory wrapper around a TSV file to function as a database
5
5
  Home-page: https://github.com/yufei-pan/TSVZ
6
6
  Author: Yufei Pan
@@ -11,6 +11,9 @@ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (G
11
11
  Classifier: Operating System :: OS Independent
12
12
  Requires-Python: >=3.6
13
13
  Description-Content-Type: text/markdown
14
+ Requires-Dist: argparse
15
+ Provides-Extra: completion
16
+ Requires-Dist: argcomplete; extra == "completion"
14
17
  Dynamic: author
15
18
  Dynamic: author-email
16
19
  Dynamic: classifier
@@ -18,6 +21,8 @@ Dynamic: description
18
21
  Dynamic: description-content-type
19
22
  Dynamic: home-page
20
23
  Dynamic: license
24
+ Dynamic: provides-extra
25
+ Dynamic: requires-dist
21
26
  Dynamic: requires-python
22
27
  Dynamic: summary
23
28
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TSVZ
3
- Version: 3.36
3
+ Version: 3.38
4
4
  Summary: An simple in memory wrapper around a TSV file to function as a database
5
5
  Home-page: https://github.com/yufei-pan/TSVZ
6
6
  Author: Yufei Pan
@@ -11,6 +11,9 @@ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (G
11
11
  Classifier: Operating System :: OS Independent
12
12
  Requires-Python: >=3.6
13
13
  Description-Content-Type: text/markdown
14
+ Requires-Dist: argparse
15
+ Provides-Extra: completion
16
+ Requires-Dist: argcomplete; extra == "completion"
14
17
  Dynamic: author
15
18
  Dynamic: author-email
16
19
  Dynamic: classifier
@@ -18,6 +21,8 @@ Dynamic: description
18
21
  Dynamic: description-content-type
19
22
  Dynamic: home-page
20
23
  Dynamic: license
24
+ Dynamic: provides-extra
25
+ Dynamic: requires-dist
21
26
  Dynamic: requires-python
22
27
  Dynamic: summary
23
28
 
@@ -5,4 +5,6 @@ TSVZ.egg-info/PKG-INFO
5
5
  TSVZ.egg-info/SOURCES.txt
6
6
  TSVZ.egg-info/dependency_links.txt
7
7
  TSVZ.egg-info/entry_points.txt
8
- TSVZ.egg-info/top_level.txt
8
+ TSVZ.egg-info/requires.txt
9
+ TSVZ.egg-info/top_level.txt
10
+ tests/test_regression.py
@@ -0,0 +1,4 @@
1
+ argparse
2
+
3
+ [completion]
4
+ argcomplete
@@ -1,4 +1,5 @@
1
- #!/usr/bin/env python3
1
+ #! /usr/bin/env python3
2
+ # PYTHON_ARGCOMPLETE_OK
2
3
  # /// script
3
4
  # requires-python = ">=3.6"
4
5
  # dependencies = [
@@ -9,7 +10,6 @@ import functools
9
10
  import io
10
11
  import os
11
12
  import re
12
- from tabnanny import verbose
13
13
  import threading
14
14
  import time
15
15
  import sys
@@ -26,23 +26,42 @@ if os.name == 'nt':
26
26
  elif os.name == 'posix':
27
27
  import fcntl
28
28
 
29
- version = '3.36'
29
+ version = '3.38'
30
30
  __version__ = version
31
31
  author = 'pan@zopyr.us'
32
- COMMIT_DATE = '2026-02-02'
32
+ COMMIT_DATE = '2026-06-04'
33
33
 
34
34
  DEFAULT_DELIMITER = '\t'
35
35
  DEFAULTS_INDICATOR_KEY = '#_defaults_#'
36
36
 
37
- COMPRESSED_FILE_EXTENSIONS = ['gz','gzip','bz2','bzip2','xz','lzma']
37
+ COMPRESSED_FILE_EXTENSIONS = ['gz','gzip','bz2','bzip2','xz','lzma','zst','zstd']
38
38
 
39
- def get_delimiter(delimiter,file_name = ''):
39
+ def _isCompressedFile(fileName):
40
+ return fileName.rpartition('.')[2].lower() in COMPRESSED_FILE_EXTENSIONS
41
+
42
+ def get_delimiter(delimiter = ...,file_name = ''):
43
+ """
44
+ Resolve the delimiter to use for an operation.
45
+
46
+ When the delimiter is not provided (the ``...`` sentinel or a falsy value),
47
+ it is determined automatically: inferred from the file name when one is
48
+ available, otherwise falling back to the last-determined default. Explicit
49
+ delimiters (including the aliases 'comma', 'tab', 'pipe', 'null' and escape
50
+ sequences) are normalized. The resolved value is remembered as the new
51
+ module-level default.
52
+
53
+ Parameters:
54
+ - delimiter: The delimiter or alias, or ``...``/``None`` to auto-determine.
55
+ - file_name (str, optional): The file name to infer the delimiter from.
56
+
57
+ Returns:
58
+ str: The resolved delimiter.
59
+ """
40
60
  global DEFAULT_DELIMITER
41
- if not delimiter:
42
- return DEFAULT_DELIMITER
43
- elif delimiter == ...:
61
+ if delimiter is ...:
44
62
  if not file_name:
45
- rtn = '\t'
63
+ # Nothing to infer from; reuse the last-determined default.
64
+ return DEFAULT_DELIMITER
46
65
  elif file_name.endswith('.csv'):
47
66
  rtn = ','
48
67
  elif file_name.endswith('.nsv'):
@@ -51,6 +70,8 @@ def get_delimiter(delimiter,file_name = ''):
51
70
  rtn = '|'
52
71
  else:
53
72
  rtn = '\t'
73
+ elif not delimiter:
74
+ return DEFAULT_DELIMITER
54
75
  elif delimiter == 'comma':
55
76
  rtn = ','
56
77
  elif delimiter == 'tab':
@@ -75,34 +96,43 @@ def eprint(*args, **kwargs):
75
96
  print(*args, **kwargs)
76
97
 
77
98
  def openFileAsCompressed(fileName,mode = 'rb',encoding = 'utf8',teeLogger = None,compressLevel = 1):
99
+ lowerFileName = fileName.lower()
78
100
  if 'b' not in mode:
79
101
  mode += 't'
80
102
  kwargs = {}
81
103
  if 'r' not in mode:
82
- if fileName.endswith('.xz'):
104
+ if lowerFileName.endswith('.xz'):
83
105
  kwargs['preset'] = compressLevel
106
+ elif lowerFileName.endswith('.zst') or lowerFileName.endswith('.zstd'):
107
+ kwargs['level'] = compressLevel
84
108
  else:
85
109
  kwargs['compresslevel'] = compressLevel
86
110
  if 'b' not in mode:
87
111
  kwargs['encoding'] = encoding
88
- if fileName.endswith('.xz') or fileName.endswith('.lzma'):
112
+ if lowerFileName.endswith('.xz') or lowerFileName.endswith('.lzma'):
89
113
  try:
90
114
  import lzma
91
115
  return lzma.open(fileName, mode, **kwargs)
92
116
  except Exception:
93
117
  __teePrintOrNot(f"Failed to open {fileName} with lzma, trying bin",teeLogger=teeLogger)
94
- elif fileName.endswith('.gz') or fileName.endswith('.gzip'):
118
+ elif lowerFileName.endswith('.gz') or lowerFileName.endswith('.gzip'):
95
119
  try:
96
120
  import gzip
97
121
  return gzip.open(fileName, mode, **kwargs)
98
122
  except Exception:
99
123
  __teePrintOrNot(f"Failed to open {fileName} with gzip, trying bin",teeLogger=teeLogger)
100
- elif fileName.endswith('.bz2') or fileName.endswith('.bzip2'):
124
+ elif lowerFileName.endswith('.bz2') or lowerFileName.endswith('.bzip2'):
101
125
  try:
102
126
  import bz2
103
127
  return bz2.open(fileName, mode, **kwargs)
104
128
  except Exception:
105
129
  __teePrintOrNot(f"Failed to open {fileName} with bz2, trying bin",teeLogger=teeLogger)
130
+ elif lowerFileName.endswith('.zst') or lowerFileName.endswith('.zstd'):
131
+ try:
132
+ from compression import zstd
133
+ return zstd.open(fileName, mode, **kwargs)
134
+ except Exception:
135
+ __teePrintOrNot(f"Failed to open {fileName} with zstd, trying bin",teeLogger=teeLogger)
106
136
  if 't' in mode:
107
137
  mode = mode.replace('t','')
108
138
  return open(fileName, mode, encoding=encoding)
@@ -328,7 +358,7 @@ def format_bytes(size, use_1024_bytes=None, to_int=False, to_str=False,str_forma
328
358
  power = 2**10
329
359
  n = 0
330
360
  power_labels = {0 : '', 1: 'Ki', 2: 'Mi', 3: 'Gi', 4: 'Ti', 5: 'Pi'}
331
- while size > power:
361
+ while size >= power:
332
362
  size /= power
333
363
  n += 1
334
364
  return f"{size:{str_format}}{' '}{power_labels[n]}"
@@ -336,7 +366,7 @@ def format_bytes(size, use_1024_bytes=None, to_int=False, to_str=False,str_forma
336
366
  power = 10**3
337
367
  n = 0
338
368
  power_labels = {0 : '', 1: 'K', 2: 'M', 3: 'G', 4: 'T', 5: 'P'}
339
- while size > power:
369
+ while size >= power:
340
370
  size /= power
341
371
  n += 1
342
372
  return f"{size:{str_format}}{' '}{power_labels[n]}"
@@ -403,7 +433,7 @@ def __teePrintOrNot(message,level = 'info',teeLogger = None):
403
433
  except Exception:
404
434
  print(message,flush=True)
405
435
 
406
- def _processLine(line,taskDic,correctColumnNum,strict = True,delimiter = DEFAULT_DELIMITER,defaults = ...,
436
+ def _processLine(line,taskDic,correctColumnNum,strict = True,delimiter = ...,defaults = ...,
407
437
  storeOffset = False, offset = -1):
408
438
  """
409
439
  Process a line of text and update the task dictionary.
@@ -423,6 +453,7 @@ def _processLine(line,taskDic,correctColumnNum,strict = True,delimiter = DEFAULT
423
453
  """
424
454
  if defaults is ...:
425
455
  defaults = []
456
+ delimiter = get_delimiter(delimiter)
426
457
  line = line.strip('\x00').rstrip('\r\n')
427
458
  if not line or (line.startswith('#') and not line.startswith(DEFAULTS_INDICATOR_KEY)):
428
459
  # if verbose:
@@ -446,7 +477,7 @@ def _processLine(line,taskDic,correctColumnNum,strict = True,delimiter = DEFAULT
446
477
  # if verbose:
447
478
  # __teePrintOrNot(f"Empty defaults line found: {line}",teeLogger=teeLogger)
448
479
  defaults.clear()
449
- defaults[0] = DEFAULTS_INDICATOR_KEY
480
+ defaults[:] = [DEFAULTS_INDICATOR_KEY]
450
481
  else:
451
482
  # if verbose:
452
483
  # __teePrintOrNot(f"Key {lineCache[0]} found with empty value, deleting such key's representaion",teeLogger=teeLogger)
@@ -481,6 +512,44 @@ def _processLine(line,taskDic,correctColumnNum,strict = True,delimiter = DEFAULT
481
512
  # __teePrintOrNot(f"Key {lineCache[0]} added",teeLogger=teeLogger)
482
513
  return correctColumnNum, lineCache
483
514
 
515
+ def _read_last_valid_line_forward(fileName, taskDic, correctColumnNum, verbose=False, teeLogger=None,
516
+ strict=False, encoding='utf8', delimiter=...,
517
+ defaults=None, storeOffset=False):
518
+ """
519
+ Forward, single-pass variant of read_last_valid_line for compressed files.
520
+
521
+ Backward chunked seeking forces a full re-decompress on every seek (and is not
522
+ reliably supported for some codecs e.g. zstd), so for compressed files we decompress
523
+ once, streaming forward, and keep only the last valid line / offset. A scratch dict is
524
+ used so memory stays flat instead of accumulating the whole file into taskDic.
525
+ """
526
+ if defaults is None:
527
+ defaults = []
528
+ delimiter = get_delimiter(delimiter, file_name=fileName)
529
+ last_valid = -1 if storeOffset else []
530
+ scratch = {}
531
+ with openFileAsCompressed(fileName, 'rb', encoding=encoding, teeLogger=teeLogger) as file:
532
+ while True:
533
+ offset = file.tell()
534
+ line = file.readline()
535
+ if not line:
536
+ break
537
+ if not line.strip():
538
+ continue
539
+ correctColumnNum, lineCache = _processLine(
540
+ line=line.decode(encoding=encoding, errors='replace'),
541
+ taskDic=scratch,
542
+ correctColumnNum=correctColumnNum,
543
+ strict=strict,
544
+ delimiter=delimiter,
545
+ defaults=defaults,
546
+ storeOffset=storeOffset,
547
+ offset=offset,
548
+ )
549
+ if lineCache:
550
+ last_valid = offset if storeOffset else lineCache
551
+ return last_valid
552
+
484
553
  def read_last_valid_line(fileName, taskDic, correctColumnNum, verbose=False, teeLogger=None, strict=False,
485
554
  encoding = 'utf8',delimiter = ...,defaults = ...,storeOffset = False ):
486
555
  """
@@ -508,6 +577,12 @@ def read_last_valid_line(fileName, taskDic, correctColumnNum, verbose=False, tee
508
577
  delimiter = get_delimiter(delimiter,file_name=fileName)
509
578
  if verbose:
510
579
  __teePrintOrNot(f"Reading last line only from {fileName}",teeLogger=teeLogger)
580
+ if _isCompressedFile(fileName):
581
+ # Backward seeking through a codec is slow/unsupported; decompress once, forward.
582
+ return _read_last_valid_line_forward(
583
+ fileName, taskDic, correctColumnNum, verbose=verbose, teeLogger=teeLogger,
584
+ strict=strict, encoding=encoding, delimiter=delimiter, defaults=defaults,
585
+ storeOffset=storeOffset)
511
586
  with openFileAsCompressed(fileName, 'rb',encoding=encoding, teeLogger=teeLogger) as file:
512
587
  file.seek(0, os.SEEK_END)
513
588
  file_size = file.tell()
@@ -558,7 +633,8 @@ def read_last_valid_line(fileName, taskDic, correctColumnNum, verbose=False, tee
558
633
  return last_valid_line
559
634
 
560
635
  @functools.lru_cache(maxsize=None)
561
- def _get_sanitization_re(delimiter = DEFAULT_DELIMITER):
636
+ def _get_sanitization_re(delimiter = ...):
637
+ delimiter = get_delimiter(delimiter)
562
638
  return re.compile(r"(</sep/>|</LF/>|<sep>|<LF>|\n|" + re.escape(delimiter) + r")")
563
639
 
564
640
  _sanitize_replacements = {
@@ -568,9 +644,10 @@ _sanitize_replacements = {
568
644
  }
569
645
  _inverse_sanitize_replacements = {v: k for k, v in _sanitize_replacements.items()}
570
646
 
571
- def _sanitize(data,delimiter = DEFAULT_DELIMITER):
647
+ def _sanitize(data,delimiter = ...):
572
648
  if not data:
573
649
  return data
650
+ delimiter = get_delimiter(delimiter)
574
651
  def repl(m):
575
652
  tok = m.group(0)
576
653
  if tok == delimiter:
@@ -584,9 +661,10 @@ def _sanitize(data,delimiter = DEFAULT_DELIMITER):
584
661
  else:
585
662
  return [pattern.sub(repl,str(segment)) if segment else '' for segment in data]
586
663
 
587
- def _unsanitize(data,delimiter = DEFAULT_DELIMITER):
664
+ def _unsanitize(data,delimiter = ...):
588
665
  if not data:
589
666
  return data
667
+ delimiter = get_delimiter(delimiter)
590
668
  def repl(m):
591
669
  tok = m.group(0)
592
670
  if tok == "<sep>":
@@ -598,7 +676,7 @@ def _unsanitize(data,delimiter = DEFAULT_DELIMITER):
598
676
  else:
599
677
  return [pattern.sub(repl,str(segment).rstrip()) if segment else '' for segment in data]
600
678
 
601
- def _formatHeader(header,verbose = False,teeLogger = None,delimiter = DEFAULT_DELIMITER):
679
+ def _formatHeader(header,verbose = False,teeLogger = None,delimiter = ...):
602
680
  """
603
681
  Format the header string.
604
682
 
@@ -611,7 +689,7 @@ def _formatHeader(header,verbose = False,teeLogger = None,delimiter = DEFAULT_DE
611
689
  list: The formatted header list of string.
612
690
  """
613
691
  if isinstance(header,str):
614
- header = header.split(delimiter)
692
+ header = header.split(get_delimiter(delimiter))
615
693
  else:
616
694
  try:
617
695
  header = [str(s) for s in header]
@@ -621,7 +699,7 @@ def _formatHeader(header,verbose = False,teeLogger = None,delimiter = DEFAULT_DE
621
699
  header = []
622
700
  return [s.rstrip() for s in header]
623
701
 
624
- def _lineContainHeader(header,line,verbose = False,teeLogger = None,strict = False,delimiter = DEFAULT_DELIMITER):
702
+ def _lineContainHeader(header,line,verbose = False,teeLogger = None,strict = False,delimiter = ...):
625
703
  """
626
704
  Verify if a line contains the header.
627
705
 
@@ -635,6 +713,7 @@ def _lineContainHeader(header,line,verbose = False,teeLogger = None,strict = Fal
635
713
  Returns:
636
714
  bool: True if the header matches the line, False otherwise.
637
715
  """
716
+ delimiter = get_delimiter(delimiter)
638
717
  line = _formatHeader(line,verbose=verbose,teeLogger=teeLogger,delimiter=delimiter)
639
718
  if verbose:
640
719
  __teePrintOrNot(f"Header: \n{header}",teeLogger=teeLogger)
@@ -646,7 +725,7 @@ def _lineContainHeader(header,line,verbose = False,teeLogger = None,strict = Fal
646
725
  return False
647
726
  return True
648
727
 
649
- def _verifyFileExistence(fileName,createIfNotExist = True,teeLogger = None,header = [],encoding = 'utf8',strict = True,delimiter = DEFAULT_DELIMITER):
728
+ def _verifyFileExistence(fileName,createIfNotExist = True,teeLogger = None,header = [],encoding = 'utf8',strict = True,delimiter = ...):
650
729
  """
651
730
  Verify the existence of the tabular file.
652
731
 
@@ -661,6 +740,7 @@ def _verifyFileExistence(fileName,createIfNotExist = True,teeLogger = None,heade
661
740
  Returns:
662
741
  bool: True if the file exists, False otherwise.
663
742
  """
743
+ delimiter = get_delimiter(delimiter, file_name=fileName)
664
744
  remainingFileName, _ ,extenstionName = fileName.rpartition('.')
665
745
  if extenstionName in COMPRESSED_FILE_EXTENSIONS:
666
746
  remainingFileName, _ ,extenstionName = remainingFileName.rpartition('.')
@@ -677,7 +757,6 @@ def _verifyFileExistence(fileName,createIfNotExist = True,teeLogger = None,heade
677
757
  try:
678
758
  with openFileAsCompressed(fileName, mode ='wb',encoding=encoding,teeLogger=teeLogger)as file:
679
759
  header = delimiter.join(_sanitize(_formatHeader(header,
680
- verbose=verbose,
681
760
  teeLogger=teeLogger,
682
761
  delimiter=delimiter,
683
762
  ),delimiter=delimiter))
@@ -770,7 +849,7 @@ def readTabularFile(fileName,teeLogger = None,header = '',createIfNotExist = Fal
770
849
  with openFileAsCompressed(fileName, mode ='rb',encoding=encoding,teeLogger=teeLogger)as file:
771
850
  if any(header) and verifyHeader:
772
851
  line = file.readline().decode(encoding=encoding,errors='replace')
773
- if _lineContainHeader(header,line,verbose = verbose,teeLogger = teeLogger,strict = strict) and correctColumnNum == -1:
852
+ if _lineContainHeader(header,line,verbose = verbose,teeLogger = teeLogger,strict = strict,delimiter = delimiter) and correctColumnNum == -1:
774
853
  correctColumnNum = len(header)
775
854
  if verbose:
776
855
  __teePrintOrNot(f"correctColumnNum: {correctColumnNum}",teeLogger=teeLogger)
@@ -872,7 +951,7 @@ def appendLinesTabularFile(fileName,linesToAppend,teeLogger = None,header = '',c
872
951
  if any(header) and verifyHeader:
873
952
  with openFileAsCompressed(fileName, mode ='rb',encoding=encoding,teeLogger=teeLogger)as file:
874
953
  line = file.readline().decode(encoding=encoding,errors='replace')
875
- if _lineContainHeader(header,line,verbose = verbose,teeLogger = teeLogger,strict = strict):
954
+ if _lineContainHeader(header,line,verbose = verbose,teeLogger = teeLogger,strict = strict,delimiter = delimiter):
876
955
  correctColumnNum = len(header)
877
956
  if verbose:
878
957
  __teePrintOrNot(f"correctColumnNum: {correctColumnNum}",teeLogger=teeLogger)
@@ -926,7 +1005,7 @@ def clearTabularFile(fileName,teeLogger = None,header = '',verifyHeader = False,
926
1005
  with openFileAsCompressed(fileName, mode ='rb',encoding=encoding,teeLogger=teeLogger)as file:
927
1006
  if any(header) and verifyHeader:
928
1007
  line = file.readline().decode(encoding=encoding,errors='replace')
929
- if not _lineContainHeader(header,line,verbose = verbose,teeLogger = teeLogger,strict = strict):
1008
+ if not _lineContainHeader(header,line,verbose = verbose,teeLogger = teeLogger,strict = strict,delimiter = delimiter):
930
1009
  __teePrintOrNot(f'Warning: Header mismatch in {fileName}. Keeping original header in file...','warning',teeLogger)
931
1010
  header = _formatHeader(line,verbose = verbose,teeLogger = teeLogger,delimiter=delimiter)
932
1011
  with openFileAsCompressed(fileName, mode ='wb',encoding=encoding,teeLogger=teeLogger)as file:
@@ -1019,10 +1098,10 @@ def scrubTabularFile(fileName,teeLogger = None,header = '',createIfNotExist = Fa
1019
1098
  appendLinesTabularFile(fileName,file,teeLogger = teeLogger,header = header,createIfNotExist = createIfNotExist,verifyHeader = verifyHeader,verbose = verbose,encoding = encoding,strict = strict,delimiter = delimiter)
1020
1099
  return file
1021
1100
 
1022
- def getListView(tsvzDic,header = [],delimiter = DEFAULT_DELIMITER):
1101
+ def getListView(tsvzDic,header = [],delimiter = ...):
1023
1102
  if header:
1024
1103
  if isinstance(header,str):
1025
- header = header.split(delimiter)
1104
+ header = header.split(get_delimiter(delimiter))
1026
1105
  elif not isinstance(header,list):
1027
1106
  try:
1028
1107
  header = list(header)
@@ -1066,7 +1145,7 @@ class TSVZed(OrderedDict):
1066
1145
  rewrite_on_exit : bool, default=False
1067
1146
  If True, rewrites the entire file when closing/exiting.
1068
1147
  rewrite_interval : float, default=0
1069
- Minimum time interval (in seconds) between full file rewrites. 0 means no limit.
1148
+ Minimum time interval (in seconds) between full file rewrites. 0 means do not rewrite.
1070
1149
  append_check_delay : float, default=0.01
1071
1150
  Time delay (in seconds) between checks of the append queue by the worker thread.
1072
1151
  monitor_external_changes : bool, default=True
@@ -1595,7 +1674,7 @@ memoryOnly:{self.memoryOnly}
1595
1674
  if self.header:
1596
1675
  line = file.readline().decode(self.encoding,errors='replace')
1597
1676
  aftPos = file.tell()
1598
- if not _lineContainHeader(self.header,line,verbose = self.verbose,teeLogger = self.teeLogger,strict = self.strict):
1677
+ if not _lineContainHeader(self.header,line,verbose = self.verbose,teeLogger = self.teeLogger,strict = self.strict,delimiter = self.delimiter):
1599
1678
  header = self.delimiter.join(_sanitize(self.header,delimiter=self.delimiter))
1600
1679
  file.seek(0)
1601
1680
  file.write(f'{header}\n'.encode(encoding=self.encoding,errors='replace'))
@@ -1726,6 +1805,7 @@ memoryOnly:{self.memoryOnly}
1726
1805
 
1727
1806
  def get_file_obj(self,modes = 'ab'):
1728
1807
  self.writeLock.acquire()
1808
+ file = None
1729
1809
  try:
1730
1810
  if not self.encoding:
1731
1811
  self.encoding = 'utf8'
@@ -1752,6 +1832,8 @@ memoryOnly:{self.memoryOnly}
1752
1832
  # if write lock is already released, return
1753
1833
  if not self.writeLock.locked():
1754
1834
  return
1835
+ if file is None:
1836
+ return
1755
1837
  try:
1756
1838
  file.flush() # Ensure the file is flushed before unlocking
1757
1839
  os.fsync(file.fileno()) # Ensure the file is synced to disk before unlocking
@@ -2024,13 +2106,18 @@ class TSVZedLite(MutableMapping):
2024
2106
  )
2025
2107
  if self.verbose:
2026
2108
  eprint(f"Read at position {pos}: {segments}")
2027
- if key is not ... and segments[0] != key:
2109
+ if key is not ...:
2110
+ if not segments:
2111
+ eprint(f"Error: No segments found at position {pos}")
2112
+ elif segments[0] != key:
2028
2113
  eprint(f"Warning: Key mismatch at position {pos}: expected {key}, got {segments[0]}")
2029
- if self.strict:
2030
- eprint("Error: Key mismatch and strict mode enabled. Raising KeyError.")
2031
- raise KeyError(key)
2032
- else :
2033
- eprint("Continuing despite key mismatch due to non-strict mode. Expect errors!")
2114
+ else:
2115
+ return segments
2116
+ if self.strict:
2117
+ eprint("Error: Key mismatch and strict mode enabled. Raising KeyError.")
2118
+ raise KeyError(key)
2119
+ else :
2120
+ eprint("Continuing despite key mismatch due to non-strict mode. Expect errors!")
2034
2121
  return segments
2035
2122
 
2036
2123
  # Implement basic __getitem__, __setitem__, __delitem__, __iter__, and __len__. needed for MutableMapping
@@ -2083,6 +2170,7 @@ class TSVZedLite(MutableMapping):
2083
2170
  self.defaults = value
2084
2171
  if self.verbose:
2085
2172
  eprint(f"Defaults set to {value}")
2173
+ return
2086
2174
  elif key.startswith('#'):
2087
2175
  if self.verbose:
2088
2176
  eprint(f"Key {key} updated in memory (data in index) as it starts with #")
@@ -2261,6 +2349,11 @@ def __main__():
2261
2349
  strictMode.add_argument('-f', '--force', dest = 'strict',action='store_false', help='Force the operation. Ignore checks for column numbers / headers')
2262
2350
  parser.add_argument('-v', '--verbose', action='store_true', help='Print verbose output')
2263
2351
  parser.add_argument('-V', '--version', action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} by {author}')
2352
+ try:
2353
+ import argcomplete
2354
+ argcomplete.autocomplete(parser,always_complete_options='long')
2355
+ except ImportError:
2356
+ pass
2264
2357
  args = parser.parse_args()
2265
2358
  args.delimiter = get_delimiter(delimiter=args.delimiter,file_name=args.filename)
2266
2359
  if args.header and args.header.endswith('\\'):
@@ -15,6 +15,14 @@ setup(
15
15
  'tsvz = TSVZ:__main__',
16
16
  ],
17
17
  },
18
+ install_requires=[
19
+ 'argparse',
20
+ ],
21
+ extras_require={
22
+ 'completion': [
23
+ 'argcomplete',
24
+ ],
25
+ },
18
26
  classifiers=[
19
27
  'Programming Language :: Python :: 3',
20
28
  'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
@@ -0,0 +1,104 @@
1
+ import os
2
+ import sys
3
+ import tempfile
4
+ import unittest
5
+
6
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
7
+ import TSVZ
8
+
9
+
10
+ class TestTSVZRegressions(unittest.TestCase):
11
+ def test_append_and_read_preserves_embedded_tab_and_newline(self):
12
+ with tempfile.TemporaryDirectory() as temp_dir:
13
+ path = os.path.join(temp_dir, "embedded.tsv")
14
+ header = ["key", "payload"]
15
+ row = ["row1", "hello\tworld\nline2"]
16
+
17
+ TSVZ.appendTabularFile(
18
+ path,
19
+ row,
20
+ header=header,
21
+ createIfNotExist=True,
22
+ verifyHeader=True,
23
+ strict=True,
24
+ delimiter="\t",
25
+ )
26
+
27
+ data = TSVZ.readTabularFile(
28
+ path,
29
+ header=header,
30
+ verifyHeader=True,
31
+ strict=True,
32
+ delimiter="\t",
33
+ )
34
+
35
+ self.assertIn("row1", data)
36
+ self.assertEqual(data["row1"], row)
37
+
38
+ def test_read_uses_defaults_row_for_missing_values(self):
39
+ with tempfile.TemporaryDirectory() as temp_dir:
40
+ path = os.path.join(temp_dir, "defaults.tsv")
41
+ with open(path, "w", encoding="utf8") as handle:
42
+ handle.write("key\tcpu\tmem\n")
43
+ handle.write(f"{TSVZ.DEFAULTS_INDICATOR_KEY}\t4\t8G\n")
44
+ handle.write("job1\t2\t16G\n")
45
+ handle.write("job2\t\t32G\n")
46
+
47
+ data = TSVZ.readTabularFile(
48
+ path,
49
+ header=["key", "cpu", "mem"],
50
+ verifyHeader=True,
51
+ strict=True,
52
+ delimiter="\t",
53
+ )
54
+
55
+ self.assertEqual(data["job1"], ["job1", "2", "16G"])
56
+ self.assertEqual(data["job2"], ["job2", "4", "32G"])
57
+
58
+ def test_last_line_only_ignores_delete_marker_line(self):
59
+ with tempfile.TemporaryDirectory() as temp_dir:
60
+ path = os.path.join(temp_dir, "lastline.tsv")
61
+ with open(path, "w", encoding="utf8") as handle:
62
+ handle.write("key\tvalue\n")
63
+ handle.write("alive\t42\n")
64
+ handle.write("alive\n")
65
+
66
+ last_valid = TSVZ.readTabularFile(
67
+ path,
68
+ header=["key", "value"],
69
+ verifyHeader=True,
70
+ lastLineOnly=True,
71
+ strict=False,
72
+ delimiter="\t",
73
+ )
74
+
75
+ self.assertEqual(last_valid, ["alive", "42"])
76
+
77
+ def test_append_and_read_gzip_file(self):
78
+ with tempfile.TemporaryDirectory() as temp_dir:
79
+ path = os.path.join(temp_dir, "compressed.tsv.gz")
80
+ header = ["key", "value"]
81
+
82
+ TSVZ.appendTabularFile(
83
+ path,
84
+ ["gk", "gv"],
85
+ header=header,
86
+ createIfNotExist=True,
87
+ verifyHeader=True,
88
+ strict=True,
89
+ delimiter="\t",
90
+ )
91
+
92
+ data = TSVZ.readTabularFile(
93
+ path,
94
+ header=header,
95
+ verifyHeader=True,
96
+ strict=True,
97
+ delimiter="\t",
98
+ )
99
+
100
+ self.assertEqual(data["gk"], ["gk", "gv"])
101
+
102
+
103
+ if __name__ == "__main__":
104
+ unittest.main()
File without changes
File without changes
File without changes
File without changes