kisstdlib 0.0.10__tar.gz → 0.0.12__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.
Files changed (43) hide show
  1. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/PKG-INFO +2 -2
  2. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/argparse_ext.py +0 -34
  3. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/fs.py +176 -166
  4. kisstdlib-0.0.12/kisstdlib/io/adapter.py +167 -0
  5. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/itertools_ext.py +1 -1
  6. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/parsing.py +120 -2
  7. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/sqlite3_ext.py +1 -1
  8. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib.egg-info/PKG-INFO +2 -2
  9. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib.egg-info/SOURCES.txt +2 -0
  10. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/pyproject.toml +1 -1
  11. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/test/test_basics.py +3 -2
  12. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/test/test_io.py +15 -0
  13. kisstdlib-0.0.12/test/test_parsing.py +49 -0
  14. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/LICENSE.txt +0 -0
  15. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/README.md +0 -0
  16. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/__init__.py +0 -0
  17. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/base.py +0 -0
  18. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/compression.py +0 -0
  19. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/failure.py +0 -0
  20. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/getpass_ext.py +0 -0
  21. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/io/__init__.py +0 -0
  22. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/io/base.py +0 -0
  23. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/io/encoder.py +0 -0
  24. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/io/stdio.py +0 -0
  25. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/io/wrapper.py +0 -0
  26. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/logging_ext.py +0 -0
  27. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/py.typed +0 -0
  28. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/pyrepr.py +0 -0
  29. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/signal_ext.py +0 -0
  30. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/sorted.py +0 -0
  31. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/string_ext.py +0 -0
  32. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/tariter.py +0 -0
  33. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib/time.py +0 -0
  34. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib.egg-info/dependency_links.txt +0 -0
  35. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib.egg-info/entry_points.txt +0 -0
  36. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib.egg-info/requires.txt +0 -0
  37. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib.egg-info/top_level.txt +0 -0
  38. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/kisstdlib_bin/describe_forest.py +0 -0
  39. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/setup.cfg +0 -0
  40. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/setup.py +0 -0
  41. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/test/test_fs.py +0 -0
  42. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/test/test_sorted.py +0 -0
  43. {kisstdlib-0.0.10 → kisstdlib-0.0.12}/test/test_time.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: kisstdlib
3
- Version: 0.0.10
3
+ Version: 0.0.12
4
4
  Summary: System programming with conceptually and algebraically simple primitives.
5
5
  Author-email: Jan Malakhovski <oxij@oxij.org>
6
6
  Project-URL: Homepage, https://oxij.org/software/kisstdlib/
@@ -194,38 +194,6 @@ class _OptionallyMarkdownHelpAction(Action):
194
194
  parser.post_parse_args = self.show_help
195
195
 
196
196
 
197
- class _StoreMapAction(Action):
198
- def __init__(self, func: _t.Callable[[_t.Any], _t.Any] = _identity, **kwargs: _t.Any) -> None:
199
- super().__init__(**kwargs)
200
- self.func = func
201
-
202
- def __call__(
203
- self,
204
- parser: _t.Any,
205
- namespace: Namespace,
206
- values: _t.Any,
207
- option_string: str | None = None,
208
- ) -> None:
209
- setattr(namespace, self.dest, self.func(values))
210
-
211
-
212
- class _AppendMapAction(Action):
213
- def __init__(self, func: _t.Callable[[_t.Any], _t.Any] = _identity, **kwargs: _t.Any) -> None:
214
- super().__init__(default=[], **kwargs)
215
- self.func = func
216
-
217
- def __call__(
218
- self,
219
- parser: _t.Any,
220
- namespace: Namespace,
221
- values: _t.Any,
222
- option_string: str | None = None,
223
- ) -> None:
224
- v = getattr(namespace, self.dest)
225
- v.append(self.func(values))
226
- setattr(namespace, self.dest, v)
227
-
228
-
229
197
  class BetterArgumentParser(ArgumentParser):
