init.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import datetime
  2. import functools
  3. import itertools
  4. import os
  5. import pathlib
  6. import typing
  7. import warnings
  8. import dateutil.parser
  9. import exifread
  10. import numpy
  11. import pandas
  12. import pgpdump
  13. import pyperclip
  14. import scipy.io.wavfile
  15. import sympy
  16. import yaml
  17. from matplotlib import pyplot # pylint: disable=unused-import; frequently used in shell
  18. # https://pandas.pydata.org/pandas-docs/stable/user_guide/options.html
  19. pandas.options.display.max_rows = 200
  20. if os.environ.get("WAYLAND_DISPLAY"):
  21. # with default "gi" in python3-pyperclip=1.8.2-2 & python3-gi=3.42.2-3+b1
  22. # pyperclip.paste() always returned empty string
  23. pyperclip.set_clipboard("wl-clipboard")
  24. # https://docs.sympy.org/latest/modules/interactive.html#module-sympy.interactive.printing
  25. sympy.init_printing(pretty_print=True)
  26. def join_pgp_packets(
  27. packets: typing.Iterator[typing.Union[bytearray, pgpdump.packet.Packet]],
  28. ) -> bytes:
  29. return b"".join(
  30. p.data if isinstance(p, pgpdump.packet.Packet) else p for p in packets
  31. )
  32. def numpy_array_from_file(
  33. path: typing.Union[str, pathlib.Path], dtype
  34. ) -> numpy.ndarray:
  35. if isinstance(path, str):
  36. path = pathlib.Path(path)
  37. return numpy.frombuffer(path.read_bytes(), dtype=dtype)
  38. def read_exif_datetime_original(path: str) -> typing.Optional[datetime.datetime]:
  39. with pathlib.Path(path).open("rb") as file:
  40. tags = exifread.process_file(file)
  41. if "EXIF DateTimeOriginal" not in tags:
  42. return None
  43. return dateutil.parser.parse(
  44. # https://web.archive.org/web/20240609164044/https://github.com/dateutil/dateutil/issues/271
  45. datetime.datetime.strptime(
  46. tags["EXIF DateTimeOriginal"].values, "%Y:%m:%d %H:%M:%S"
  47. ).isoformat()
  48. + (
  49. "." + tags["EXIF SubSecTimeOriginal"].values
  50. if "EXIF SubSecTimeOriginal" in tags
  51. else ""
  52. )
  53. + (
  54. tags["EXIF OffsetTimeOriginal"].values
  55. if "EXIF OffsetTimeOriginal" in tags
  56. else ""
  57. )
  58. )
  59. def split_pgp_file(
  60. path: pathlib.Path,
  61. ) -> typing.Iterator[typing.Union[bytearray, pgpdump.packet.Packet]]:
  62. """
  63. https://datatracker.ietf.org/doc/html/rfc4880#section-4
  64. """
  65. bundle_bytes = path.read_bytes()
  66. if bundle_bytes.startswith(b"-----BEGIN"):
  67. bundle = pgpdump.AsciiData(bundle_bytes)
  68. else:
  69. bundle = pgpdump.BinaryData(bundle_bytes)
  70. remaining_bytes = bundle.data
  71. for packet in bundle.packets():
  72. try:
  73. prefix, remaining_bytes = remaining_bytes.split(packet.data, maxsplit=1)
  74. except ValueError:
  75. assert len(packet.data) > 596 # actual threshold might be higher
  76. split_index = 2**9
  77. prefix, remaining_bytes = remaining_bytes.split(
  78. packet.data[:split_index], maxsplit=1
  79. )
  80. separator, remaining_bytes = remaining_bytes.split(
  81. packet.data[split_index:], maxsplit=1
  82. )
  83. assert sum(separator) == len(packet.data) - split_index
  84. warnings.warn(
  85. "ignoring separator; output of join_pgp_packets will be invalid"
  86. )
  87. yield prefix
  88. yield packet
  89. assert not remaining_bytes
  90. def split_sequence_by_delimiter(
  91. sequence: typing.Sequence, delimiter: typing.Any, delimiter_min_length: int = 1
  92. ) -> typing.Iterator[typing.Sequence]:
  93. slice_start_index, slice_length = 0, 0
  94. for is_delimiter, group in itertools.groupby(
  95. sequence, key=lambda item: item == delimiter
  96. ):
  97. group_length = sum(1 for _ in group)
  98. if is_delimiter and group_length >= delimiter_min_length:
  99. if slice_length > 0:
  100. yield sequence[slice_start_index : slice_start_index + slice_length]
  101. slice_start_index += slice_length + group_length
  102. slice_length = 0
  103. else:
  104. slice_length += group_length
  105. if slice_length > 0:
  106. yield sequence[slice_start_index : slice_start_index + slice_length]
  107. def trim_where(
  108. # https://docs.python.org/3.8/library/collections.abc.html#collections-abstract-base-classes
  109. sequence: typing.Sequence,
  110. condition: typing.Sequence[bool],
  111. ) -> typing.Sequence:
  112. start = 0
  113. for item_condition in condition:
  114. if item_condition:
  115. start += 1
  116. else:
  117. break
  118. stop = len(sequence)
  119. assert stop == len(condition)
  120. for item_condition in condition[::-1]:
  121. if item_condition:
  122. stop -= 1
  123. else:
  124. break
  125. return sequence[start:stop]
  126. def wavfile_read_mono(
  127. path: typing.Union[pathlib.Path, str]
  128. ) -> typing.Tuple[int, numpy.ndarray]:
  129. # https://docs.scipy.org/doc/scipy/reference/generated/scipy.io.wavfile.read.html
  130. rate, data = scipy.io.wavfile.read(path)
  131. if len(data.shape) == 1:
  132. return rate, data
  133. data_first_channel = data[:, 0]
  134. for channel_index in range(1, data.shape[1]):
  135. assert (data_first_channel == data[:, channel_index]).all()
  136. return rate, data_first_channel
  137. def yaml_dump(path: typing.Union[pathlib.Path, str], data: typing.Any) -> None:
  138. with pathlib.Path(path).open("w") as stream:
  139. yaml.safe_dump(data, stream)
  140. def yaml_load(path: typing.Union[pathlib.Path, str]) -> typing.Any:
  141. with pathlib.Path(path).open("r") as stream:
  142. return yaml.safe_load(stream)
  143. class Pipe:
  144. def __init__(self, function: typing.Callable[[typing.Any], typing.Any]) -> None:
  145. self._function = function
  146. def __ror__(self, other: typing.Iterable) -> typing.Any:
  147. return self._function(other)
  148. class PipeMap(Pipe):
  149. @classmethod
  150. def _partial_map(
  151. cls, function: typing.Callable[[typing.Any], typing.Any], *, axis: int
  152. ) -> typing.Callable[[typing.Any], typing.Any]:
  153. if axis <= 0:
  154. return functools.partial(map, function)
  155. return functools.partial(map, cls._partial_map(function, axis=axis - 1))
  156. @staticmethod
  157. def _catch(
  158. *, function: typing.Callable[[typing.Any], typing.Any], arg: typing.Any
  159. ) -> typing.Any | Exception:
  160. try:
  161. return function(arg)
  162. except Exception as exc:
  163. return exc
  164. def __init__(
  165. self,
  166. function: typing.Callable[[typing.Any], typing.Any],
  167. axis: int = 0,
  168. catch: bool = False,
  169. ) -> None:
  170. self._function = self._partial_map(
  171. (lambda v: self._catch(function=function, arg=v)) if catch else function,
  172. axis=axis,
  173. )
  174. assert list(PipeMap._partial_map(str, axis=0)(range(3))) == ["0", "1", "2"]
  175. assert [tuple(r) for r in PipeMap._partial_map(str, axis=1)((range(2), range(3)))] == [
  176. ("0", "1"),
  177. ("0", "1", "2"),
  178. ]
  179. assert range(65, 68) | PipeMap(chr) | PipeMap(str.lower) | Pipe(list) == ["a", "b", "c"]
  180. assert range(2, 4) | PipeMap(range) | PipeMap(lambda n: n**3, axis=1) | PipeMap(
  181. tuple
  182. ) | Pipe(list) == [(0, 1), (0, 1, 8)]
  183. assert "123\n456\n789".splitlines() | PipeMap(list) | PipeMap(int, axis=1) | PipeMap(
  184. tuple
  185. ) | Pipe(list) == [(1, 2, 3), (4, 5, 6), (7, 8, 9)]
  186. assert "123|456\n98|76|54".splitlines() | PipeMap(lambda s: s.split("|")) | PipeMap(
  187. list, axis=1
  188. ) | PipeMap(int, axis=2) | PipeMap(tuple, axis=1) | PipeMap(tuple) | Pipe(list) == [
  189. ((1, 2, 3), (4, 5, 6)),
  190. ((9, 8), (7, 6), (5, 4)),
  191. ]
  192. assert [1, 0, 2] | PipeMap(lambda d: 42 // d, catch=True) | PipeMap(
  193. lambda r: r.args if isinstance(r, Exception) else r
  194. ) | Pipe(list) == [42, ("integer division or modulo by zero",), 21]
  195. class PipeFilter(Pipe):
  196. def __init__(
  197. self, filter_function: typing.Union[typing.Callable[[typing.Any], bool], None]
  198. ) -> None:
  199. self._function = functools.partial(filter, filter_function)
  200. assert range(5) | PipeFilter(lambda n: n % 2 == 0) | Pipe(list) == [0, 2, 4]
  201. assert [0, True, 2.3, "4", (5, 6)] | PipeFilter(
  202. lambda e: isinstance(e, (bool, tuple))
  203. ) | Pipe(list) == [True, (5, 6)]
  204. class PipePair(PipeMap):
  205. @staticmethod
  206. def _catch(
  207. *, function: typing.Callable[[typing.Any], typing.Any], arg: typing.Any
  208. ) -> typing.Tuple[typing.Any, typing.Any | Exception]:
  209. try:
  210. return function(arg)
  211. except Exception as exc:
  212. return (arg, exc)
  213. def __init__(
  214. self, function: typing.Callable[[typing.Any], typing.Any], **kwargs
  215. ) -> None:
  216. super().__init__(function=lambda a: (a, function(a)), **kwargs)
  217. assert range(65, 68) | PipePair(chr) | Pipe(list) == [
  218. (65, "A"),
  219. (66, "B"),
  220. (67, "C"),
  221. ]
  222. assert range(2, 4) | PipeMap(range) | PipePair(lambda n: n**3, axis=1) | PipeMap(
  223. set
  224. ) | Pipe(list) == [{(0, 0), (1, 1)}, {(0, 0), (1, 1), (2, 8)}]
  225. assert [1, 0, 2] | PipePair(lambda d: 42 // d, catch=True) | PipeMap(
  226. lambda r: (r[0], r[1].args) if isinstance(r[1], Exception) else r
  227. ) | Pipe(list) == [(1, 42), (0, ("integer division or modulo by zero",)), (2, 21)]