pyinfra 0.11.dev3__py3-none-any.whl → 3.5.1__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 (203) hide show
  1. pyinfra/__init__.py +9 -12
  2. pyinfra/__main__.py +4 -0
  3. pyinfra/api/__init__.py +18 -3
  4. pyinfra/api/arguments.py +406 -0
  5. pyinfra/api/arguments_typed.py +79 -0
  6. pyinfra/api/command.py +274 -0
  7. pyinfra/api/config.py +222 -28
  8. pyinfra/api/connect.py +33 -13
  9. pyinfra/api/connectors.py +27 -0
  10. pyinfra/api/deploy.py +65 -66
  11. pyinfra/api/exceptions.py +67 -18
  12. pyinfra/api/facts.py +253 -202
  13. pyinfra/api/host.py +413 -50
  14. pyinfra/api/inventory.py +121 -160
  15. pyinfra/api/operation.py +432 -262
  16. pyinfra/api/operations.py +273 -260
  17. pyinfra/api/state.py +302 -248
  18. pyinfra/api/util.py +291 -368
  19. pyinfra/connectors/base.py +173 -0
  20. pyinfra/connectors/chroot.py +212 -0
  21. pyinfra/connectors/docker.py +381 -0
  22. pyinfra/connectors/dockerssh.py +297 -0
  23. pyinfra/connectors/local.py +238 -0
  24. pyinfra/connectors/scp/__init__.py +1 -0
  25. pyinfra/connectors/scp/client.py +204 -0
  26. pyinfra/connectors/ssh.py +670 -0
  27. pyinfra/connectors/ssh_util.py +114 -0
  28. pyinfra/connectors/sshuserclient/client.py +309 -0
  29. pyinfra/connectors/sshuserclient/config.py +102 -0
  30. pyinfra/connectors/terraform.py +135 -0
  31. pyinfra/connectors/util.py +410 -0
  32. pyinfra/connectors/vagrant.py +183 -0
  33. pyinfra/context.py +145 -0
  34. pyinfra/facts/__init__.py +7 -6
  35. pyinfra/facts/apk.py +22 -7
  36. pyinfra/facts/apt.py +117 -60
  37. pyinfra/facts/brew.py +100 -15
  38. pyinfra/facts/bsdinit.py +23 -0
  39. pyinfra/facts/cargo.py +37 -0
  40. pyinfra/facts/choco.py +47 -0
  41. pyinfra/facts/crontab.py +195 -0
  42. pyinfra/facts/deb.py +94 -0
  43. pyinfra/facts/dnf.py +48 -0
  44. pyinfra/facts/docker.py +96 -23
  45. pyinfra/facts/efibootmgr.py +113 -0
  46. pyinfra/facts/files.py +630 -58
  47. pyinfra/facts/flatpak.py +77 -0
  48. pyinfra/facts/freebsd.py +70 -0
  49. pyinfra/facts/gem.py +19 -6
  50. pyinfra/facts/git.py +59 -14
  51. pyinfra/facts/gpg.py +150 -0
  52. pyinfra/facts/hardware.py +313 -167
  53. pyinfra/facts/iptables.py +72 -62
  54. pyinfra/facts/launchd.py +44 -0
  55. pyinfra/facts/lxd.py +17 -4
  56. pyinfra/facts/mysql.py +122 -86
  57. pyinfra/facts/npm.py +17 -9
  58. pyinfra/facts/openrc.py +71 -0
  59. pyinfra/facts/opkg.py +246 -0
  60. pyinfra/facts/pacman.py +50 -7
  61. pyinfra/facts/pip.py +24 -7
  62. pyinfra/facts/pipx.py +82 -0
  63. pyinfra/facts/pkg.py +15 -6
  64. pyinfra/facts/pkgin.py +35 -0
  65. pyinfra/facts/podman.py +54 -0
  66. pyinfra/facts/postgres.py +178 -0
  67. pyinfra/facts/postgresql.py +6 -147
  68. pyinfra/facts/rpm.py +105 -0
  69. pyinfra/facts/runit.py +77 -0
  70. pyinfra/facts/selinux.py +161 -0
  71. pyinfra/facts/server.py +746 -285
  72. pyinfra/facts/snap.py +88 -0
  73. pyinfra/facts/systemd.py +139 -0
  74. pyinfra/facts/sysvinit.py +59 -0
  75. pyinfra/facts/upstart.py +35 -0
  76. pyinfra/facts/util/__init__.py +17 -0
  77. pyinfra/facts/util/databases.py +4 -6
  78. pyinfra/facts/util/packaging.py +37 -6
  79. pyinfra/facts/util/units.py +30 -0
  80. pyinfra/facts/util/win_files.py +99 -0
  81. pyinfra/facts/vzctl.py +20 -13
  82. pyinfra/facts/xbps.py +35 -0
  83. pyinfra/facts/yum.py +34 -40
  84. pyinfra/facts/zfs.py +77 -0
  85. pyinfra/facts/zypper.py +42 -0
  86. pyinfra/local.py +45 -83
  87. pyinfra/operations/__init__.py +12 -0
  88. pyinfra/operations/apk.py +98 -0
  89. pyinfra/operations/apt.py +488 -0
  90. pyinfra/operations/brew.py +231 -0
  91. pyinfra/operations/bsdinit.py +59 -0
  92. pyinfra/operations/cargo.py +45 -0
  93. pyinfra/operations/choco.py +61 -0
  94. pyinfra/operations/crontab.py +191 -0
  95. pyinfra/operations/dnf.py +210 -0
  96. pyinfra/operations/docker.py +446 -0
  97. pyinfra/operations/files.py +1939 -0
  98. pyinfra/operations/flatpak.py +94 -0
  99. pyinfra/operations/freebsd/__init__.py +12 -0
  100. pyinfra/operations/freebsd/freebsd_update.py +70 -0
  101. pyinfra/operations/freebsd/pkg.py +219 -0
  102. pyinfra/operations/freebsd/service.py +116 -0
  103. pyinfra/operations/freebsd/sysrc.py +92 -0
  104. pyinfra/operations/gem.py +47 -0
  105. pyinfra/operations/git.py +419 -0
  106. pyinfra/operations/iptables.py +311 -0
  107. pyinfra/operations/launchd.py +45 -0
  108. pyinfra/operations/lxd.py +68 -0
  109. pyinfra/operations/mysql.py +609 -0
  110. pyinfra/operations/npm.py +57 -0
  111. pyinfra/operations/openrc.py +63 -0
  112. pyinfra/operations/opkg.py +88 -0
  113. pyinfra/operations/pacman.py +81 -0
  114. pyinfra/operations/pip.py +205 -0
  115. pyinfra/operations/pipx.py +102 -0
  116. pyinfra/operations/pkg.py +70 -0
  117. pyinfra/operations/pkgin.py +91 -0
  118. pyinfra/operations/postgres.py +436 -0
  119. pyinfra/operations/postgresql.py +30 -0
  120. pyinfra/operations/puppet.py +40 -0
  121. pyinfra/operations/python.py +72 -0
  122. pyinfra/operations/runit.py +184 -0
  123. pyinfra/operations/selinux.py +189 -0
  124. pyinfra/operations/server.py +1099 -0
  125. pyinfra/operations/snap.py +117 -0
  126. pyinfra/operations/ssh.py +216 -0
  127. pyinfra/operations/systemd.py +149 -0
  128. pyinfra/operations/sysvinit.py +141 -0
  129. pyinfra/operations/upstart.py +68 -0
  130. pyinfra/operations/util/__init__.py +12 -0
  131. pyinfra/operations/util/docker.py +251 -0
  132. pyinfra/operations/util/files.py +247 -0
  133. pyinfra/operations/util/packaging.py +336 -0
  134. pyinfra/operations/util/service.py +46 -0
  135. pyinfra/operations/vzctl.py +137 -0
  136. pyinfra/operations/xbps.py +77 -0
  137. pyinfra/operations/yum.py +210 -0
  138. pyinfra/operations/zfs.py +175 -0
  139. pyinfra/operations/zypper.py +192 -0
  140. pyinfra/progress.py +44 -32
  141. pyinfra/py.typed +0 -0
  142. pyinfra/version.py +9 -1
  143. pyinfra-3.5.1.dist-info/METADATA +141 -0
  144. pyinfra-3.5.1.dist-info/RECORD +159 -0
  145. {pyinfra-0.11.dev3.dist-info → pyinfra-3.5.1.dist-info}/WHEEL +1 -2
  146. pyinfra-3.5.1.dist-info/entry_points.txt +12 -0
  147. {pyinfra-0.11.dev3.dist-info → pyinfra-3.5.1.dist-info/licenses}/LICENSE.md +1 -1
  148. pyinfra_cli/__init__.py +1 -0
  149. pyinfra_cli/cli.py +780 -0
  150. pyinfra_cli/commands.py +66 -0
  151. pyinfra_cli/exceptions.py +155 -65
  152. pyinfra_cli/inventory.py +233 -89
  153. pyinfra_cli/log.py +39 -43
  154. pyinfra_cli/main.py +26 -495
  155. pyinfra_cli/prints.py +215 -156
  156. pyinfra_cli/util.py +172 -105
  157. pyinfra_cli/virtualenv.py +25 -20
  158. pyinfra/api/connectors/__init__.py +0 -21
  159. pyinfra/api/connectors/ansible.py +0 -99
  160. pyinfra/api/connectors/docker.py +0 -178
  161. pyinfra/api/connectors/local.py +0 -169
  162. pyinfra/api/connectors/ssh.py +0 -402
  163. pyinfra/api/connectors/sshuserclient/client.py +0 -105
  164. pyinfra/api/connectors/sshuserclient/config.py +0 -90
  165. pyinfra/api/connectors/util.py +0 -63
  166. pyinfra/api/connectors/vagrant.py +0 -155
  167. pyinfra/facts/init.py +0 -176
  168. pyinfra/facts/util/files.py +0 -102
  169. pyinfra/hook.py +0 -41
  170. pyinfra/modules/__init__.py +0 -11
  171. pyinfra/modules/apk.py +0 -64
  172. pyinfra/modules/apt.py +0 -272
  173. pyinfra/modules/brew.py +0 -122
  174. pyinfra/modules/files.py +0 -711
  175. pyinfra/modules/gem.py +0 -30
  176. pyinfra/modules/git.py +0 -115
  177. pyinfra/modules/init.py +0 -344
  178. pyinfra/modules/iptables.py +0 -271
  179. pyinfra/modules/lxd.py +0 -45
  180. pyinfra/modules/mysql.py +0 -347
  181. pyinfra/modules/npm.py +0 -47
  182. pyinfra/modules/pacman.py +0 -60
  183. pyinfra/modules/pip.py +0 -99
  184. pyinfra/modules/pkg.py +0 -43
  185. pyinfra/modules/postgresql.py +0 -245
  186. pyinfra/modules/puppet.py +0 -20
  187. pyinfra/modules/python.py +0 -37
  188. pyinfra/modules/server.py +0 -524
  189. pyinfra/modules/ssh.py +0 -150
  190. pyinfra/modules/util/files.py +0 -52
  191. pyinfra/modules/util/packaging.py +0 -118
  192. pyinfra/modules/vzctl.py +0 -133
  193. pyinfra/modules/yum.py +0 -171
  194. pyinfra/pseudo_modules.py +0 -64
  195. pyinfra-0.11.dev3.dist-info/.DS_Store +0 -0
  196. pyinfra-0.11.dev3.dist-info/METADATA +0 -135
  197. pyinfra-0.11.dev3.dist-info/RECORD +0 -95
  198. pyinfra-0.11.dev3.dist-info/entry_points.txt +0 -3
  199. pyinfra-0.11.dev3.dist-info/top_level.txt +0 -2
  200. pyinfra_cli/__main__.py +0 -40
  201. pyinfra_cli/config.py +0 -92
  202. /pyinfra/{modules/util → connectors}/__init__.py +0 -0
  203. /pyinfra/{api/connectors → connectors}/sshuserclient/__init__.py +0 -0