230
198
  """Like `argparse.ArgumentParser`, but uses `BetterHelpFormatter` by default,
231
199
  and implements `format_help` that recurses into subcommands and appends
@@ -257,8 +225,6 @@ class BetterArgumentParser(ArgumentParser):
257
225
  **kwargs,
258
226
  )
259
227
 
260
- self.register("action", "store_map", _StoreMapAction)
261
- self.register("action", "append_map", _AppendMapAction)
262
228
  self.register("action", "help_omd", _OptionallyMarkdownHelpAction)
263
229
 
264
230
  self.add_version = add_version
@@ -61,6 +61,16 @@ def dirname_dot(path: _t.AnyStr) -> tuple[_t.AnyStr, bool]:
61
61
  return path, True
62
62
 
63
63
 
64
+ def realdir(path: _t.AnyStr, strict: bool = False) -> _t.AnyStr:
65
+ """Apply `os.path.realpath` to the `dirname` part of `path`.
66
+
67
+ Essentially, this returns the canonical path of the inode the `path` points to, even when that
68
+ inode is a symlink.
69
+ """
70
+ dn, bn = _op.split(_op.abspath(path))
71
+ return _op.join(_op.realpath(dn, strict=strict), bn)
72
+
73
+
64
74
  def read_file_maybe(path: str | bytes) -> bytes | None:
65
75
  """Return contents of the given file path, or `None` if it does not exist."""
66
76
  try:
@@ -192,7 +202,7 @@ def iter_subtree(
192
202
  fstat = _os.stat(path, follow_symlinks=follow_symlinks)
193
203
  except OSError as exc:
194
204
  if handle_error is not None:
195
- eno = exc.errno
205
+ eno = exc.errno or 0
196
206
  handle_error(
197
207
  "failed to `stat`: [Errno %d, %s] %s: %s",
198
208
  eno,
@@ -218,7 +228,7 @@ def iter_subtree(
218
228
  scandir_it = _os.scandir(path)
219
229
  except OSError as exc:
220
230
  if handle_error is not None:
221
- eno = exc.errno
231
+ eno = exc.errno or 0
222
232
  handle_error(
223
233
  "failed to `scandir`: [Errno %d, %s] %s: %s",
224
234
  eno,
@@ -240,7 +250,7 @@ def iter_subtree(
240
250
  break
241
251
  except OSError as exc:
242
252
  if handle_error is not None:
243
- eno = exc.errno
253
+ eno = exc.errno or 0
244
254
  handle_error(
245
255
  "failed in `scandir`: [Errno %d, %s] %s: %s",
246
256
  eno,
@@ -255,7 +265,7 @@ def iter_subtree(
255
265
  entry_is_dir = entry.is_dir(follow_symlinks=follow_symlinks)
256
266
  except OSError as exc:
257
267
  if handle_error is not None:
258
- eno = exc.errno
268
+ eno = exc.errno or 0
259
269
  handle_error(
260
270
  "failed to `stat`: [Errno %d, %s] %s: %s",
261
271
  eno,
@@ -398,7 +408,7 @@ def describe_forest(
398
408
  """Produce a simple `find .`+`stat`-like description of walks of given `paths`.
399
409
  See `describe-forest` script for more info.
