dycw-utilities 0.174.7__tar.gz → 0.174.9__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 (103) hide show
  1. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/PKG-INFO +1 -1
  2. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/pyproject.toml +3 -3
  3. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/__init__.py +1 -1
  4. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/hypothesis.py +34 -0
  5. dycw_utilities-0.174.9/src/utilities/permissions.py +297 -0
  6. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/subprocess.py +109 -14
  7. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/README.md +0 -0
  8. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/aeventkit.py +0 -0
  9. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/altair.py +0 -0
  10. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/asyncio.py +0 -0
  11. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/atomicwrites.py +0 -0
  12. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/atools.py +0 -0
  13. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/cachetools.py +0 -0
  14. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/click.py +0 -0
  15. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/concurrent.py +0 -0
  16. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/contextlib.py +0 -0
  17. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/contextvars.py +0 -0
  18. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/cryptography.py +0 -0
  19. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/cvxpy.py +0 -0
  20. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/dataclasses.py +0 -0
  21. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/docker.py +0 -0
  22. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/enum.py +0 -0
  23. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/errors.py +0 -0
  24. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/fastapi.py +0 -0
  25. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/fpdf2.py +0 -0
  26. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/functions.py +0 -0
  27. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/functools.py +0 -0
  28. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/getpass.py +0 -0
  29. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/git.py +0 -0
  30. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/grp.py +0 -0
  31. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/gzip.py +0 -0
  32. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/hashlib.py +0 -0
  33. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/http.py +0 -0
  34. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/importlib.py +0 -0
  35. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/inflect.py +0 -0
  36. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/ipython.py +0 -0
  37. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/iterables.py +0 -0
  38. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/jinja2.py +0 -0
  39. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/json.py +0 -0
  40. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/jupyter.py +0 -0
  41. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/libcst.py +0 -0
  42. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/lightweight_charts.py +0 -0
  43. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/logging.py +0 -0
  44. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/math.py +0 -0
  45. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/memory_profiler.py +0 -0
  46. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/modules.py +0 -0
  47. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/more_itertools.py +0 -0
  48. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/numpy.py +0 -0
  49. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/operator.py +0 -0
  50. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/optuna.py +0 -0
  51. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/orjson.py +0 -0
  52. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/os.py +0 -0
  53. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/parse.py +0 -0
  54. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pathlib.py +0 -0
  55. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pickle.py +0 -0
  56. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/platform.py +0 -0
  57. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/polars.py +0 -0
  58. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/polars_ols.py +0 -0
  59. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/postgres.py +0 -0
  60. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pottery.py +0 -0
  61. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pqdm.py +0 -0
  62. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/psutil.py +0 -0
  63. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pwd.py +0 -0
  64. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/py.typed +0 -0
  65. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pydantic.py +0 -0
  66. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pydantic_settings.py +0 -0
  67. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pydantic_settings_sops.py +0 -0
  68. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pyinstrument.py +0 -0
  69. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pytest.py +0 -0
  70. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pytest_plugins/__init__.py +0 -0
  71. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pytest_plugins/pytest_randomly.py +0 -0
  72. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pytest_plugins/pytest_regressions.py +0 -0
  73. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/pytest_regressions.py +0 -0
  74. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/random.py +0 -0
  75. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/re.py +0 -0
  76. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/redis.py +0 -0
  77. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/reprlib.py +0 -0
  78. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/scipy.py +0 -0
  79. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/sentinel.py +0 -0
  80. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/shelve.py +0 -0
  81. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/shutil.py +0 -0
  82. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/slack_sdk.py +0 -0
  83. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/socket.py +0 -0
  84. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/sqlalchemy.py +0 -0
  85. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/sqlalchemy_polars.py +0 -0
  86. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/statsmodels.py +0 -0
  87. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/string.py +0 -0
  88. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/tempfile.py +0 -0
  89. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/testbook.py +0 -0
  90. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/text.py +0 -0
  91. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/threading.py +0 -0
  92. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/timer.py +0 -0
  93. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/traceback.py +0 -0
  94. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/types.py +0 -0
  95. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/typing.py +0 -0
  96. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/tzdata.py +0 -0
  97. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/tzlocal.py +0 -0
  98. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/uuid.py +0 -0
  99. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/version.py +0 -0
  100. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/warnings.py +0 -0
  101. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/whenever.py +0 -0
  102. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/zipfile.py +0 -0
  103. {dycw_utilities-0.174.7 → dycw_utilities-0.174.9}/src/utilities/zoneinfo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dycw-utilities