pyinfra/facts/files.py CHANGED
@@ -1,105 +1,677 @@
1
+ """
2
+ The files facts provide information about the filesystem and it's contents on the target host.
3
+
4
+ Facts need to be imported before use, eg
5
+
6
+ from pyinfra.facts.files import File
7
+ """
8
+
9
+ from __future__ import annotations
10
+
1
11
  import re
12
+ import shlex
13
+ import stat
14
+ from datetime import datetime, timezone
15
+ from typing import TYPE_CHECKING, List, Optional, Tuple, Union
16
+
17
+ from typing_extensions import Literal, NotRequired, TypedDict, override
2
18
 
19
+ from pyinfra.api import StringCommand
20
+ from pyinfra.api.command import QuoteString, make_formatted_string_command
3
21
  from pyinfra.api.facts import FactBase
22
+ from pyinfra.api.util import try_int
23
+ from pyinfra.facts.util.units import parse_size
24
+
25
+ LINUX_STAT_COMMAND = "stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'"
26
+ BSD_STAT_COMMAND = "stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'"
27
+ LS_COMMAND = "ls -ld"
28
+
29
+ STAT_REGEX = (
30
+ r"user=(.*) group=(.*) mode=(.*) "
31
+ r"atime=(-?[0-9]*) mtime=(-?[0-9]*) ctime=(-?[0-9]*) "
32
+ r"size=([0-9]*) (.*)"
33
+ )
34
+
35
+ # ls -ld output: permissions links user group size month day year/time path
36
+ # Supports attribute markers: . (SELinux), @ (extended attrs), + (ACL)
37
+ # Handles both "MMM DD" and "DD MMM" date formats
38
+ LS_REGEX = (
39
+ r"^([dlbcsp-][-rwxstST]{9}[.@+]?)\s+\d+\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$"
40
+ )
41
+
42
+ FLAG_TO_TYPE = {
43
+ "b": "block",
44
+ "c": "character",
45
+ "d": "directory",
46
+ "l": "link",
47
+ "s": "socket",
48
+ "p": "fifo",
49
+ "-": "file",
50
+ }
51
+
52
+ # Each item is a map of character to permission octal to be combined, taken from stdlib:
53
+ # https://github.com/python/cpython/blob/c1c3be0f9dc414bfae9a5718451ca217751ac687/Lib/stat.py#L128-L154
54
+ CHAR_TO_PERMISSION = (
55
+ # User
56
+ {"r": stat.S_IRUSR},
57
+ {"w": stat.S_IWUSR},
58
+ {"x": stat.S_IXUSR, "S": stat.S_ISUID, "s": stat.S_IXUSR | stat.S_ISUID},
59
+ # Group
60
+ {"r": stat.S_IRGRP},
61
+ {"w": stat.S_IWGRP},
62
+ {"x": stat.S_IXGRP, "S": stat.S_ISGID, "s": stat.S_IXGRP | stat.S_ISGID},
63
+ # Other
64
+ {"r": stat.S_IROTH},
65
+ {"w": stat.S_IWOTH},
66
+ {"x": stat.S_IXOTH, "T": stat.S_ISVTX, "t": stat.S_IXOTH | stat.S_ISVTX},
67
+ )
68
+
69
+
70
+ def _parse_mode(mode: str) -> int:
71
+ """
72
+ Converts ls mode output (rwxrwxrwx) -> octal permission integer (755).
73
+ """
74
+
75
+ out = 0
76
+
77
+ for i, char in enumerate(mode):
78
+ for c, m in CHAR_TO_PERMISSION[i].items():
79
+ if char == c:
80
+ out |= m
81
+ break
82
+
83
+ return int(oct(out)[2:])
84
+
85
+
86
+ def _parse_datetime(value: str) -> Optional[datetime]:
87
+ value = try_int(value)
88
+ if isinstance(value, int):
89
+ return datetime.fromtimestamp(value, timezone.utc).replace(tzinfo=None)
90
+ return None
91
+
92
+
93
+ def _parse_ls_timestamp(month: str, day: str, year_or_time: str) -> Optional[datetime]:
94
+ """
95
+ Parse ls timestamp format.
96
+ Examples: "Jan 1 1970", "Apr 2 2025", "Dec 31 12:34"
97
+ """
98
+ try:
99
+ # Month abbreviation to number mapping
100
+ month_map = {
101
+ "Jan": 1,
102
+ "Feb": 2,
103
+ "Mar": 3,
104
+ "Apr": 4,
105
+ "May": 5,
106
+ "Jun": 6,
107
+ "Jul": 7,
108
+ "Aug": 8,
109
+ "Sep": 9,
110
+ "Oct": 10,
111
+ "Nov": 11,
112
+ "Dec": 12,
113
+ }
114
+
115
+ month_num = month_map.get(month)
116
+ if month_num is None:
117
+ return None
118
+
119
+ day_num = int(day)
120
+
121
+ # Check if year_or_time is a year (4 digits) or time (HH:MM)
122
+ if ":" in year_or_time:
123
+ # It's a time, assume current year
124
+ import time
125
+
126
+ current_year = time.gmtime().tm_year
127
+ hour, minute = map(int, year_or_time.split(":"))
128
+ return datetime(current_year, month_num, day_num, hour, minute)
129
+ else:
130
+ # It's a year
131
+ year_num = int(year_or_time)
132
+ return datetime(year_num, month_num, day_num)
133
+
134
+ except (ValueError, TypeError):
135
+ return None
136
+
137
+
138
+ def _parse_ls_output(output: str) -> Optional[tuple[FileDict, str]]:
139
+ """
140
+ Parse ls -ld output and extract file information.
141
+ Example: drwxr-xr-x 1 root root 416 Jan 1 1970 /
142
+ """
143
+ match = re.match(LS_REGEX, output.strip())
144
+ if not match:
145
+ return None
146
+
147
+ permissions = match.group(1)
148
+ user = match.group(2)
149
+ group = match.group(3)
150
+ size = match.group(4)
151
+ date_part1 = match.group(5)
152
+ date_part2 = match.group(6)
153
+ year_or_time = match.group(7)
154
+ path = match.group(8)
155
+
156
+ # Determine if it's "MMM DD" or "DD MMM" format
157
+ if date_part1.isdigit():
158
+ # "DD MMM" format (e.g., "22 Jun")
159
+ day = date_part1
160
+ month = date_part2
161
+ else:
162
+ # "MMM DD" format (e.g., "Jun 22")
163
+ month = date_part1
164
+ day = date_part2
165
+
166
+ # Extract file type from first character of permissions
167
+ path_type = FLAG_TO_TYPE[permissions[0]]
168
+
169
+ # Parse mode (skip first character which is file type, and any trailing attribute markers)
170
+ # Remove trailing attribute markers (.@+) if present
171
+ mode_str = permissions[1:10] # Take exactly 9 characters after file type
172
+ mode = _parse_mode(mode_str)
173
+
174
+ # Parse timestamp - ls shows modification time
175
+ mtime = _parse_ls_timestamp(month, day, year_or_time)
176
+
177
+ data: FileDict = {
178
+ "user": user,
179
+ "group": group,
180
+ "mode": mode,
181
+ "atime": None, # ls doesn't provide atime
182
+ "mtime": mtime,
183
+ "ctime": None, # ls doesn't provide ctime
184
+ "size": try_int(size),
185
+ }
186
+
187
+ # Handle symbolic links
188
+ if path_type == "link" and " -> " in path:
189
+ filename, target = path.split(" -> ", 1)
190
+ data["link_target"] = target.strip("'").lstrip("`")
191
+
192
+ return data, path_type
193
+
194
+
195
+ class FileDict(TypedDict):
196
+ mode: int
197
+ size: Union[int, str]
198
+ atime: Optional[datetime]
199
+ mtime: Optional[datetime]
200
+ ctime: Optional[datetime]
201
+ user: str
202
+ group: str
203
+ link_target: NotRequired[str]
204
+
205
+
206
+ class File(FactBase[Union[FileDict, Literal[False], None]]):
207
+ """
208
+ Returns information about a file on the remote system:
209
+
210
+ .. code:: python
211
+
212
+ {
213
+ "user": "pyinfra",
214
+ "group": "pyinfra",
215
+ "mode": 644,
216
+ "size": 3928,
217
+ }
218
+
219
+ If the path does not exist:
220
+ returns ``None``
221
+
222
+ If the path exists but is not a file:
223
+ returns ``False``
224
+ """
225
+
226
+ type = "file"
227
+
228
+ @override
229
+ def command(self, path):
230
+ if path.startswith("~/"):
231
+ # Do not quote leading tilde to ensure that it gets properly expanded by the shell
232
+ path = f"~/{shlex.quote(path[2:])}"
233
+ else:
234
+ path = QuoteString(path)
235
+
236
+ return make_formatted_string_command(
237
+ (
238
+ # only stat if the path exists (file or symlink)
239
+ "! (test -e {0} || test -L {0} ) || "
240
+ "( {linux_stat_command} {0} 2> /dev/null || "
241
+ "{bsd_stat_command} {0} || {ls_command} {0} )"
242
+ ),
243
+ path,
244
+ linux_stat_command=LINUX_STAT_COMMAND,
245
+ bsd_stat_command=BSD_STAT_COMMAND,
246
+ ls_command=LS_COMMAND,
247
+ )
248
+
249
+ @override
250
+ def process(self, output) -> Union[FileDict, Literal[False], None]:
251
+ # Try to parse as stat output first
252
+ match = re.match(STAT_REGEX, output[0])
253
+ if match:
254
+ mode = match.group(3)
255
+ path_type = FLAG_TO_TYPE[mode[0]]
256
+
257
+ data: FileDict = {
258
+ "user": match.group(1),
259
+ "group": match.group(2),
260
+ "mode": _parse_mode(mode[1:]),
261
+ "atime": _parse_datetime(match.group(4)),
262
+ "mtime": _parse_datetime(match.group(5)),
263
+ "ctime": _parse_datetime(match.group(6)),
264
+ "size": try_int(match.group(7)),
265
+ }
266
+
267
+ if path_type != self.type:
268
+ return False
269
+
270
+ if path_type == "link":
271
+ filename = match.group(8)
272
+ filename, target = filename.split(" -> ")
273
+ data["link_target"] = target.strip("'").lstrip("`")
274
+
275
+ return data
276
+
277
+ # Try to parse as ls output
278
+ ls_result = _parse_ls_output(output[0])
279
+ if ls_result is not None:
280
+ data, path_type = ls_result
281
+
282
+ if path_type != self.type:
283
+ return False
284
+
285
+ return data
286
+
287
+ return None
4
288
 