400
410
  """
401
- escape: _t.Callable[[str], str] = _identity if literal else _escape_path # type: ignore
411
+ escape: _t.Callable[[str], str] = _identity if literal else _escape_path
402
412
  seen: dict[tuple[int, int], tuple[_t.AnyStr, int, str]] = {}
403
413
  for i, dirpath in enumerate(paths):
404
414
  for fpath, _eps, _edir in iter_subtree(dirpath, follow_symlinks=follow_symlinks):
@@ -458,111 +468,6 @@ def describe_forest(
458
468
  yield [epath, "???"] + emode + emtime + esize
459
469
 
460
470
 
461
- def unlink_maybe(path: str | bytes) -> None:
462
- """Try to `os.unlink` and ignore `Exception`s."""
463
- try:
464
- _os.unlink(path)
465
- except Exception:
466
- pass
467
-
468
-
469
- def fsync_maybe(fd: int) -> None:
470
- """Try to `os.fsync` and ignore `errno.EINVAL` errors."""
471
- try:
472
- _os.fsync(fd)
473
- except OSError as exc:
474
- if exc.errno == _errno.EINVAL:
475
- # EINVAL means fd is not attached to a file, so we
476
- # ignore this error
477
- return
478
- raise
479
-
480
-
481
- def fsync_file(path: str | bytes, flags: int = 0) -> None:
482
- """Run `os.fsync` on a given `path`."""
483
- oflags = _os.O_RDONLY | _os.O_NOFOLLOW | _os.O_CLOEXEC if _POSIX else _os.O_RDWR
484
- try:
485
- fd = _os.open(path, oflags | flags)
486
- except OSError as exc:
487
- if exc.errno == _errno.ELOOP:
488
- # ignore symlinks; doing it this way insead of `stat`ing the path to
489
- # ensure atomicity
490
- return
491
- raise
492
- try:
493
- _os.fsync(fd)
494
- except OSError as exc:
495
- exc.filename = path
496
- raise exc
497
- finally:
498
- _os.close(fd)
499
-
500
-
501
- def unlink_many(
502
- paths: _t.Iterable[_t.AnyStr], exceptions: list[Exception] | None = None
503
- ) -> list[_t.AnyStr]:
504
- """`os.unlink` many paths, optionally collecting exceptions.
505
- Returns the paths for which `os.unlink` failed.
506
- """
507
- left = []
508
- for path in paths:
509
- try:
510
- _os.unlink(path)
511
- except Exception as exc:
512
- if exceptions is None:
513
- raise
514
- left.append(path)
515
- exceptions.append(exc)
516
- return left
517
-
518
-
519
- def fsync_many_files(
520
- paths: _t.Iterable[_t.AnyStr], flags: int = 0, exceptions: list[Exception] | None = None
521
- ) -> list[_t.AnyStr]:
522
- """`os.fsync` many paths, optionally collecting exceptions.
523
- Returns the paths for which `os.fsync` failed.
524
- """
525
- left = []
526
- for path in paths:
527
- try:
528
- fsync_file(path, flags)
529
- except Exception as exc:
530
- if exceptions is None:
531
- raise
532
- left.append(path)
533
- exceptions.append(exc)
534
- return left
535
-
536
-
537
- def rename(
538
- src_path: _t.AnyStr,
539
- dst_path: _t.AnyStr,
540
- allow_overwrites: bool,
541
- *,
542
- makedirs: bool = True,
543
- dst_dir: _t.AnyStr | None = None,
544
- ) -> None:
545
- if dst_dir is None:
546
- dst_dir, nondot = dirname_dot(dst_path)
547
- makedirs = makedirs and nondot
548
-
549
- if makedirs:
550
- _os.makedirs(dst_dir, exist_ok=True)
551
-
552
- if allow_overwrites:
553
- _os.replace(src_path, dst_path)
554
- elif _POSIX:
555
- with Directory(dst_dir) as d:
556
- d.flock()
557
- # this is now atomic
558
- if _os.path.lexists(dst_path):
559
- raise FileExistsError(_errno.EEXIST, _os.strerror(_errno.EEXIST), dst_path)
560
- _os.rename(src_path, dst_path)
561
- else:
562
- # this is both atomic and fails with `FileExistsError` on Windows
563
- _os.rename(src_path, dst_path)
564
-
565
-
566
471
  DeferredRename = tuple[_t.AnyStr, _t.AnyStr, bool, _t.AnyStr, _t.AnyStr]
567
472
 
568
473
 
@@ -601,6 +506,12 @@ class DeferredSync(_t.Generic[_t.AnyStr]):
601
506
  self.rename_file = _c.deque()
602
507
  self._after = None
603
508
 
509
+ @property
510
+ def after(self) -> "DeferredSync[_t.AnyStr]":
511
+ if self._after is None:
512
+ self._after = DeferredSync(self.defer)
513
+ return self._after
514
+
604
515
  def __repr__(self) -> str:
605
516
  return (
606
517
  f"""<{self.__class__.__name__}
@@ -615,12 +526,6 @@ after="""
615
526
  + ">"
616
527
  )
617
528
 
618
- @property
619
- def after(self) -> "DeferredSync[_t.AnyStr]":
620
- if self._after is None:
621
- self._after = DeferredSync(self.defer)
622
- return self._after
623
-
624
529
  def copy(self) -> "DeferredSync[_t.AnyStr]":
625
530
  """Return a shallow copy of this object (elements describing operations are not
626
531
  copied, only the structure is).
