pythonwrench 0.6.4__py3-none-any.whl

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 (52) hide show
  1. pythonwrench/__init__.py +490 -0
  2. pythonwrench/__main__.py +7 -0
  3. pythonwrench/_core.py +192 -0
  4. pythonwrench/abc.py +30 -0
  5. pythonwrench/argparse/__init__.py +81 -0
  6. pythonwrench/argparse/dataclass_.py +284 -0
  7. pythonwrench/argparse/parsers.py +619 -0
  8. pythonwrench/cast.py +247 -0
  9. pythonwrench/checksum.py +427 -0
  10. pythonwrench/collections/__init__.py +104 -0
  11. pythonwrench/collections/collections.py +900 -0
  12. pythonwrench/collections/prop.py +104 -0
  13. pythonwrench/collections/reducers.py +330 -0
  14. pythonwrench/concurrent.py +73 -0
  15. pythonwrench/csv.py +12 -0
  16. pythonwrench/dataclasses.py +117 -0
  17. pythonwrench/datetime.py +17 -0
  18. pythonwrench/difflib.py +39 -0
  19. pythonwrench/disk_cache.py +615 -0
  20. pythonwrench/entrypoints/info.py +44 -0
  21. pythonwrench/entrypoints/safe_rmdir.py +98 -0
  22. pythonwrench/entrypoints/tree.py +113 -0
  23. pythonwrench/enum.py +55 -0
  24. pythonwrench/functools.py +234 -0
  25. pythonwrench/hashlib.py +95 -0
  26. pythonwrench/importlib.py +243 -0
  27. pythonwrench/inspect.py +69 -0
  28. pythonwrench/json.py +12 -0
  29. pythonwrench/jsonl.py +12 -0
  30. pythonwrench/logging.py +252 -0
  31. pythonwrench/math.py +107 -0
  32. pythonwrench/os.py +226 -0
  33. pythonwrench/pickle.py +12 -0
  34. pythonwrench/random.py +60 -0
  35. pythonwrench/re.py +139 -0
  36. pythonwrench/semver.py +406 -0
  37. pythonwrench/serialization/__init__.py +70 -0
  38. pythonwrench/serialization/_core.py +70 -0
  39. pythonwrench/serialization/csv.py +493 -0
  40. pythonwrench/serialization/json.py +178 -0
  41. pythonwrench/serialization/jsonl.py +215 -0
  42. pythonwrench/serialization/pickle.py +186 -0
  43. pythonwrench/time.py +34 -0
  44. pythonwrench/typing/__init__.py +125 -0
  45. pythonwrench/typing/checks.py +551 -0
  46. pythonwrench/typing/classes.py +251 -0
  47. pythonwrench/warnings.py +118 -0
  48. pythonwrench-0.6.4.dist-info/METADATA +242 -0
  49. pythonwrench-0.6.4.dist-info/RECORD +52 -0
  50. pythonwrench-0.6.4.dist-info/WHEEL +4 -0
  51. pythonwrench-0.6.4.dist-info/entry_points.txt +10 -0
  52. pythonwrench-0.6.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import logging