5
- from .util.files import parse_ls_output
6
289
 
290
+ class Link(File):
291
+ """
292
+ Returns information about a link on the remote system:
7
293
 
8
- class File(FactBase):
9
- # Types must match FLAG_TO_TYPE in .util.files.py
10
- type = 'file'
294
+ .. code:: python
11
295
 
12
- def command(self, name):
13
- self.name = name
14
- return 'ls -ld --time-style=long-iso {0} || ls -ldT {0}'.format(name)
296
+ {
297
+ "user": "pyinfra",
298
+ "group": "pyinfra",
299
+ "link_target": "/path/to/link/target"
300
+ }
15
301
 
16
- def process(self, output):
17
- return parse_ls_output(output[0], self.type)
302
+ If the path does not exist:
303
+ returns ``None``
18
304
 
305
+ If the path exists but is not a link:
306
+ returns ``False``
307
+ """
19
308
 
20
- class Link(File):
21
- type = 'link'
309
+ type = "link"
22
310
 
23
311
 
24
312
  class Directory(File):
25
- type = 'directory'
313
+ """
314
+ Returns information about a directory on the remote system:
315
+
316
+ .. code:: python
317
+
318
+ {
319
+ "user": "pyinfra",
320
+ "group": "pyinfra",
321
+ "mode": 644,
322
+ }
323
+
324
+ If the path does not exist:
325
+ returns ``None``
326
+
327
+ If the path exists but is not a directory:
328
+ returns ``False``
329
+ """
330
+
331
+ type = "directory"
26
332
 