3
- Version: 0.174.7
3
+ Version: 0.174.9
4
4
  Author: Derek Wan
5
5
  Author-email: Derek Wan <d.wan@icloud.com>
6
6
  Requires-Dist: atomicwrites>=1.4.1,<1.5
@@ -28,7 +28,7 @@
28
28
  "pytest-cov >=7.0.0, <7.1",
29
29
  "pytest-timeout >=2.4.0, <2.5",
30
30
  ]
31
- fastapi = ["fastapi >=0.127.0, <0.128"]
31
+ fastapi = ["fastapi >=0.127.1, <0.128"]
32
32
  fastapi-test = ["httpx", "uvicorn"]
33
33
  fpdf2 = ["fpdf2 >=2.8.5, <2.9"]
34
34
  gitpython = ["gitpython >=3.1.45, <3.2"]
@@ -101,7 +101,7 @@
101
101
  name = "dycw-utilities"
102
102
  readme = "README.md"
103
103
  requires-python = ">= 3.12"
104
- version = "0.174.7"
104
+ version = "0.174.9"
105
105
 
106
106
  [project.entry-points.pytest11]
107
107
  pytest-randomly = "utilities.pytest_plugins.pytest_randomly"
@@ -135,7 +135,7 @@
135
135
  # bump-my-version
136
136
  [tool.bumpversion]
137
137
  allow_dirty = true
138
- current_version = "0.174.7"
138
+ current_version = "0.174.9"
139
139
 
140
140
  [[tool.bumpversion.files]]
141
141
  filename = "src/utilities/__init__.py"
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.174.7"
3
+ __version__ = "0.174.9"
@@ -77,6 +77,7 @@ from utilities.math import (
77
77
  )
78
78
  from utilities.os import get_env_var
79
79
  from utilities.pathlib import module_path, temp_cwd
80
+ from utilities.permissions import Permissions
80
81
  from utilities.platform import IS_LINUX
81
82
  from utilities.sentinel import Sentinel, is_sentinel, sentinel
82
83
  from utilities.tempfile import TEMP_DIR, TemporaryDirectory
@@ -864,6 +865,38 @@ def _path_parts(draw: DrawFn, /) -> str:
864
865
  ##
865
866
 
866
867
 
868
+ @composite
869
+ def permissions(
870
+ draw: DrawFn,
871
+ /,
872
+ *,
873
+ user_read: MaybeSearchStrategy[bool | None] = None,
874
+ user_write: MaybeSearchStrategy[bool | None] = None,
875
+ user_execute: MaybeSearchStrategy[bool | None] = None,
876
+ group_read: MaybeSearchStrategy[bool | None] = None,
877
+ group_write: MaybeSearchStrategy[bool | None] = None,
878
+ group_execute: MaybeSearchStrategy[bool | None] = None,
879
+ others_read: MaybeSearchStrategy[bool | None] = None,
880
+ others_write: MaybeSearchStrategy[bool | None] = None,
881
+ others_execute: MaybeSearchStrategy[bool | None] = None,
882
+ ) -> Permissions:
883
+ """Strategy for generating `Permissions`."""
884
+ return Permissions(
885
+ user_read=draw2(draw, user_read, booleans()),
886
+ user_write=draw2(draw, user_write, booleans()),
887
+ user_execute=draw2(draw, user_execute, booleans()),
888
+ group_read=draw2(draw, group_read, booleans()),
889
+ group_write=draw2(draw, group_write, booleans()),
890
+ group_execute=draw2(draw, group_execute, booleans()),
891
+ others_read=draw2(draw, others_read, booleans()),
892
+ others_write=draw2(draw, others_write, booleans()),
893
+ others_execute=draw2(draw, others_execute, booleans()),
894
+ )
895
+
896
+
897
+ ##
898
+
899
+
867
900
  @composite