@@ -793,6 +698,135 @@ after="""
793
698
  self.clear()
794
699
 
795
700
 
701
+ def fsync_maybe(fd: int) -> None:
702
+ """Try to `os.fsync` and ignore `errno.EINVAL` errors."""
703
+ try:
704
+ _os.fsync(fd)
705
+ except OSError as exc:
706
+ if exc.errno == _errno.EINVAL:
707
+ # EINVAL means fd is not attached to a file, so we
708
+ # ignore this error
709
+ return
710
+ raise
711
+
712
+
713
+ def fsync_file(path: str | bytes, flags: int = 0) -> None:
714
+ """Run `os.fsync` on a given `path`."""
715
+ oflags = _os.O_RDONLY | _os.O_NOFOLLOW | _os.O_CLOEXEC if _POSIX else _os.O_RDWR
716
+ try:
717
+ fd = _os.open(path, oflags | flags)
718
+ except OSError as exc:
719
+ if exc.errno == _errno.ELOOP:
720
+ # ignore symlinks; doing it this way insead of `stat`ing the path to
721
+ # ensure atomicity
722
+ return
723
+ raise
724
+ try:
725
+ _os.fsync(fd)
726
+ except OSError as exc:
727
+ exc.filename = path
728
+ raise exc
729
+ finally:
730
+ _os.close(fd)
731
+
732
+
733
+ def fsync_many_files(
734
+ paths: _t.Iterable[_t.AnyStr], flags: int = 0, exceptions: list[Exception] | None = None
735
+ ) -> list[_t.AnyStr]:
736
+ """`os.fsync` many paths, optionally collecting exceptions.
737
+ Returns the paths for which `os.fsync` failed.
738
+ """
739
+ left = []
740
+ for path in paths:
741
+ try:
742
+ fsync_file(path, flags)
743
+ except Exception as exc:
744
+ if exceptions is None:
745
+ raise
746
+ left.append(path)
747
+ exceptions.append(exc)
748
+ return left
749
+
750
+
751
+ def unlink_maybe(path: str | bytes) -> None:
752
+ """Try to `os.unlink` and ignore `Exception`s."""
753
+ try:
754
+ _os.unlink(path)
755
+ except Exception:
756
+ pass
757
+
758
+
759
+ def unlink_many(
760
+ paths: _t.Iterable[_t.AnyStr], exceptions: list[Exception] | None = None
761
+ ) -> list[_t.AnyStr]:
762
+ """`os.unlink` many paths, optionally collecting exceptions.
763
+ Returns the paths for which `os.unlink` failed.
764
+ """
765
+ left = []
766
+ for path in paths:
767
+ try:
768
+ _os.unlink(path)
769
+ except Exception as exc:
770
+ if exceptions is None:
771
+ raise
772
+ left.append(path)
773
+ exceptions.append(exc)
774
+ return left
775
+
776
+
777
+ def atomic_unlink(
778
+ path: _t.AnyStr,
779
+ *,
780
+ sync: DeferredSync[_t.AnyStr] | bool = True,
781
+ ) -> None:
782
+ """Atomically unlink `path`."""
783
+
784
+ dirname, _ = dirname_dot(path)
785
+
786
+ if isinstance(sync, DeferredSync) and sync.defer:
787
+ sync.unlink_file.add(path)
788
+ sync.fsync_dir.add(dirname)
789
+ return
790
+
791
+ _os.unlink(path)
792
+
793
+ if isinstance(sync, DeferredSync):
794
+ sync.fsync_dir.add(dirname)
795
+ return
796
+
797
+ if sync and _POSIX:
798
+ fsync_file(dirname, _os.O_DIRECTORY)
799
+
800
+
801
+ def rename(
802
+ src_path: _t.AnyStr,
803
+ dst_path: _t.AnyStr,
804
+ allow_overwrites: bool,
805
+ *,
806
+ makedirs: bool = True,
807
+ dst_dir: _t.AnyStr | None = None,
808
+ ) -> None:
809
+ if dst_dir is None:
810
+ dst_dir, nondot = dirname_dot(dst_path)
811
+ makedirs = makedirs and nondot
812
+
813
+ if makedirs:
814
+ _os.makedirs(dst_dir, exist_ok=True)
815
+
816
+ if allow_overwrites:
817
+ _os.replace(src_path, dst_path)
818
+ elif _POSIX:
819
+ with Directory(dst_dir) as d:
820
+ d.flock()
821
+ # this is now atomic
822
+ if _os.path.lexists(dst_path):
823
+ raise FileExistsError(_errno.EEXIST, _os.strerror(_errno.EEXIST), dst_path)
824
+ _os.rename(src_path, dst_path)
825
+ else:
826
+ # this is both atomic and fails with `FileExistsError` on Windows
827
+ _os.rename(src_path, dst_path)
828
+
829
+
796
830
  def atomic_rename(
797
831
  src_path: _t.AnyStr,
798
832
  dst_path: _t.AnyStr,
@@ -915,6 +949,30 @@ def atomic_make_file(
915
949
  # NB: src_dir == dst_dir
916
950
 
917
951
 
952
+ def atomic_write(
953
+ data: bytes,
954
+ dst_path: _t.AnyStr,
955
+ allow_overwrites: bool = False,
956
+ *,
957
+ makedirs: bool = True,
958
+ sync: DeferredSync[_t.AnyStr] | bool = True,
959
+ ) -> None:
960
+ """Atomically write given `data` to `dst_path`."""
961
+
962
+ def make_dst(tmp_path: _t.AnyStr, fsync_immediately: bool) -> None:
963
+ try:
964
+ with open(tmp_path, "xb") as fdst:
965
+ fdst.write(data)
966
+ fdst.flush()
967
+ if fsync_immediately:
968
+ _os.fsync(fdst.fileno())
969
+ except Exception:
970
+ unlink_maybe(tmp_path)
971
+ raise
972
+
973
+ atomic_make_file(make_dst, dst_path, allow_overwrites, makedirs=makedirs, sync=sync)
974
+
975
+
918
976
  def atomic_copy2(
919
977
  src_path: _t.AnyStr,
920
978
  dst_path: _t.AnyStr,
@@ -1046,60 +1104,12 @@ def atomic_move(
1046
1104
  fsync_file(src_dir, _os.O_DIRECTORY)
1047
1105
 
1048
1106
 
1049
- def atomic_write(
1050
- data: bytes,
1051
- dst_path: _t.AnyStr,
1052
- allow_overwrites: bool = False,
1053
- *,
1054
- makedirs: bool = True,
1055
- sync: DeferredSync[_t.AnyStr] | bool = True,
1056
- ) -> None:
1057
- """Atomically write given `data` to `dst_path`."""
1058
-
1059
- def make_dst(tmp_path: _t.AnyStr, fsync_immediately: bool) -> None:
1060
- try:
1061
- with open(tmp_path, "xb") as fdst:
1062
- fdst.write(data)
1063
- fdst.flush()
1064
- if fsync_immediately:
1065
- _os.fsync(fdst.fileno())
1066
- except Exception:
1067
- unlink_maybe(tmp_path)
1068
- raise
1069
-
1070
- atomic_make_file(make_dst, dst_path, allow_overwrites, makedirs=makedirs, sync=sync)
1071
-
1072
-
1073
- def atomic_unlink(
1074
- path: _t.AnyStr,
1075
- *,
1076
- sync: DeferredSync[_t.AnyStr] | bool = True,
1077
- ) -> None:
1078
- """Atomically unlink `path`."""
1079
-
1080
- dirname, _ = dirname_dot(path)
1081
-
1082
- if isinstance(sync, DeferredSync) and sync.defer:
1083
- sync.unlink_file.add(path)
1084
- sync.fsync_dir.add(dirname)
1085
- return
1086
-
1087
- _os.unlink(path)
1088
-
1089
- if isinstance(sync, DeferredSync):
1090
- sync.fsync_dir.add(dirname)
1091
- return
1092
-
1093
- if sync and _POSIX:
1094
- fsync_file(dirname, _os.O_DIRECTORY)
1095
-
1096
-
1097
1107
  def setup_fs(prog: str | None = None, ext: str = ".part", add_pid: bool = True) -> None:
1098
1108
  """Setup temporary files prefix."""
1099
1109
  if prog is None:
1100
1110
  prog = _op.basename(_sys.argv[0])
1101
1111
 
1102
1112
  global _dot_part, _dot_partb
1103
- pid = f"_{_os.getpid()}" if add_pid else ""
1104
- _dot_part = f".{prog}{pid}{ext}"
1113
+ pid = f"{_os.getpid()}." if add_pid else ""
1114
+ _dot_part = f".{pid}{prog}{ext}"
1105
1115
  _dot_partb = _dot_part.encode("ascii")
@@ -0,0 +1,167 @@
1
+ # Copyright (c) 2018-2025 Jan Malakhovski <oxij@oxij.org>
2
+ #
3
+ # This file is a part of `kisstdlib` project.
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ """Adapt almost any folding function into `IOBase` interface."""
24
+
25
+ import typing as _t
26
+
27
+ from zlib import compressobj as _compressobj, decompressobj as _decompressobj
28
+
29
+ from ..parsing import (
30
+ BytesTransformer as _BytesTransformer,
31
+ LF2CRLF as _LF2CRLF,
32
+ CRLF2LF as _CRLF2LF,
33
+ )
34
+
35
+
36
+ class IOAdapter:
37
+ def __init__(self, fobj: _t.Any) -> None:
38
+ self._fobj = fobj
39
+
40
+ def close(self) -> None:
41
+ self._fobj.close()
42
+
43
+ def flush(self) -> None:
44
+ self._fobj.flush()
45
+
46
+ def __enter__(self) -> _t.Any:
47
+ return self
48
+
49
+ def __exit__(self, exc_type: _t.Any, exc_value: _t.Any, exc_tb: _t.Any) -> None:
50
+ self.close()
51
+
52
+ def fileno(self) -> int:
53
+ return self._fobj.fileno() # type: ignore
54
+
55
+ @property
56
+ def closed(self) -> bool:
57
+ return self._fobj.closed # type: ignore
58
+
59
+
60
+ class ReadAdapter(IOAdapter):
61
+ def __init__(self, fobj: _t.Any, block_size: int) -> None:
62
+ super().__init__(fobj)
63
+ self._block_size = block_size
64
+ self._buffer = b""
65
+ self._eof = False
66
+
67
+ def _handle_eof(self) -> bytes:
68
+ raise NotImplementedError()
69
+
70
+ def _handle_data(self, data: bytes) -> bytes:
71
+ raise NotImplementedError()
72
+
73
+ def read(self, size: int = -1) -> bytes:
74
+ while not self._eof and (size == -1 or len(self._buffer) < size):
75
+ data = self._fobj.read(self._block_size)
76
+ if len(data) == 0:
77
+ self._buffer += self._handle_eof()
78
+ self._eof = True
79
+ else:
80
+ self._buffer += self._handle_data(data)
81
+
82
+ if len(self._buffer) == 0:
83
+ return self._buffer
84
+
85
+ if size == -1 or len(self._buffer) == size:
86
+ res = self._buffer
87
+ self._buffer = b""
88
+ return res
89
+
90
+ res = self._buffer[:size]
91
+ self._buffer = self._buffer[len(res) :]
92
+ return res
93
+
94
+ def tell(self) -> int:
95
+ return self._fobj.tell() # type: ignore
96
+
97
+
98
+ class UpdateFinalizeReader(ReadAdapter):
99
+ def __init__(self, fobj: _t.Any, preprocessor: _t.Any, block_size: int = 4096) -> None:
100
+ super().__init__(fobj, block_size)
101
+ self._preprocessor = preprocessor
102
+
103
+ def _handle_eof(self) -> bytes:
104
+ return self._preprocessor.finalize() # type: ignore
105
+
106
+ def _handle_data(self, data: bytes) -> bytes:
107
+ return self._preprocessor.update(data) # type: ignore
108
+
109
+
110
+ class UpdateFinalizeWriter(IOAdapter):
111
+ def __init__(self, fobj: _t.Any, preprocessor: _t.Any) -> None:
112
+ super().__init__(fobj)
113
+ self._preprocessor = preprocessor
114
+
115
+ def write(self, data: bytes) -> None:
116
+ self._fobj.write(self._preprocessor.update(data))
117
+
118
+ def close(self) -> None:
119
+ if not self.closed:
120
+ self._fobj.write(self._preprocessor.finalize())
121
+ self._fobj.close()
122
+
123
+
124
+ class DOS2UNIXReader(UpdateFinalizeReader):
125
+ def __init__(self, fobj: _t.Any, block_size: int = 4096) -> None:
126
+ super().__init__(fobj, _BytesTransformer(_CRLF2LF), block_size)
127
+
128
+
129
+ class UNIX2DOSReader(UpdateFinalizeReader):
130
+ def __init__(self, fobj: _t.Any, block_size: int = 4096) -> None:
131
+ super().__init__(fobj, _BytesTransformer(_LF2CRLF), block_size)
132
+
133
+
134
+ class DOS2UNIXWriter(UpdateFinalizeWriter):
135
+ def __init__(self, fobj: _t.Any) -> None:
136
+ super().__init__(fobj, _BytesTransformer(_CRLF2LF))
137
+
138
+
139
+ class UNIX2DOSWriter(UpdateFinalizeWriter):
140
+ def __init__(self, fobj: _t.Any) -> None:
141
+ super().__init__(fobj, _BytesTransformer(_LF2CRLF))
142
+
143
+
144
+ class ZlibDecompressor(ReadAdapter):
145
+ def __init__(self, fobj: _t.Any, block_size: int, **kwargs: _t.Any) -> None:
146
+ super().__init__(fobj, block_size)
147
+ self._decompressor = _decompressobj(**kwargs)
148
+
149
+ def _handle_eof(self) -> bytes:
150
+ return self._decompressor.flush()
151
+
152
+ def _handle_data(self, data: bytes) -> bytes:
153
+ return self._decompressor.decompress(data)
154
+
155
+
156
+ class ZlibCompressor(IOAdapter):
157
+ def __init__(self, fobj: _t.Any, **kwargs: _t.Any) -> None:
158
+ super().__init__(fobj)
159
+ self._compressor = _compressobj(**kwargs)
160
+
161
+ def write(self, data: bytes) -> None:
162
+ self._fobj.write(self._compressor.compress(data))
163
+
164
+ def close(self) -> None:
165
+ if not self.closed:
166
+ self._fobj.write(self._compressor.flush())
167
+ self._fobj.close()
@@ -95,7 +95,7 @@ def nth(n: int, iterable: _t.Iterable[_AType], default: _AType | Missing = MISSI
95
95
  for e in iterable:
96
96
  if i == 0:
97
97
  return e
98
- i -= i
98
+ i -= 1
99
99
  if default is MISSING:
100
100
  raise IndexError("not enough elements")
101
101
  return _t.cast(_AType, default)
@@ -20,13 +20,131 @@
20
20
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  # SOFTWARE.
22
22
 
23
- """Parsec-like parser combinator, but using regexes a lot."""
23
+ """Parsing of `str`ings and `bytes`."""
24
24
 
25
25
  import re as _re
26
26
  import typing as _t
27
27
 
28
+ from itertools import islice as _islice
29
+
28
30
  from .failure import ParsingFailure
29
31
 
32
+
33
+ # A state transition function, see `BytesTransformer` below.
34
+ ByteTransitions: _t.TypeAlias = _t.Callable[[int, int], tuple[int, bytes] | None]
35
+
36
+
37
+ def LF2CRLF(_s: int, c: int) -> tuple[int, bytes] | None:
38
+ "Map all LF to CR LF."
39
+ if c == 10:
40
+ # LF
41
+ return 0, b"\r\n"
42
+ return None
43
+
44
+
45
+ def CRLF2LF(s: int, c: int) -> tuple[int, bytes] | None:
46
+ "Map all CR LF sequences to LF."
47
+ if s == 0:
48
+ if c == 13:
49
+ # CR
50
+ return 1, b""
51
+ return None
52
+
53
+ if c == 10:
54
+ # CR LF
55
+ return 0, b"\n"
56
+ if c == 13:
57
+ # CR CR
58
+ return 1, b"\r"
59
+ if c == -1:
60
+ # CR EOF
61
+ return 0, b"\r"
62
+
63
+ # CR other
64
+ return 0, bytes([13, c])
65
+
66
+
67
+ class BytesTransformer:
68
+ """Transform some `bytes` using a given `ByteTransitions` somewhat similarly to
69
+ `bytearray.transform`, but with more expressive power.
70
+
71
+ This class implements the same `update` + `finalize` API as most cryptographic libraries.
72
+
73
+ `ByteTransitions` is a function from old state number (the first `int`) and a char (the second
74
+ `int`) to new state number and generated `bytes` output. The `None` result means "keep the same
75
+ state, output the input byte". The initial state is `0`, EOF is represented by a `-1` byte.
76
+
77
+ See, e.g. `UNIX2DOS*` and `DOS2UNIX*` in `kisstdlib.io.adapter` for example usages.
78
+ """
79
+
80
+ def __init__(self, transitions: ByteTransitions) -> None:
81
+ self._transitions = transitions
82
+ self._stateno = 0
83
+
84
+ def update(self, data: bytes) -> bytes:
85
+ i = 0
86
+ dlen = len(data)
87
+
88
+ stateno = self._stateno
89
+ tf = self._transitions
90
+
91
+ c: int
92
+ t: tuple[int, bytes] | None
93
+
94
+ # skip noops
95
+ while i < dlen:
96
+ c = data[i]
97
+ t = tf(stateno, c)
98
+ if t is None:
99
+ i += 1
100
+ continue
101
+ # nstateno, nout = t
102
+ # if nstateno == stateno and len(nout) == 0 and nout[0] == c:
103
+ # i += 1
104
+ # continue
105
+ break
106
+ else:
107
+ return data
108
+
109
+ if i == dlen:
110
+ # there were only noops
111
+ return data
112
+
113
+ # copy the noop part
114
+ res = bytearray(_islice(data, i))
115
+
116
+ # update on the current char
117
+ stateno, nout = t
118
+ res.extend(nout)
119
+ i += 1
120
+
121
+ # update on the rest
122
+ while i < dlen:
123
+ c = data[i]
124
+ t = tf(stateno, c)
125
+ if t is None:
126
+ res.append(c)
127
+ else:
128
+ stateno, nout = t
129
+ res.extend(nout)
130
+ i += 1
131
+
132
+ self._stateno = stateno
133
+ return bytes(res)
134
+
135
+ def finalize(self) -> bytes:
136
+ t = self._transitions(self._stateno, -1)
137
+ if t is None:
138
+ self._stateno, nout = 0, b""
139
+ else:
140
+ self._stateno, nout = t
141
+
142
+ # ensure it gets reset properly
143
+ assert self._stateno == 0
144
+
145
+ return nout
146
+
147
+
30
148
  word_re = _re.compile(r"(\S+)")
31
149
  natural_re = _re.compile(r"([0-9]+)")
32
150
  integer_re = _re.compile(r"(-?[0-9]+)")
@@ -40,7 +158,7 @@ ParserParamSpec = _t.ParamSpec("ParserParamSpec")
40
158
 
41
159
 
42
160
  class Parser:
43
- """Parser combinator with regexes."""
161
+ """Parsec-like parser combinator with regexes."""
44
162
 
45
163
  def __init__(self, data: str) -> None:
46
164
  self.buffer = data
@@ -340,7 +340,7 @@ class AppDB(_t.Generic[SettingsType]):
340
340
  self.commit()
341
341
 
342
342
  self.logger.debug(
343
- "`%s`: opened at version %d with settings of `%s`",
343
+ "`%s`: finished init at version %d with settings of `%s`",
344
344
  path,
345
345
  self._version,
346
346
  str(self._settings),
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: kisstdlib
3
- Version: 0.0.10
3
+ Version: 0.0.12
4
4
  Summary: System programming with conceptually and algebraically simple primitives.
5
5
  Author-email: Jan Malakhovski <oxij@oxij.org>
6
6
  Project-URL: Homepage, https://oxij.org/software/kisstdlib/
@@ -27,6 +27,7 @@ kisstdlib.egg-info/entry_points.txt
27
27
  kisstdlib.egg-info/requires.txt
28
28
  kisstdlib.egg-info/top_level.txt
29
29
  kisstdlib/io/__init__.py
30
+ kisstdlib/io/adapter.py
30
31
  kisstdlib/io/base.py
31
32
  kisstdlib/io/encoder.py
32
33
  kisstdlib/io/stdio.py
@@ -35,5 +36,6 @@ kisstdlib_bin/describe_forest.py
35
36
  test/test_basics.py
36
37
  test/test_fs.py
37
38
  test/test_io.py
39
+ test/test_parsing.py
38
40
  test/test_sorted.py
39
41
  test/test_time.py
@@ -9,7 +9,7 @@ namespaces = true
9
9
  "kisstdlib" = ["py.typed"]
10
10
  [project]
11
11
  name = "kisstdlib"
12
- version = "0.0.10"
12
+ version = "0.0.12"
13
13
  authors = [{ name = "Jan Malakhovski", email = "oxij@oxij.org" }]
14
14
  description = "System programming with conceptually and algebraically simple primitives."
15
15
  readme = "README.md"
@@ -48,6 +48,7 @@ def test_basics() -> None:
48
48
  assert first([123], None) == first((123,), None) == 123
49
49
  assert last([123], None) == last((123,), None) == 123
50
50
  assert nth(0, [], None) == nth(0, (), None) == None
51
+ assert nth(0, [123], None) == 123
51
52
 
52
53
  e: _t.Any
53
54
  for e in ((), []):
@@ -78,8 +79,8 @@ def test_basics() -> None:
78
79
  for i in range(0, len(l) + 1):
79
80
  assert drop(i, l) == l[i:]
80
81
 
81
- assert nth(0, l, None) == nth(0, t, None) == l[0]
82
- assert nth(1, l, None) == nth(1, t, None) == (l[1] if len(l) > 1 else None)
82
+ for i in range(0, len(l) + 1):
83
+ assert nth(i, l, None) == nth(i, t, None) == (l[i] if i < len(l) else None)
83
84
 
84
85
  l.pop()
85
86
 
@@ -26,9 +26,24 @@ import io as _io
26
26
  import typing as _t
27
27
 
28
28
  from kisstdlib.io.base import *
29
+ from kisstdlib.io.adapter import *
29
30
  from kisstdlib.io.wrapper import *
30
31
 
31
32
 
33
+ def test_adapters() -> None:
34
+ buf = _io.BytesIO()
35
+
36
+ u2d = UNIX2DOSWriter(buf)
37
+ orig = b"Hello, World!\nFoo\rBar\r"
38
+ u2d.write(orig)
39
+ assert buf.getvalue() == b"Hello, World!\r\nFoo\rBar\r"
40
+
41
+ buf.seek(0)
42
+
43
+ d2u = DOS2UNIXReader(buf)
44
+ assert d2u.read() == orig
45
+
46
+
32
47
  def test_ansi_set() -> None:
33
48
  def check(*args: _t.Any, **kwargs: _t.Any) -> bytes:
34
49
  x = TIOWrappedWriter(_io.BytesIO(), ansi=True)
@@ -0,0 +1,49 @@
1
+ # Copyright (c) 2025 Jan Malakhovski <oxij@oxij.org>
2
+ #
3
+ # This file is a part of `kisstdlib` project.
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ """Testing `kisstdlib.parsing` module."""
24
+
25
+ import typing as _t
26
+
27
+ from kisstdlib.parsing import *
28
+
29
+
30
+ def test_BytesTransformer() -> None:
31
+ unix2dos = BytesTransformer(LF2CRLF)
32
+ dos2unix = BytesTransformer(CRLF2LF)
33
+
34
+ def check(a: bytes, b: bytes) -> None:
35
+ assert unix2dos.update(a) == b
36
+ assert dos2unix.update(b) == a
37
+
38
+ check(b"", b"")
39
+ check(b"\n", b"\r\n")
40
+ check(b"foo\n", b"foo\r\n")
41
+ check(b"foo\nbar", b"foo\r\nbar")
42
+ check(b"foo\r\n", b"foo\r\r\n")
43
+ check(b"foo\r\nbar", b"foo\r\r\nbar")
44
+
45
+ assert dos2unix.update(b"foo\r") == b"foo"
46
+ assert dos2unix.update(b"bar") == b"\rbar"
47
+
48
+ assert dos2unix.update(b"foo\r") == b"foo"
49
+ assert dos2unix.update(b"\n") == b"\n"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes