pyinfra 0.11.dev3__py3-none-any.whl → 3.6__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 (204) hide show
  1. pyinfra/__init__.py +9 -12
  2. pyinfra/__main__.py +4 -0
  3. pyinfra/api/__init__.py +19 -3
  4. pyinfra/api/arguments.py +413 -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 +73 -18
  12. pyinfra/api/facts.py +267 -200
  13. pyinfra/api/host.py +416 -50
  14. pyinfra/api/inventory.py +121 -160
  15. pyinfra/api/metadata.py +69 -0
  16. pyinfra/api/operation.py +432 -262
  17. pyinfra/api/operations.py +273 -260
  18. pyinfra/api/state.py +302 -248
  19. pyinfra/api/util.py +309 -369
  20. pyinfra/connectors/base.py +173 -0
  21. pyinfra/connectors/chroot.py +212 -0
  22. pyinfra/connectors/docker.py +405 -0
  23. pyinfra/connectors/dockerssh.py +297 -0
  24. pyinfra/connectors/local.py +238 -0
  25. pyinfra/connectors/scp/__init__.py +1 -0
  26. pyinfra/connectors/scp/client.py +204 -0
  27. pyinfra/connectors/ssh.py +727 -0
  28. pyinfra/connectors/ssh_util.py +114 -0
  29. pyinfra/connectors/sshuserclient/client.py +309 -0
  30. pyinfra/connectors/sshuserclient/config.py +102 -0
  31. pyinfra/connectors/terraform.py +135 -0
  32. pyinfra/connectors/util.py +417 -0
  33. pyinfra/connectors/vagrant.py +183 -0
  34. pyinfra/context.py +145 -0
  35. pyinfra/facts/__init__.py +7 -6
  36. pyinfra/facts/apk.py +22 -7
  37. pyinfra/facts/apt.py +117 -60
  38. pyinfra/facts/brew.py +100 -15
  39. pyinfra/facts/bsdinit.py +23 -0
  40. pyinfra/facts/cargo.py +37 -0
  41. pyinfra/facts/choco.py +47 -0
  42. pyinfra/facts/crontab.py +195 -0
  43. pyinfra/facts/deb.py +94 -0
  44. pyinfra/facts/dnf.py +48 -0
  45. pyinfra/facts/docker.py +96 -23
  46. pyinfra/facts/efibootmgr.py +113 -0
  47. pyinfra/facts/files.py +629 -58
  48. pyinfra/facts/flatpak.py +77 -0
  49. pyinfra/facts/freebsd.py +70 -0
  50. pyinfra/facts/gem.py +19 -6
  51. pyinfra/facts/git.py +59 -14
  52. pyinfra/facts/gpg.py +150 -0
  53. pyinfra/facts/hardware.py +313 -167
  54. pyinfra/facts/iptables.py +72 -62
  55. pyinfra/facts/launchd.py +44 -0
  56. pyinfra/facts/lxd.py +17 -4
  57. pyinfra/facts/mysql.py +122 -86
  58. pyinfra/facts/npm.py +17 -9
  59. pyinfra/facts/openrc.py +71 -0
  60. pyinfra/facts/opkg.py +246 -0
  61. pyinfra/facts/pacman.py +50 -7
  62. pyinfra/facts/pip.py +24 -7
  63. pyinfra/facts/pipx.py +82 -0
  64. pyinfra/facts/pkg.py +15 -6
  65. pyinfra/facts/pkgin.py +35 -0
  66. pyinfra/facts/podman.py +54 -0
  67. pyinfra/facts/postgres.py +178 -0
  68. pyinfra/facts/postgresql.py +6 -147
  69. pyinfra/facts/rpm.py +105 -0
  70. pyinfra/facts/runit.py +77 -0
  71. pyinfra/facts/selinux.py +161 -0
  72. pyinfra/facts/server.py +762 -285
  73. pyinfra/facts/snap.py +88 -0
  74. pyinfra/facts/systemd.py +139 -0
  75. pyinfra/facts/sysvinit.py +59 -0
  76. pyinfra/facts/upstart.py +35 -0
  77. pyinfra/facts/util/__init__.py +17 -0
  78. pyinfra/facts/util/databases.py +4 -6
  79. pyinfra/facts/util/packaging.py +37 -6
  80. pyinfra/facts/util/units.py +30 -0
  81. pyinfra/facts/util/win_files.py +99 -0
  82. pyinfra/facts/vzctl.py +20 -13
  83. pyinfra/facts/xbps.py +35 -0
  84. pyinfra/facts/yum.py +34 -40
  85. pyinfra/facts/zfs.py +77 -0
  86. pyinfra/facts/zypper.py +42 -0
  87. pyinfra/local.py +45 -83
  88. pyinfra/operations/__init__.py +12 -0
  89. pyinfra/operations/apk.py +99 -0
  90. pyinfra/operations/apt.py +496 -0
  91. pyinfra/operations/brew.py +232 -0
  92. pyinfra/operations/bsdinit.py +59 -0
  93. pyinfra/operations/cargo.py +45 -0
  94. pyinfra/operations/choco.py +61 -0
  95. pyinfra/operations/crontab.py +194 -0
  96. pyinfra/operations/dnf.py +213 -0
  97. pyinfra/operations/docker.py +492 -0
  98. pyinfra/operations/files.py +2014 -0
  99. pyinfra/operations/flatpak.py +95 -0
  100. pyinfra/operations/freebsd/__init__.py +12 -0
  101. pyinfra/operations/freebsd/freebsd_update.py +70 -0
  102. pyinfra/operations/freebsd/pkg.py +219 -0
  103. pyinfra/operations/freebsd/service.py +116 -0
  104. pyinfra/operations/freebsd/sysrc.py +92 -0
  105. pyinfra/operations/gem.py +48 -0
  106. pyinfra/operations/git.py +420 -0
  107. pyinfra/operations/iptables.py +312 -0
  108. pyinfra/operations/launchd.py +45 -0
  109. pyinfra/operations/lxd.py +69 -0
  110. pyinfra/operations/mysql.py +610 -0
  111. pyinfra/operations/npm.py +57 -0
  112. pyinfra/operations/openrc.py +63 -0
  113. pyinfra/operations/opkg.py +89 -0
  114. pyinfra/operations/pacman.py +82 -0
  115. pyinfra/operations/pip.py +206 -0
  116. pyinfra/operations/pipx.py +103 -0
  117. pyinfra/operations/pkg.py +71 -0
  118. pyinfra/operations/pkgin.py +92 -0
  119. pyinfra/operations/postgres.py +437 -0
  120. pyinfra/operations/postgresql.py +30 -0
  121. pyinfra/operations/puppet.py +41 -0
  122. pyinfra/operations/python.py +73 -0
  123. pyinfra/operations/runit.py +184 -0
  124. pyinfra/operations/selinux.py +190 -0
  125. pyinfra/operations/server.py +1100 -0
  126. pyinfra/operations/snap.py +118 -0
  127. pyinfra/operations/ssh.py +217 -0
  128. pyinfra/operations/systemd.py +150 -0
  129. pyinfra/operations/sysvinit.py +142 -0
  130. pyinfra/operations/upstart.py +68 -0
  131. pyinfra/operations/util/__init__.py +12 -0
  132. pyinfra/operations/util/docker.py +407 -0
  133. pyinfra/operations/util/files.py +247 -0
  134. pyinfra/operations/util/packaging.py +338 -0
  135. pyinfra/operations/util/service.py +46 -0
  136. pyinfra/operations/vzctl.py +137 -0
  137. pyinfra/operations/xbps.py +78 -0
  138. pyinfra/operations/yum.py +213 -0
  139. pyinfra/operations/zfs.py +176 -0
  140. pyinfra/operations/zypper.py +193 -0
  141. pyinfra/progress.py +44 -32
  142. pyinfra/py.typed +0 -0
  143. pyinfra/version.py +9 -1
  144. pyinfra-3.6.dist-info/METADATA +142 -0
  145. pyinfra-3.6.dist-info/RECORD +160 -0
  146. {pyinfra-0.11.dev3.dist-info → pyinfra-3.6.dist-info}/WHEEL +1 -2
  147. pyinfra-3.6.dist-info/entry_points.txt +12 -0
  148. {pyinfra-0.11.dev3.dist-info → pyinfra-3.6.dist-info/licenses}/LICENSE.md +1 -1
  149. pyinfra_cli/__init__.py +1 -0
  150. pyinfra_cli/cli.py +793 -0
  151. pyinfra_cli/commands.py +66 -0
  152. pyinfra_cli/exceptions.py +155 -65
  153. pyinfra_cli/inventory.py +233 -89
  154. pyinfra_cli/log.py +39 -43
  155. pyinfra_cli/main.py +26 -495
  156. pyinfra_cli/prints.py +215 -156
  157. pyinfra_cli/util.py +172 -105
  158. pyinfra_cli/virtualenv.py +25 -20
  159. pyinfra/api/connectors/__init__.py +0 -21
  160. pyinfra/api/connectors/ansible.py +0 -99
  161. pyinfra/api/connectors/docker.py +0 -178
  162. pyinfra/api/connectors/local.py +0 -169
  163. pyinfra/api/connectors/ssh.py +0 -402
  164. pyinfra/api/connectors/sshuserclient/client.py +0 -105
  165. pyinfra/api/connectors/sshuserclient/config.py +0 -90
  166. pyinfra/api/connectors/util.py +0 -63
  167. pyinfra/api/connectors/vagrant.py +0 -155
  168. pyinfra/facts/init.py +0 -176
  169. pyinfra/facts/util/files.py +0 -102
  170. pyinfra/hook.py +0 -41
  171. pyinfra/modules/__init__.py +0 -11
  172. pyinfra/modules/apk.py +0 -64
  173. pyinfra/modules/apt.py +0 -272
  174. pyinfra/modules/brew.py +0 -122
  175. pyinfra/modules/files.py +0 -711
  176. pyinfra/modules/gem.py +0 -30
  177. pyinfra/modules/git.py +0 -115
  178. pyinfra/modules/init.py +0 -344
  179. pyinfra/modules/iptables.py +0 -271
  180. pyinfra/modules/lxd.py +0 -45
  181. pyinfra/modules/mysql.py +0 -347
  182. pyinfra/modules/npm.py +0 -47
  183. pyinfra/modules/pacman.py +0 -60
  184. pyinfra/modules/pip.py +0 -99
  185. pyinfra/modules/pkg.py +0 -43
  186. pyinfra/modules/postgresql.py +0 -245
  187. pyinfra/modules/puppet.py +0 -20
  188. pyinfra/modules/python.py +0 -37
  189. pyinfra/modules/server.py +0 -524
  190. pyinfra/modules/ssh.py +0 -150
  191. pyinfra/modules/util/files.py +0 -52
  192. pyinfra/modules/util/packaging.py +0 -118
  193. pyinfra/modules/vzctl.py +0 -133
  194. pyinfra/modules/yum.py +0 -171
  195. pyinfra/pseudo_modules.py +0 -64
  196. pyinfra-0.11.dev3.dist-info/.DS_Store +0 -0
  197. pyinfra-0.11.dev3.dist-info/METADATA +0 -135
  198. pyinfra-0.11.dev3.dist-info/RECORD +0 -95
  199. pyinfra-0.11.dev3.dist-info/entry_points.txt +0 -3
  200. pyinfra-0.11.dev3.dist-info/top_level.txt +0 -2
  201. pyinfra_cli/__main__.py +0 -40
  202. pyinfra_cli/config.py +0 -92
  203. /pyinfra/{modules/util → connectors}/__init__.py +0 -0
  204. /pyinfra/{api/connectors → connectors}/sshuserclient/__init__.py +0 -0
pyinfra/facts/files.py CHANGED
@@ -1,105 +1,676 @@
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. Returns ``None`` if the file does not exist.
668
+ """
98
669
 
99
- class FindDirectories(FindFiles):
100
- '''
101
- Returns a list of directories from a start point, recursively using find.
102
- '''
670
+ @override
671
+ def command(self, path):
672
+ return make_formatted_string_command("cat {0}", QuoteString(path))
103
673
 
104
- def command(self, name):
105
- return 'find {0} -type d'.format(name)
674
+ @override
675
+ def process(self, output):
676
+ return output