27
333
 
28
334
  class Socket(File):
29
- type = 'socket'
335
+ """
336
+ Returns information about a socket on the remote system:
30
337
 
338
+ .. code:: python
31
339
 
32
- class Sha1File(FactBase):
33
- '''
34
- Returns a SHA1 hash of a file. Works with both sha1sum and sha1.
35
- '''
340
+ {
341
+ "user": "pyinfra",
342
+ "group": "pyinfra",
343
+ }
36
344
 
37
- _regexes = [
38
- r'^([a-zA-Z0-9]{40})\s+%s$',
39
- r'^SHA1\s+\(%s\)\s+=\s+([a-zA-Z0-9]{40})$',
40
- ]
345
+ If the path does not exist:
346
+ returns ``None``
41
347
 
42
- def command(self, name):
43
- self.name = name
44
- return 'sha1sum {0} || sha1 {0}'.format(name)
348
+ If the path exists but is not a socket:
349
+ returns ``False``
350
+ """
45
351
 
46
- def process(self, output):
47
- for regex in self._regexes:
48
- regex = regex % self.name
49
- matches = re.match(regex, output[0])
352
+ type = "socket"
353
+
354
+
355
+ if TYPE_CHECKING:
356
+ FactBaseOptionalStr = FactBase[Optional[str]]
357
+ else:
358
+ FactBaseOptionalStr = FactBase
359
+
360
+
361
+ class HashFileFactBase(FactBaseOptionalStr):
362
+ _raw_cmd: str
363
+ _regexes: Tuple[str, str]
364
+
365
+ @override
366
+ def __init_subclass__(cls, digits: int, cmds: List[str], **kwargs) -> None:
367
+ super().__init_subclass__(**kwargs)
368
+
369
+ raw_hash_cmds = ["%s {0} 2> /dev/null" % cmd for cmd in cmds]
370
+ raw_hash_cmd = " || ".join(raw_hash_cmds)
371
+ cls._raw_cmd = "test -e {0} && ( %s ) || true" % raw_hash_cmd
50
372
 