868
901
  def plain_date_times(
869
902
  draw: DrawFn,
@@ -1611,6 +1644,7 @@ __all__ = [
1611
1644
  "numbers",
1612
1645
  "pairs",
1613
1646
  "paths",
1647
+ "permissions",
1614
1648
  "plain_date_times",
1615
1649
  "py_datetimes",
1616
1650
  "quadruples",
@@ -0,0 +1,297 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import suppress
4
+ from dataclasses import dataclass
5
+ from functools import reduce
6
+ from operator import or_
7
+ from stat import (
8
+ S_IRGRP,
9
+ S_IROTH,
10
+ S_IRUSR,
11
+ S_IWGRP,
12
+ S_IWOTH,
13
+ S_IWUSR,
14
+ S_IXGRP,
15
+ S_IXOTH,
16
+ S_IXUSR,
17
+ )
18
+ from typing import Literal, Self, override
19
+
20
+ from utilities.dataclasses import replace_non_sentinel
21
+ from utilities.functions import ensure_member
22
+ from utilities.re import (
23
+ ExtractGroupError,
24
+ ExtractGroupsError,
25
+ extract_group,
26
+ extract_groups,
27
+ )
28
+ from utilities.sentinel import Sentinel, sentinel
29
+ from utilities.typing import get_args
30
+
31
+ _MIN_INT = 0o0
32
+ _MAX_INT = 0o777
33
+ type _ZeroToSeven = Literal[0, 1, 2, 3, 4, 5, 6, 7]
34
+ _ZERO_TO_SEVEN: list[_ZeroToSeven] = list(get_args(_ZeroToSeven.__value__))
35
+
36
+
37
+ @dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True)
38
+ class Permissions:
39
+ user_read: bool = False
40
+ user_write: bool = False
41
+ user_execute: bool = False
42
+ group_read: bool = False
43
+ group_write: bool = False
44
+ group_execute: bool = False
45
+ others_read: bool = False
46
+ others_write: bool = False
47
+ others_execute: bool = False
48
+
49
+ def __int__(self) -> int:
50
+ return (
51
+ 100
52
+ * self._int(
53
+ read=self.user_read, write=self.user_write, execute=self.user_execute
54
+ )
55
+ + 10
56
+ * self._int(
57
+ read=self.group_read, write=self.group_write, execute=self.group_execute
58
+ )
59
+ + self._int(
60
+ read=self.others_read,
61
+ write=self.others_write,
62
+ execute=self.others_execute,
63
+ )
64
+ )
65
+
66
+ def _int(
67
+ self, *, read: bool = False, write: bool = False, execute: bool = False
68
+ ) -> _ZeroToSeven:
69
+ return (4 if read else 0) + (2 if write else 0) + (1 if execute else 0)
70
+
71
+ @override
72
+ def __repr__(self) -> str:
73
+ return ",".join([
74
+ self._repr_parts(
75
+ "u",
76
+ read=self.user_read,
77
+ write=self.user_write,
78
+ execute=self.user_execute,
79
+ ),
80
+ self._repr_parts(
81
+ "g",
82
+ read=self.group_read,
83
+ write=self.group_write,
84
+ execute=self.group_execute,
85
+ ),
86
+ self._repr_parts(
87
+ "o",
88
+ read=self.others_read,
89
+ write=self.others_write,
90
+ execute=self.others_execute,
91
+ ),
92
+ ])
93
+
94
+ def _repr_parts(
95
+ self,
96
+ prefix: Literal["u", "g", "o"],
97
+ /,
98
+ *,
99
+ read: bool = False,
100
+ write: bool = False,
101
+ execute: bool = False,
102
+ ) -> str:
103
+ parts: list[str] = []
104
+ if read:
105
+ parts.append("r")
106
+ if write:
107
+ parts.append("w")
108
+ if execute:
109
+ parts.append("x")
110
+ return f"{prefix}={''.join(parts)}"
111
+
112
+ @override
113
+ def __str__(self) -> str:
114
+ return repr(self)
115
+
116
+ @classmethod
117
+ def from_int(cls, n: int, /) -> Self:
118
+ with suppress(ExtractGroupsError):
119
+ user, group, others = extract_groups(r"^([0-7])([0-7])([0-7])$", str(n))
120
+ user_read, user_write, user_execute = cls._from_int(
121
+ ensure_member(int(user), _ZERO_TO_SEVEN)
122
+ )
123
+ group_read, group_write, group_execute = cls._from_int(
124
+ ensure_member(int(group), _ZERO_TO_SEVEN)
125
+ )
126
+ others_read, others_write, others_execute = cls._from_int(
127
+ ensure_member(int(others), _ZERO_TO_SEVEN)
128
+ )
129
+ return cls(
130
+ user_read=user_read,
131
+ user_write=user_write,
132
+ user_execute=user_execute,
133
+ group_read=group_read,
134
+ group_write=group_write,
135
+ group_execute=group_execute,
136
+ others_read=others_read,
137
+ others_write=others_write,
138
+ others_execute=others_execute,
139
+ )
140
+ with suppress(ExtractGroupsError):
141
+ group, others = extract_groups(r"^([0-7])([0-7])$", str(n))
142
+ group_read, group_write, group_execute = cls._from_int(
143
+ ensure_member(int(group), _ZERO_TO_SEVEN)
144
+ )
145
+ others_read, others_write, others_execute = cls._from_int(
146
+ ensure_member(int(others), _ZERO_TO_SEVEN)
147
+ )
148
+ return cls(
149
+ group_read=group_read,
150
+ group_write=group_write,
151
+ group_execute=group_execute,
152
+ others_read=others_read,
153
+ others_write=others_write,
154
+ others_execute=others_execute,
155
+ )
156
+ with suppress(ExtractGroupError):
157
+ others = extract_group(r"^([0-7])$", str(n))
158
+ others_read, others_write, others_execute = cls._from_int(
159
+ ensure_member(int(others), _ZERO_TO_SEVEN)
160
+ )
161
+ return cls(
162
+ others_read=others_read,
163
+ others_write=others_write,
164
+ others_execute=others_execute,
165
+ )
166
+ if n == 0:
167
+ return cls()
168
+ raise PermissionsFromIntError(n=n)
169
+
170
+ @classmethod
171
+ def _from_int(cls, n: _ZeroToSeven, /) -> tuple[bool, bool, bool]:
172
+ return bool(4 & n), bool(2 & n), bool(1 & n)
173
+
174
+ @classmethod
175
+ def from_octal(cls, n: int, /) -> Self:
176
+ if _MIN_INT <= n <= _MAX_INT:
177
+ return cls(
178
+ user_read=bool(n & S_IRUSR),
179
+ user_write=bool(n & S_IWUSR),
180
+ user_execute=bool(n & S_IXUSR),
181
+ group_read=bool(n & S_IRGRP),
182
+ group_write=bool(n & S_IWGRP),
183
+ group_execute=bool(n & S_IXGRP),
184
+ others_read=bool(n & S_IROTH),
185
+ others_write=bool(n & S_IWOTH),
186
+ others_execute=bool(n & S_IXOTH),
187
+ )
188
+ raise PermissionsFromOctalError(n=n)
189
+
190
+ @classmethod
191
+ def from_text(cls, text: str, /) -> Self:
192
+ try:
193
+ user, group, others = extract_groups(
194
+ r"^u=(r?w?x?),g=(r?w?x?),o=(r?w?x?)$", text
195
+ )
196
+ except ExtractGroupsError:
197
+ raise PermissionsFromTextError(text=text) from None
198
+ user_read, user_write, user_execute = cls._from_text_part(user)
199
+ group_read, group_write, group_execute = cls._from_text_part(group)
200
+ others_read, others_write, others_execute = cls._from_text_part(others)
201
+ return cls(
202
+ user_read=user_read,
203
+ user_write=user_write,
204
+ user_execute=user_execute,
205
+ group_read=group_read,
206
+ group_write=group_write,
207
+ group_execute=group_execute,
208
+ others_read=others_read,
209
+ others_write=others_write,
210
+ others_execute=others_execute,
211
+ )
212
+
213
+ @classmethod
214
+ def _from_text_part(cls, text: str, /) -> tuple[bool, bool, bool]:
215
+ read, write, execute = extract_groups("^(r?)(w?)(x?)$", text)
216
+ return read != "", write != "", execute != ""
217
+
218
+ @property
219
+ def octal(self) -> int:
220
+ flags: list[int] = [
221
+ S_IRUSR if self.user_read else 0,
222
+ S_IWUSR if self.user_write else 0,
223
+ S_IXUSR if self.user_execute else 0,
224
+ S_IRGRP if self.group_read else 0,
225
+ S_IWGRP if self.group_write else 0,
226
+ S_IXGRP if self.group_execute else 0,
227
+ S_IROTH if self.others_read else 0,
228
+ S_IWOTH if self.others_write else 0,
229
+ S_IXOTH if self.others_execute else 0,
230
+ ]
231
+ return reduce(or_, flags)
232
+
233
+ def replace(
234
+ self,
235
+ *,
236
+ user_read: bool | Sentinel = sentinel,
237
+ user_write: bool | Sentinel = sentinel,
238
+ user_execute: bool | Sentinel = sentinel,
239
+ group_read: bool | Sentinel = sentinel,
240
+ group_write: bool | Sentinel = sentinel,
241
+ group_execute: bool | Sentinel = sentinel,
242
+ others_read: bool | Sentinel = sentinel,
243
+ others_write: bool | Sentinel = sentinel,
244
+ others_execute: bool | Sentinel = sentinel,
245
+ ) -> Self:
246
+ return replace_non_sentinel(
247
+ self,
248
+ user_read=user_read,
249
+ user_write=user_write,
250
+ user_execute=user_execute,
251
+ group_read=group_read,
252
+ group_write=group_write,
253
+ group_execute=group_execute,
254
+ others_read=others_read,
255
+ others_write=others_write,
256
+ others_execute=others_execute,
257
+ )
258
+
259
+
260
+ @dataclass(kw_only=True, slots=True)
261
+ class PermissionsError(Exception): ...
262
+
263
+
264
+ @dataclass(kw_only=True, slots=True)
265
+ class PermissionsFromIntError(PermissionsError):
266
+ n: int
267
+
268
+ @override
269
+ def __str__(self) -> str:
270
+ return f"Invalid integer for permissions; got {self.n}"
271
+
272
+
273
+ @dataclass(kw_only=True, slots=True)
274
+ class PermissionsFromOctalError(PermissionsError):
275
+ n: int
276
+
277
+ @override
278
+ def __str__(self) -> str:
279
+ return f"Invalid octal for permissions; got {oct(self.n)}"
280
+
281
+
282
+ @dataclass(kw_only=True, slots=True)
283
+ class PermissionsFromTextError(PermissionsError):
284
+ text: str
285
+
286
+ @override
287
+ def __str__(self) -> str:
288
+ return f"Invalid string for permissions; got {self.text!r}"
289
+
290
+
291
+ __all__ = [
292
+ "Permissions",
293
+ "PermissionsError",
294
+ "PermissionsFromIntError",
295
+ "PermissionsFromOctalError",
296
+ "PermissionsFromTextError",
297
+ ]
@@ -5,7 +5,7 @@ from contextlib import contextmanager
5
5
  from dataclasses import dataclass
6
6
  from io import StringIO
7
7
  from pathlib import Path
8
- from shlex import join, quote
8
+ from shlex import join
9
9
  from string import Template
10
10
  from subprocess import PIPE, CalledProcessError, Popen
11
11
  from threading import Thread
@@ -40,22 +40,37 @@ RESTART_SSHD = ["systemctl", "restart", "sshd"]
40
40
  UPDATE_CA_CERTIFICATES: str = "update-ca-certificates"
41
41
 
42
42
 
43
+ ##
44
+
45
+
43
46
  def apt_install_cmd(package: str, /) -> list[str]:
44
47
  return ["apt", "install", "-y", package]
45
48
 
46
49
 
50
+ ##
51
+
52
+
47
53
  def cat_cmd(path: PathLike, /) -> list[str]:
48
54
  return ["cat", str(path)]
49
55
 
50
56
 
57
+ ##
58
+
59
+
51
60
  def cd_cmd(path: PathLike, /) -> list[str]:
52
61
  return ["cd", str(path)]
53
62
 
54
63
 
64
+ ##
65
+
66
+
55
67
  def chmod_cmd(path: PathLike, mode: str, /) -> list[str]:
56
68
  return ["chmod", mode, str(path)]
57
69
 
58
70
 
71
+ ##
72
+
73
+
59
74
  def chown_cmd(
60
75
  path: PathLike, /, *, user: str | None = None, group: str | None = None
61
76
  ) -> list[str]:
@@ -80,14 +95,23 @@ class ChownCmdError(Exception):
80
95
  return "At least one of 'user' and/or 'group' must be given; got None"
81
96
 
82
97
 
98
+ ##
99
+
100
+
83
101
  def cp_cmd(src: PathLike, dest: PathLike, /) -> list[str]:
84
102
  return ["cp", "-r", str(src), str(dest)]
85
103
 
86
104
 
105
+ ##
106
+
107
+
87
108
  def echo_cmd(text: str, /) -> list[str]:
88
109
  return ["echo", text]
89
110
 
90
111
 
112
+ ##
113
+
114
+
91
115
  def expand_path(
92
116
  path: PathLike, /, *, subs: StrMapping | None = None, sudo: bool = False
93
117
  ) -> Path:
@@ -98,47 +122,71 @@ def expand_path(
98
122
  return Path(path).expanduser()
99
123
 
100
124
 
125
+ ##
126
+
127
+
101
128
  def git_clone_cmd(url: str, path: PathLike, /) -> list[str]:
102
129
  return ["git", "clone", "--recurse-submodules", url, str(path)]
103
130
 
104
131
 
132
+ ##
133
+
134
+
105
135
  def git_hard_reset_cmd(*, branch: str | None = None) -> list[str]:
106
136
  branch_use = "master" if branch is None else branch
107
137
  return ["git", "hard-reset", branch_use]
108
138
 
109
139
 
140
+ ##
141
+
142
+
143
+ def maybe_parent(path: PathLike, /, *, parent: bool = False) -> Path:
144
+ path = Path(path)
145
+ return path.parent if parent else path
146
+
147
+
148
+ ##
149
+
150
+
110
151
  def maybe_sudo_cmd(cmd: str, /, *args: str, sudo: bool = False) -> list[str]:
111
152
  parts: list[str] = [cmd, *args]
112
153
  return sudo_cmd(*parts) if sudo else parts
113
154
 
114
155
 
156
+ ##
157
+
158
+
115
159
  def mkdir(path: PathLike, /, *, sudo: bool = False, parent: bool = False) -> None:
116
160
  if sudo: # pragma: no cover
117
161
  run(*sudo_cmd(*mkdir_cmd(path, parent=parent)))
118
162
  else:
119
- path = expand_path(path)
120
- path_use = path.parent if parent else path
121
- path_use.mkdir(parents=True, exist_ok=True)
163
+ maybe_parent(path, parent=parent).mkdir(parents=True, exist_ok=True)
164
+
165
+
166
+ ##
122
167
 
123
168
 
124
169
  def mkdir_cmd(path: PathLike, /, *, parent: bool = False) -> list[str]:
125
- args: list[str] = ["mkdir", "-p"]
126
- quoted = quote(str(path))
127
- if parent:
128
- args.append(f"$(dirname {quoted})")
129
- else:
130
- args.append(quoted)
131
- return args
170
+ return ["mkdir", "-p", str(maybe_parent(path, parent=parent))]
171
+
172
+
173
+ ##
132
174
 
133
175
 
134
176
  def mv_cmd(src: PathLike, dest: PathLike, /) -> list[str]:
135
177
  return ["mv", str(src), str(dest)]
136
178
 
137
179
 
180
+ ##
181
+
182
+
138
183
  def rm_cmd(path: PathLike, /) -> list[str]:
139
184
  return ["rm", "-rf", str(path)]
140
185
 
141
186
 
187
+ ##
188
+
189
+
142
190
  def rsync(
143
191
  src_or_srcs: MaybeIterable[PathLike],
144
192
  user: str,
@@ -153,7 +201,6 @@ def rsync(
153
201
  print: bool = False, # noqa: A002
154
202
  retry: Retry | None = None,
155
203
  logger: LoggerLike | None = None,
156
- archive: bool = False,
157
204
  chown_user: str | None = None,
158
205
  chown_group: str | None = None,
159
206
  exclude: MaybeIterable[str] | None = None,
@@ -171,12 +218,13 @@ def rsync(
171
218
  retry=retry,
172
219
  logger=logger,
173
220
  )
221
+ is_dir = any(Path(s).is_dir() for s in always_iterable(src_or_srcs)) # skipif-ci
174
222
  rsync_args = rsync_cmd( # skipif-ci
175
223
  src_or_srcs,
176
224
  user,
177
225
  hostname,
178
226
  dest,
179
- archive=archive,
227
+ archive=is_dir,
180
228
  chown_user=chown_user,
181
229
  chown_group=chown_group,
182
230
  exclude=exclude,
@@ -184,6 +232,7 @@ def rsync(
184
232
  host_key_algorithms=host_key_algorithms,
185
233
  strict_host_key_checking=strict_host_key_checking,
186
234
  sudo=sudo,
235
+ parent=is_dir,
187
236
  )
188
237
  run(*rsync_args, print=print, retry=retry, logger=logger) # skipif-ci
189
238
  if chmod is not None: # skipif-ci
@@ -201,6 +250,9 @@ def rsync(
201
250
  )
202
251
 
203
252
 
253
+ ##
254
+
255
+
204
256
  def rsync_cmd(
205
257
  src_or_srcs: MaybeIterable[PathLike],
206
258
  user: str,
@@ -216,6 +268,7 @@ def rsync_cmd(
216
268
  host_key_algorithms: list[str] = _HOST_KEY_ALGORITHMS,
217
269
  strict_host_key_checking: bool = True,
218
270
  sudo: bool = False,
271
+ parent: bool = False,
219
272
  ) -> list[str]:
220
273
  args: list[str] = ["rsync"]
221
274
  if archive:
@@ -244,7 +297,15 @@ def rsync_cmd(
244
297
  args.extend(["--rsh", join(rsh_args)])
245
298
  if sudo:
246
299
  args.extend(["--rsync-path", join(sudo_cmd("rsync"))])
247
- return [*args, *map(str, always_iterable(src_or_srcs)), f"{user}@{hostname}:{dest}"]
300
+ dest_use = maybe_parent(dest, parent=parent)
301
+ return [
302
+ *args,
303
+ *map(str, always_iterable(src_or_srcs)),
304
+ f"{user}@{hostname}:{dest_use}",
305
+ ]
306
+
307
+
308
+ ##
248
309
 
249
310
 
250
311
  @overload
@@ -502,10 +563,16 @@ def _run_write_to_streams(text: str, /, *outputs: IO[str]) -> None:
502
563
  _ = output.write(text)
503
564
 
504
565
 
566
+ ##
567
+
568
+
505
569
  def set_hostname_cmd(hostname: str, /) -> list[str]:
506
570
  return ["hostnamectl", "set-hostname", hostname]
507
571
 
508
572
 
573
+ ##
574
+
575
+
509
576
  @overload
510
577
  def ssh(
511
578
  user: str,
@@ -641,6 +708,9 @@ def ssh(
641
708
  )
642
709
 
643
710
 
711
+ ##
712
+
713
+
644
714
  def ssh_cmd(
645
715
  user: str,
646
716
  hostname: str,
@@ -658,6 +728,9 @@ def ssh_cmd(
658
728
  return [*args, f"{user}@{hostname}", *cmd_and_cmds_or_args]
659
729
 
660
730
 
731
+ ##
732
+
733
+
661
734
  def ssh_opts_cmd(
662
735
  *,
663
736
  batch_mode: bool = True,
@@ -673,26 +746,44 @@ def ssh_opts_cmd(
673
746
  return [*args, "-T"]
674
747
 
675
748
 
749
+ ##
750
+
751
+
676
752
  def ssh_keygen_cmd(hostname: str, /) -> list[str]:
677
753
  return ["ssh-keygen", "-f", "~/.ssh/known_hosts", "-R", hostname]
678
754
 
679
755
 
756
+ ##
757
+
758
+
680
759
  def sudo_cmd(cmd: str, /, *args: str) -> list[str]:
681
760
  return ["sudo", cmd, *args]
682
761
 
683
762
 
763
+ ##
764
+
765
+
684
766
  def sudo_nopasswd_cmd(user: str, /) -> str:
685
767
  return f"{user} ALL=(ALL) NOPASSWD: ALL"
686
768
 
687
769
 
770
+ ##
771
+
772
+
688
773
  def symlink_cmd(src: PathLike, dest: PathLike, /) -> list[str]:
689
774
  return ["ln", "-s", str(src), str(dest)]
690
775
 
691
776
 
777
+ ##
778
+
779
+
692
780
  def touch_cmd(path: PathLike, /) -> list[str]:
693
781
  return ["touch", str(path)]
694
782
 
695
783
 
784
+ ##
785
+
786
+
696
787
  def uv_run_cmd(module: str, /, *args: str) -> list[str]:
697
788
  return [
698
789
  "uv",
@@ -708,6 +799,9 @@ def uv_run_cmd(module: str, /, *args: str) -> list[str]:
708
799
  ]
709
800
 
710
801
 
802
+ ##
803
+
804
+
711
805
  @contextmanager
712
806
  def yield_ssh_temp_dir(
713
807
  user: str,
@@ -748,6 +842,7 @@ __all__ = [
748
842
  "expand_path",
749
843
  "git_clone_cmd",
750
844
  "git_hard_reset_cmd",
845
+ "maybe_parent",
751
846
  "maybe_sudo_cmd",
752
847
  "mkdir",
753
848
  "mkdir_cmd",