5
+ from argparse import ArgumentParser
6
+ from pathlib import Path
7
+ from typing import Union
8
+
9
+ from pythonwrench.argparse import str_to_bool
10
+ from pythonwrench.os import safe_rmdir
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def print_safe_rmdir(
16
+ root: Union[str, Path],
17
+ *,
18
+ rm_root: bool = True,
19
+ error_on_non_empty_dir: bool = True,
20
+ followlinks: bool = False,
21
+ dry_run: bool = False,
22
+ verbose: int = 0,
23
+ ) -> None:
24
+ """Perform the print safe rmdir operation."""
25
+ deleted, reviewed = safe_rmdir(
26
+ root=root,
27
+ rm_root=rm_root,
28
+ error_on_non_empty_dir=error_on_non_empty_dir,
29
+ followlinks=followlinks,
30
+ dry_run=dry_run,
31
+ verbose=verbose,
32
+ )
33
+ if dry_run:
34
+ msg = f"Dry run mode enabled. Here is the list of directories to delete ({len(deleted)}/{len(reviewed)}):"
35
+ print(msg)
36
+ for path in deleted:
37
+ print(f" - {path}")
38
+
39
+ elif verbose >= 1:
40
+ print(f"{len(deleted)} directories has been deleted.")
41
+
42
+
43
+ def main_safe_rmdir() -> None:
44
+ """Perform the main safe rmdir operation."""
45
+ parser = ArgumentParser()
46
+ parser.add_argument(
47
+ "root",
48
+ type=str,
49
+ help="Root directory path.",
50
+ )
51
+ parser.add_argument(
52
+ "--rm_root",
53
+ "--rm-root",
54
+ type=str_to_bool,
55
+ default=True,
56
+ help="If True, remove the root directory too if it is empty at the end. defaults to True.",
57
+ )
58
+ parser.add_argument(
59
+ "--error_on_non_empty_dir",
60
+ "--error-on-non-empty-dir",
61
+ type=str_to_bool,
62
+ default=True,
63
+ help="If True, raises a RuntimeError if a subdirectory contains at least 1 file. Otherwise it will ignore non-empty directories. defaults to True.",
64
+ )
65
+ parser.add_argument(
66
+ "--followlinks",
67
+ "--follow_links",
68
+ "--follow-links",
69
+ type=str_to_bool,
70
+ default=False,
71
+ help="Indicates whether or not symbolic links shound be followed. defaults to False.",
72
+ )
73
+ parser.add_argument(
74
+ "--dry_run",
75
+ "--dry-run",
76
+ type=str_to_bool,
77
+ default=False,
78
+ help="If True, does not remove any directory and just output the list of directories which could be deleted. defaults to False.",
79
+ )
80
+ parser.add_argument(
81
+ "--verbose",
82
+ type=int,
83
+ default=0,
84
+ help="Verbose level. defaults to 0.",
85
+ )
86
+ args = parser.parse_args()
87
+ print_safe_rmdir(
88
+ root=args.root,
89
+ rm_root=args.rm_root,
90
+ error_on_non_empty_dir=args.error_on_non_empty_dir,
91
+ followlinks=args.followlinks,
92
+ dry_run=args.dry_run,
93
+ verbose=args.verbose,
94
+ )
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main_safe_rmdir()
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import logging
5
+ import sys
6
+ from argparse import ArgumentParser
7
+ from pathlib import Path
8
+ from typing import Union
9
+
10
+ from pythonwrench.argparse import str_to_bool
11
+ from pythonwrench.os import tree_iter
12
+ from pythonwrench.re import PatternListLike
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def main_tree() -> None:
18
+ """Perform the main tree operation."""
19
+ parser = ArgumentParser()
20
+ parser.add_argument(
21
+ "root",
22
+ type=str,
23
+ help="Root directory path.",
24
+ default=".",
25
+ nargs="?", # for optional positional argument
26
+ )
27
+ parser.add_argument(
28
+ "--include",
29
+ type=str,
30
+ help="Include file/dir patterns.",
31
+ default=".*",
32
+ nargs="*",
33
+ )
34
+ parser.add_argument(
35
+ "--exclude",
36
+ type=str,
37
+ help="Exclude file/dir patterns.",
38
+ default=(),
39
+ nargs="*",
40
+ )
41
+ parser.add_argument(
42
+ "--max_depth",
43
+ type=int,
44
+ help="Max directory tree depth.",
45
+ default=sys.maxsize,
46
+ )
47
+ parser.add_argument(
48
+ "--followlinks",
49
+ type=str_to_bool,
50
+ help="Indicates whether or not symbolic links should be followed. defaults to True.",
51
+ default=True,
52
+ )
53
+ parser.add_argument(
54
+ "--skipfiles",
55
+ type=str_to_bool,
56
+ help="Indicates whether or not symbolic files should be shown. defaults to False.",
57
+ default=False,
58
+ )
59
+ parser.add_argument(
60
+ "--sort",
61
+ type=str_to_bool,
62
+ help="Sort element by name. defaults to False.",
63
+ default=False,
64
+ )
65
+ args = parser.parse_args()
66
+
67
+ print_tree(
68
+ root=args.root,
69
+ include=args.include,
70
+ exclude=args.exclude,
71
+ max_depth=args.max_depth,
72
+ followlinks=args.followlinks,
73
+ skipfiles=args.skipfiles,
74
+ sort=args.sort,
75
+ )
76
+
77
+
78
+ def print_tree(
79
+ root: Union[str, Path],
80
+ *,
81
+ include: PatternListLike = ".*",
82
+ exclude: PatternListLike = (),
83
+ max_depth: int = sys.maxsize,
84
+ followlinks: bool = False,
85
+ skipfiles: bool = False,
86
+ sort: bool = False,
87
+ ) -> None:
88
+ """Print directory tree to stdout."""
89
+ num_dirs = 0
90
+ num_files = 0
91
+
92
+ iterable = tree_iter(
93
+ root=root,
94
+ include=include,
95
+ exclude=exclude,
96
+ max_depth=max_depth,
97
+ followlinks=followlinks,
98
+ skipfiles=skipfiles,
99
+ sort=sort,
100
+ )
101
+ for line in iterable:
102
+ print(f"{line}")
103
+
104
+ if line.endswith("/"):
105
+ num_dirs += 1
106
+ else:
107
+ num_files += 1
108
+
109
+ print(f"\n{num_dirs} directories, {num_files} files")
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main_tree()
pythonwrench/enum.py ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from enum import Enum
5
+
6
+ from typing_extensions import Self
7
+
8
+
9
+ class StrEnum(str, Enum):
10
+ """StrEnum is the same as Enum, but its members are also strings and can be used in most of the same places that a string can be used.
11
+
12
+ Note: when used as keys of dicts, enums are considered different from strings keys.
13
+
14
+ This class has the same objective than https://docs.python.org/3/library/enum.html#enum.StrEnum, which was introduced in Python 3.11.
15
+ """
16
+
17
+ @classmethod
18
+ def from_str(
19
+ cls,
20
+ value: str,
21
+ case_sensitive: bool = False,
22
+ ) -> Self:
23
+ """Create an instance from str."""
24
+ members = cls.__members__.keys()
25
+ for member in members:
26
+ if member == value or (
27
+ not case_sensitive and member.lower() == value.lower()
28
+ ):
29
+ return cls[member]
30
+
31
+ msg = f"Invalid argument {value=}. (expected one of {tuple(members)})"
32
+ raise ValueError(msg)
33
+
34
+ @staticmethod
35
+ def _generate_next_value_(name, start, count, last_values) -> str:
36
+ """Perform the generate next value operation."""
37
+ return name
38
+
39
+ @property
40
+ def value(self) -> str:
41
+ """Perform the value operation."""
42
+ return self._value_
43
+
44
+ def __eq__(self, other: object) -> bool:
45
+ """Return whether this instance equals another object."""
46
+ other = other.value if isinstance(other, Enum) else str(other)
47
+ return self.value == other # type: ignore
48
+
49
+ def __hash__(self) -> int:
50
+ """Return the hash of the instance."""
51
+ return hash(self.value)
52
+
53
+ def __str__(self) -> str:
54
+ """Return the string representation of the instance."""
55
+ return self.name
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import inspect
5
+ from types import CodeType
6
+ from typing import (
7
+ Any,
8
+ Callable,
9
+ Generic,
10
+ Iterable,
11
+ Optional,
12
+ Tuple,
13
+ TypeVar,
14
+ overload,
15
+ )
16
+
17
+ from typing_extensions import ParamSpec
18
+
19
+ from pythonwrench._core import T_Function, _decorator_factory, return_none # noqa: F401
20
+ from pythonwrench.inspect import get_argnames, get_fullname
21
+ from pythonwrench.typing import isinstance_generic
22
+
23
+ T = TypeVar("T")
24
+ U = TypeVar("U")
25
+ P = ParamSpec("P")
26
+
27
+
28
+ class Compose(Generic[T, U]):
29
+ """Compose callables to chain calls sequentially."""
30
+
31
+ @overload
32
+ def __init__(self) -> None:
33
+ """Initialize the instance."""
34
+ ...
35
+
36
+ @overload
37
+ def __init__(
38
+ self,
39
+ fn0: Iterable[Callable[[T], T]],
40
+ /,
41
+ ) -> None:
42
+ """Initialize the instance."""
43
+ ...
44
+
45
+ @overload
46
+ def __init__(
47
+ self,
48
+ fn0: Callable[[T], U],
49
+ /,
50
+ ) -> None:
51
+ """Initialize the instance."""
52
+ ...
53
+
54
+ @overload
55
+ def __init__(
56
+ self,
57
+ fn0: Callable[[T], Any],
58
+ fn1: Callable[[Any], U],
59
+ /,
60
+ ) -> None:
61
+ """Initialize the instance."""
62
+ ...
63
+
64
+ @overload
65
+ def __init__(
66
+ self,
67
+ fn0: Callable[[T], Any],
68
+ fn1: Callable[[Any], Any],
69
+ fn2: Callable[[Any], U],
70
+ /,
71
+ ) -> None:
72
+ """Initialize the instance."""
73
+ ...
74
+
75
+ @overload
76
+ def __init__(
77
+ self,
78
+ fn0: Callable[[T], Any],
79
+ fn1: Callable[[Any], Any],
80
+ fn2: Callable[[Any], Any],
81
+ fn3: Callable[[Any], U],
82
+ /,
83
+ ) -> None:
84
+ """Initialize the instance."""
85
+ ...
86
+
87
+ @overload
88
+ def __init__(
89
+ self,
90
+ fn0: Callable[[T], Any],
91
+ fn1: Callable[[Any], Any],
92
+ fn2: Callable[[Any], Any],
93
+ fn3: Callable[[Any], Any],
94
+ fn4: Callable[[Any], U],
95
+ /,
96
+ ) -> None:
97
+ """Initialize the instance."""
98
+ ...
99
+
100
+ @overload
101
+ def __init__(self, *fns: Callable) -> None:
102
+ """Initialize the instance."""
103
+ ...
104
+
105
+ def __init__(self, *fns) -> None:
106
+ """Initialize the instance."""
107
+ if isinstance_generic(fns, Tuple[Iterable[Callable]]):
108
+ fns = fns[0]
109
+ elif isinstance_generic(fns, Tuple[Callable, ...]):
110
+ pass
111
+ else:
112
+ msg = f"Invalid argument types {type(fns)=}. (with {fns=})"
113
+ raise TypeError(msg)
114
+
115
+ super().__init__()
116
+ self.fns = fns
117
+
118
+ def __call__(self, x: T) -> U:
119
+ """Call the instance."""
120
+ for fn in self.fns:
121
+ x = fn(x)
122
+ return x # type: ignore
123
+
124
+ def __getitem__(self, idx: int, /) -> Callable[[Any], Any]:
125
+ """Return the item at the requested index or key."""
126
+ return self.fns[idx]
127
+
128
+ def __len__(self) -> int:
129
+ """Return the number of items in the instance."""
130
+ return len(self.fns)
131
+
132
+
133
+ compose = Compose # type: ignore
134
+
135
+
136
+ def filter_and_call(
137
+ fn: Callable[..., T],
138
+ _fill_all_arguments: bool = False,
139
+ **kwargs: Any,
140
+ ) -> T:
141
+ """Call object only with the valid keyword arguments. Non-valid arguments are ignored.
142
+
143
+ Arguments:
144
+ fn: Callable to call.
145
+ _fill_all_arguments: If True, all arguments of fn must be provided in kwargs. defaults to False.
146
+ **kwargs: Superset of arguments to pass to fn. Name that does not match any argument of fn are ignored.
147
+
148
+ Examples:
149
+ ---------
150
+ >>> def f(x, y):
151
+ >>> return x + y
152
+ >>> filter_and_call(f, y=2, x=1)
153
+ ... 3
154
+ >>> filter_and_call(f, y=2, x=1, z=0) # z is ignored
155
+ ... 3
156
+ """
157
+ argnames = get_argnames(fn)
158
+ code, _start = _get_code_and_start(fn)
159
+
160
+ if "_fill_all_arguments" in argnames:
161
+ msg = f"Invalid argument {get_fullname(fn)}, because it has argument '_fill_all_arguments'."
162
+ raise RuntimeError(msg)
163
+
164
+ if _fill_all_arguments:
165
+ missing = set(argnames).difference(kwargs)
166
+ if len(missing) > 0:
167
+ msg = f"Missing {len(missing)}/{len(argnames)} arguments: {tuple(missing)}. (with {_fill_all_arguments=})"
168
+ raise ValueError(msg)
169
+
170
+ pos_argnames = argnames[: code.co_posonlyargcount]
171
+ other_argnames = argnames[code.co_posonlyargcount :]
172
+
173
+ posonly_args = {
174
+ name: value for name, value in kwargs.items() if name in pos_argnames
175
+ }
176
+ other_kwds = {
177
+ name: value for name, value in kwargs.items() if name in other_argnames
178
+ }
179
+ result = fn(*posonly_args.values(), **other_kwds)
180
+ return result
181
+
182
+
183
+ def function_alias(
184
+ alternative: T_Function,
185
+ *,
186
+ pre_fn: Optional[Callable[..., Any]] = None,
187
+ post_fn: Optional[Callable[..., Any]] = None,
188
+ ) -> Callable[..., T_Function]:
189
+ """Decorator to wrap function aliases.
190
+
191
+ Unlike setting directly an alias, this preserve the nature of the alias for language server.
192
+
193
+ Example
194
+ -------
195
+ >>> def f(a: int, b: str) -> str:
196
+ >>> return a * b
197
+ >>> @function_alias(f)
198
+ >>> def g(*args, **kwargs): ...
199
+ >>> f(2, "a")
200
+ ... "aa"
201
+ >>> g(3, "b") # calls function f() internally.
202
+ ... "bbb"
203
+
204
+ """
205
+ return _decorator_factory(alternative, pre_fn=pre_fn, post_fn=post_fn)
206
+
207
+
208
+ def identity(x: T, **kwargs) -> T:
209
+ """Identity function placeholder. Returns the first argument. Other keywords arguments are ignored."""
210
+ return x
211
+
212
+
213
+ def repeat_fn(f: Callable[[T], T], n: int) -> Callable[[T], T]:
214
+ """Creates wrapper which call a function n items."""
215
+ return Compose([f] * n)
216
+
217
+
218
+ def _get_code_and_start(fn: Callable) -> Tuple[CodeType, int]:
219
+ """Perform the get code and start operation."""
220
+ if inspect.isfunction(fn):
221
+ code = fn.__code__
222
+ start = 0
223
+ elif inspect.ismethod(fn):
224
+ code = fn.__code__
225
+ start = 1 # If method, remove 'self' arg
226
+ elif inspect.isclass(fn):
227
+ # If init, remove 'self' arg
228
+ code = fn.__init__.__code__
229
+ start = 1 # If init, remove 'self' arg
230
+ else:
231
+ code = fn.__call__.__code__
232
+ start = 0
233
+
234
+ return code, start
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import hashlib
5
+ import logging
6
+ from io import BufferedReader
7
+ from pathlib import Path
8
+ from typing import Literal, Optional, Protocol, Union, get_args, runtime_checkable
9
+
10
+ from typing_extensions import Buffer
11
+
12
+ HasherName = Literal["sha256", "md5"]
13
+ HashName = HasherName # alias
14
+
15
+ DEFAULT_CHUNK_SIZE = 256 * 1024**2 # 256 MiB
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @runtime_checkable
21
+ class Hasher(Protocol):
22
+ """Hasher protocol class."""
23
+
24
+ @property
25
+ def digest_size(self) -> int: ...
26
+ @property
27
+ def block_size(self) -> int: ...
28
+ @property
29
+ def name(self) -> str: ...
30
+
31
+ def digest(self) -> bytes:
32
+ """Perform the digest operation."""
33
+ ...
34
+
35
+ def update(self, data: Buffer, /) -> None:
36
+ """Perform the update operation."""
37
+ ...
38
+
39
+
40
+ def hash_file(
41
+ fpath: Union[str, Path, BufferedReader],
42
+ hash_type: Union[HasherName, Hasher] = "md5",
43
+ chunk_size: Optional[int] = DEFAULT_CHUNK_SIZE,
44
+ *,
45
+ init_bytes: bytes = b"",
46
+ ) -> str:
47
+ """Return the hash value for a file.
48
+
49
+ Based on https://github.com/pytorch/audio/blob/v0.13.0/torchaudio/datasets/utils.py#L110
50
+
51
+ Args:
52
+ fpath: Path to existing file.
53
+ hash_type: Hash name or custom Hasher algorithm.
54
+ chunk_size: Max chunk size in bytes. defaults to 268435456 (256 MiB).
55
+
56
+ Returns:
57
+ Hash value as string.
58
+ """
59
+ if isinstance(fpath, (str, Path)):
60
+ with open(fpath, "rb") as file:
61
+ return hash_file(file, hash_type, chunk_size)
62
+ else:
63
+ file = fpath
64
+ del fpath
65
+
66
+ if isinstance(hash_type, str):
67
+ hasher = _get_hasher(hash_type, init_bytes)
68
+ elif isinstance(hash_type, Hasher):
69
+ hasher = hash_type
70
+ else:
71
+ msg = f"Invalid argument {hash_type=}. (expected one of {get_args(HasherName)} or custom Hasher type)"
72
+ raise ValueError(msg)
73
+ del hash_type
74
+
75
+ while True:
76
+ chunk = file.read(chunk_size)
77
+ if not chunk:
78
+ break
79
+ hasher.update(chunk)
80
+
81
+ hash_bytes = hasher.digest()
82
+ return hash_bytes.hex()
83
+
84
+
85
+ def _get_hasher(hasher_name: HasherName, init_bytes: bytes = b"") -> Hasher:
86
+ if hasher_name == "sha256":
87
+ hasher = hashlib.sha256(init_bytes)
88
+ elif hasher_name == "md5":
89
+ hasher = hashlib.md5(init_bytes)
90
+ else:
91
+ msg = (
92
+ f"Invalid argument {hasher_name=}. (expected one of {get_args(HasherName)})"
93
+ )
94
+ raise ValueError(msg)
95
+ return hasher