373
+ assert cls.__name__.endswith("File")
374
+ hash_name = cls.__name__[:-4].upper()
375
+ cls._regexes = (
376
+ # GNU coreutils style:
377
+ r"^([a-fA-F0-9]{%d})\s+%%s$" % digits,
378
+ # BSD style:
379
+ r"^%s\s+\(%%s\)\s+=\s+([a-fA-F0-9]{%d})$" % (hash_name, digits),
380
+ )
381
+
382
+ @override
383
+ def command(self, path):
384
+ self.path = path
385
+ return make_formatted_string_command(self._raw_cmd, QuoteString(path))
386
+
387
+ @override
388
+ def process(self, output) -> Optional[str]:
389
+ output = output[0]
390
+ escaped_path = re.escape(self.path)
391
+ for regex in self._regexes:
392
+ matches = re.match(regex % escaped_path, output)
51
393
  if matches:
52
394
  return matches.group(1)
395
+ return None
53
396
 
54
397
 
55
- class FindInFile(FactBase):
56
- '''
57
- Checks for the existence of text in a file using grep. Returns a list of matching
58
- lines if the file exists, and ``None`` if the file does not.
59
- '''
398
+ class Sha1File(HashFileFactBase, digits=40, cmds=["sha1sum", "shasum", "sha1"]):
399
+ """
400
+ Returns a SHA1 hash of a file. Works with both sha1sum and sha1. Returns
401
+ ``None`` if the file doest not exist.
402
+ """
403
+
404
+
405
+ class Sha256File(HashFileFactBase, digits=64, cmds=["sha256sum", "shasum -a 256", "sha256"]):
406
+ """
407
+ Returns a SHA256 hash of a file, or ``None`` if the file does not exist.
408
+ """
409
+
410
+
411
+ class Sha384File(HashFileFactBase, digits=96, cmds=["sha384sum", "shasum -a 384", "sha384"]):
412
+ """
413
+ Returns a SHA384 hash of a file, or ``None`` if the file does not exist.
414
+ """
415
+
60
416
 
61
- def command(self, name, pattern):
62
- self.name = name
417
+ class Md5File(HashFileFactBase, digits=32, cmds=["md5sum", "md5"]):
418
+ """
419
+ Returns an MD5 hash of a file, or ``None`` if the file does not exist.
420
+ """
63
421
 
64
- return '''
65
- grep "{0}" {1} || (find {1} -type f > /dev/null && echo "__pyinfra_exists_{1}")
66
- '''.format(pattern, name).strip()
67
422
 
423
+ class FindInFile(FactBase):
424
+ """
425
+ Checks for the existence of text in a file using grep. Returns a list of matching
426
+ lines if the file exists, and ``None`` if the file does not.
427
+ """
428
+
429
+ @override
430
+ def command(self, path, pattern, interpolate_variables=False):
431
+ self.exists_flag = "__pyinfra_exists_{0}".format(path)
432
+
433
+ if interpolate_variables:
434
+ pattern = '"{0}"'.format(pattern.replace('"', '\\"'))
435
+ else:
436
+ pattern = QuoteString(pattern)
437
+
438
+ return make_formatted_string_command(
439
+ (
440
+ "grep -e {0} {1} 2> /dev/null || "
441
+ "( find {1} -type f > /dev/null && echo {2} || true )"
442
+ ),
443
+ pattern,
444
+ QuoteString(path),
445
+ QuoteString(self.exists_flag),
446
+ )
447
+
448
+ @override
68
449
  def process(self, output):
69
450
  # If output is the special string: no matches, so return an empty list;
70
451
  # this allows us to differentiate between no matches in an existing file
71
452
  # or a file not existing.
72
- if output and output[0] == '__pyinfra_exists_{0}'.format(self.name):
453
+ if output and output[0] == self.exists_flag:
73
454
  return []
74
455
 
75
456
  return output
76
457
 
77
458
 
78
- class FindFiles(FactBase):
79
- '''
80
- Returns a list of files from a start point, recursively using find.
81
- '''
82
-
83
- def command(self, name):
84
- return 'find {0} -type f'.format(name)
459
+ class FindFilesBase(FactBase):
460
+ abstract = True
461
+ default = list
462
+ type_flag: str
85
463
 
464
+ @override
86
465
  def process(self, output):
87
466
  return output
88
467
 
468
+ @override
469
+ def command(
470
+ self,
471
+ path: str,
472
+ size: Optional[str | int] = None,
473
+ min_size: Optional[str | int] = None,
474
+ max_size: Optional[str | int] = None,
475
+ maxdepth: Optional[int] = None,
476
+ fname: Optional[str] = None,
477
+ iname: Optional[str] = None,
478
+ regex: Optional[str] = None,
479
+ args: Optional[List[str]] = None,
480
+ quote_path=True,
481
+ ):
482
+ """
483
+ @param path: the path to start the search from
484
+ @param size: exact size in bytes or human-readable format.
485
+ GB means 1e9 bytes, GiB means 2^30 bytes
486
+ @param min_size: minimum size in bytes or human-readable format
487
+ @param max_size: maximum size in bytes or human-readable format
488
+ @param maxdepth: maximum depth to descend to
489
+ @param name: True if the last component of the pathname being examined matches pattern.
490
+ Special shell pattern matching characters (“[”, “]”, “*”, and “?”)
491
+ may be used as part of pattern.
492
+ These characters may be matched explicitly
493
+ by escaping them with a backslash (“\\”).
494
+
495
+ @param iname: Like -name, but the match is case insensitive.
496
+ @param regex: True if the whole path of the file matches pattern using regular expression.
497
+ @param args: additional arguments to pass to find
498
+ @param quote_path: if the path should be quoted
499
+ @return:
500
+ """
501
+ if args is None:
502
+ args = []
503
+
504
+ def maybe_quote(value):
505
+ return QuoteString(value) if quote_path else value
506
+
507
+ command = [
508
+ "find",
509
+ maybe_quote(path),
510
+ "-type",
511
+ self.type_flag,
512
+ ]
513
+
514
+ """
515
+ Why we need special handling for size:
516
+ https://unix.stackexchange.com/questions/275925/why-does-find-size-1g-not-find-any-files
517
+ In short, 'c' means bytes, without it, it means 512-byte blocks.
518
+ If we use any units other than 'c', it has a weird rounding behavior,
519
+ and is implementation-specific. So, we always use 'c'
520
+ """
521
+ if "-size" not in args:
522
+ if min_size is not None:
523
+ command.append("-size")
524
+ command.append("+{0}c".format(parse_size(min_size)))
525
+
526
+ if max_size is not None:
527
+ command.append("-size")
528
+ command.append("-{0}c".format(parse_size(max_size)))
529
+
530
+ if size is not None:
531
+ command.append("-size")
532
+ command.append("{0}c".format(size))
533
+
534
+ if maxdepth is not None and "-maxdepth" not in args:
535
+ command.append("-maxdepth")
536
+ command.append("{0}".format(maxdepth))
537
+
538
+ if fname is not None and "-fname" not in args:
539
+ command.append("-name")
540
+ command.append(maybe_quote(fname))
541
+
542
+ if iname is not None and "-iname" not in args:
543
+ command.append("-iname")
544
+ command.append(maybe_quote(iname))
545
+
546
+ if regex is not None and "-regex" not in args:
547
+ command.append("-regex")
548
+ command.append(maybe_quote(regex))
549
+
550
+ command.append("||")
551
+ command.append("true")
552
+
553
+ return StringCommand(*command)
554
+
555
+
556
+ class FindFiles(FindFilesBase):
557
+ """
558
+ Returns a list of files from a start path, recursively using ``find``.
559
+ """
560
+
561
+ type_flag = "f"
562
+
563
+
564
+ class FindLinks(FindFilesBase):
565
+ """
566
+ Returns a list of links from a start path, recursively using ``find``.
567
+ """
568
+
569
+ type_flag = "l"
570
+
571
+
572
+ class FindDirectories(FindFilesBase):
573
+ """
574
+ Returns a list of directories from a start path, recursively using ``find``.
575
+ """
576
+
577
+ type_flag = "d"
578
+
579
+
580
+ class Flags(FactBase):
581
+ """
582
+ Returns a list of the file flags set for the specified file or directory.
583
+ """
584
+
585
+ @override
586
+ def requires_command(self, path) -> str:
587
+ return "chflags" # don't try to retrieve them if we can't set them
588
+
589
+ @override
590
+ def command(self, path):
591
+ return make_formatted_string_command(
592
+ "! test -e {0} || stat -f %Sf {0}",
593
+ QuoteString(path),
594
+ )
89
595
 
90
- class FindLinks(FindFiles):
91
- '''
92
- Returns a list of links from a start point, recursively using find.
93
- '''
596
+ @override
597
+ def process(self, output):
598
+ return [flag for flag in output[0].split(",") if len(flag) > 0] if len(output) == 1 else []
599
+
600
+
601
+ MARKER_DEFAULT = "# {mark} PYINFRA BLOCK"
602
+ MARKER_BEGIN_DEFAULT = "BEGIN"
603
+ MARKER_END_DEFAULT = "END"
604
+ EXISTS = "__pyinfra_exists_"
605
+ MISSING = "__pyinfra_missing_"
606
+
607
+
608
+ class Block(FactBase):
609
+ """
610
+ Returns a (possibly empty) list of the lines found between the markers.
611
+
612
+ .. code:: python
613
+
614
+ [
615
+ "xray: one",
616
+ "alpha: two"
617
+ ]
618
+
619
+ If the ``path`` doesn't exist
620
+ returns ``None``
621
+
622
+ If the ``path`` exists but the markers are not found
623
+ returns ``[]``
624
+ """
625
+
626
+ # if markers aren't found, awk will return 0 and produce no output but we need to
627
+ # distinguish between "markers not found" and "markers found but nothing between them"
628
+ # for the former we use the empty list (created the call to default) and for the latter
629
+ # the list with a single empty string.
630
+ default = list
631
+
632
+ @override
633
+ def command(self, path, marker=None, begin=None, end=None):
634
+ self.path = path
635
+ start = (marker or MARKER_DEFAULT).format(mark=begin or MARKER_BEGIN_DEFAULT)
636
+ end = (marker or MARKER_DEFAULT).format(mark=end or MARKER_END_DEFAULT)
637
+ if start == end:
638
+ raise ValueError(f"delimiters for block must be different but found only '{start}'")
639
+
640
+ backstop = make_formatted_string_command(
641
+ "(find {0} -type f > /dev/null && echo {1} || echo {2} )",
642
+ QuoteString(path),
643
+ QuoteString(f"{EXISTS}{path}"),
644
+ QuoteString(f"{MISSING}{path}"),
645
+ )
646
+
647
+ cmd = StringCommand(
648
+ f"awk '/{end}/{{ f=0}} f; /{start}/{{ f=1}} ' ",
649
+ QuoteString(path),
650
+ " || ",
651
+ backstop,
652
+ _separator="",
653
+ )
654
+ return cmd
655
+
656
+ @override
657
+ def process(self, output):
658
+ if output and (output[0] == f"{EXISTS}{self.path}"):
659
+ return []
660
+ if output and (output[0] == f"{MISSING}{self.path}"):
661
+ return None
662
+ return output
94
663
 
95
- def command(self, name):
96
- return 'find {0} -type l'.format(name)
97
664
 
665
+ class FileContents(FactBase):
666
+ """
667
+ Returns the contents of a file as a list of lines. Works with both sha1sum and sha1. Returns
668
+ ``None`` if the file doest not exist.
669
+ """
98
670
 
99
- class FindDirectories(FindFiles):
100
- '''
101
- Returns a list of directories from a start point, recursively using find.
102
- '''
671
+ @override
672
+ def command(self, path):
673
+ return make_formatted_string_command("cat {0}", QuoteString(path))
103
674
 
104
- def command(self, name):
105
- return 'find {0} -type d'.format(name)
675
+ @override
676
+ def process(self, output):
677
+ return output