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
@@ -0,0 +1,1939 @@
1
+ """
2
+ The files operations handles filesystem state, file uploads and template generation.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import posixpath
9
+ import sys
10
+ import traceback
11
+ from datetime import datetime, timedelta, timezone
12
+ from fnmatch import fnmatch
13
+ from io import StringIO
14
+ from pathlib import Path
15
+ from typing import IO, Any, Union
16
+
17
+ import click
18
+ from jinja2 import TemplateRuntimeError, TemplateSyntaxError, UndefinedError
19
+
20
+ from pyinfra import host, logger, state
21
+ from pyinfra.api import (
22
+ FileDownloadCommand,
23
+ FileUploadCommand,
24
+ OperationError,
25
+ OperationTypeError,
26
+ OperationValueError,
27
+ QuoteString,
28
+ RsyncCommand,
29
+ StringCommand,
30
+ operation,
31
+ )
32
+ from pyinfra.api.command import make_formatted_string_command
33
+ from pyinfra.api.util import (
34
+ get_call_location,
35
+ get_file_io,
36
+ get_file_md5,
37
+ get_file_sha1,
38
+ get_file_sha256,
39
+ get_path_permissions_mode,
40
+ get_template,
41
+ memoize,
42
+ )
43
+ from pyinfra.facts.files import (
44
+ MARKER_BEGIN_DEFAULT,
45
+ MARKER_DEFAULT,
46
+ MARKER_END_DEFAULT,
47
+ Block,
48
+ Directory,
49
+ File,
50
+ FileContents,
51
+ FindFiles,
52
+ FindInFile,
53
+ Flags,
54
+ Link,
55
+ Md5File,
56
+ Sha1File,
57
+ Sha256File,
58
+ Sha384File,
59
+ )
60
+ from pyinfra.facts.server import Date, Which
61
+
62
+ from .util import files as file_utils
63
+ from .util.files import (
64
+ MetadataTimeField,
65
+ adjust_regex,
66
+ ensure_mode_int,
67
+ generate_color_diff,
68
+ get_timestamp,
69
+ sed_delete,
70
+ sed_replace,
71
+ unix_path_join,
72
+ )
73
+
74
+
75
+ @operation()
76
+ def download(
77
+ src: str,
78
+ dest: str,
79
+ user: str | None = None,
80
+ group: str | None = None,
81
+ mode: str | None = None,
82
+ cache_time: int | None = None,
83
+ force=False,
84
+ sha384sum: str | None = None,
85
+ sha256sum: str | None = None,
86
+ sha1sum: str | None = None,
87
+ md5sum: str | None = None,
88
+ headers: dict[str, str] | None = None,
89
+ insecure=False,
90
+ proxy: str | None = None,
91
+ temp_dir: str | Path | None = None,
92
+ extra_curl_args: dict[str, str] | None = None,
93
+ extra_wget_args: dict[str, str] | None = None,
94
+ ):
95
+ """
96
+ Download files from remote locations using ``curl`` or ``wget``.
97
+
98
+ + src: source URL of the file
99
+ + dest: where to save the file
100
+ + user: user to own the files
101
+ + group: group to own the files
102
+ + mode: permissions of the files
103
+ + cache_time: if the file exists already, re-download after this time (in seconds)
104
+ + force: always download the file, even if it already exists
105
+ + sha384sum: sha384 hash to checksum the downloaded file against
106
+ + sha256sum: sha256 hash to checksum the downloaded file against
107
+ + sha1sum: sha1 hash to checksum the downloaded file against
108
+ + md5sum: md5 hash to checksum the downloaded file against
109
+ + headers: optional dictionary of headers to set for the HTTP request
110
+ + insecure: disable SSL verification for the HTTP request
111
+ + proxy: simple HTTP proxy through which we can download files, form `http://<yourproxy>:<port>`
112
+ + temp_dir: use this custom temporary directory during the download
113
+ + extra_curl_args: optional dictionary with custom arguments for curl
114
+ + extra_wget_args: optional dictionary with custom arguments for wget
115
+
116
+ **Example:**
117
+
118
+ .. code:: python
119
+
120
+ files.download(
121
+ name="Download the Docker repo file",
122
+ src="https://download.docker.com/linux/centos/docker-ce.repo",
123
+ dest="/etc/yum.repos.d/docker-ce.repo",
124
+ )
125
+ """
126
+
127
+ info = host.get_fact(File, path=dest)
128
+
129
+ # Destination is a directory?
130
+ if info is False:
131
+ raise OperationError(
132
+ "Destination {0} already exists and is not a file".format(dest),
133
+ )
134
+
135
+ # Do we download the file? Force by default
136
+ download = force
137
+
138
+ # Doesn't exist, lets download it
139
+ if info is None:
140
+ download = True
141
+
142
+ # Destination file exists & cache_time: check when the file was last modified,
143
+ # download if old
144
+ else:
145
+ if cache_time:
146
+ # Time on files is not tz-aware, and will be the same tz as the server's time,
147
+ # so we can safely remove the tzinfo from the Date fact before comparison.
148
+ ctime = host.get_fact(Date).replace(tzinfo=None) - timedelta(seconds=cache_time)
149
+ if info["mtime"] and info["mtime"] < ctime:
150
+ download = True
151
+
152
+ if sha1sum:
153
+ if sha1sum != host.get_fact(Sha1File, path=dest):
154
+ download = True
155
+
156
+ if sha256sum:
157
+ if sha256sum != host.get_fact(Sha256File, path=dest):
158
+ download = True
159
+
160
+ if sha384sum:
161
+ if sha384sum != host.get_fact(Sha384File, path=dest):
162
+ download = True
163
+
164
+ if md5sum:
165
+ if md5sum != host.get_fact(Md5File, path=dest):
166
+ download = True
167
+
168
+ # If we download, always do user/group/mode as SSH user may be different
169
+ if download:
170
+ temp_file = host.get_temp_filename(
171
+ dest, temp_directory=str(temp_dir) if temp_dir is not None else None
172
+ )
173
+
174
+ curl_args: list[Union[str, StringCommand]] = ["-sSLf"]
175
+ wget_args: list[Union[str, StringCommand]] = ["-q"]
176
+
177
+ if extra_curl_args:
178
+ for key, value in extra_curl_args.items():
179
+ curl_args.append(StringCommand(key, QuoteString(value)))
180
+
181
+ if extra_wget_args:
182
+ for key, value in extra_wget_args.items():
183
+ wget_args.append(StringCommand(key, QuoteString(value)))
184
+
185
+ if proxy:
186
+ curl_args.append(f"--proxy {proxy}")
187
+ wget_args.append("-e use_proxy=yes")
188
+ wget_args.append(f"-e http_proxy={proxy}")
189
+
190
+ if insecure:
191
+ curl_args.append("--insecure")
192
+ wget_args.append("--no-check-certificate")
193
+
194
+ if headers:
195
+ for key, value in headers.items():
196
+ header_arg = StringCommand("--header", QuoteString(f"{key}: {value}"))
197
+ curl_args.append(header_arg)
198
+ wget_args.append(header_arg)
199
+
200
+ curl_command = make_formatted_string_command(
201
+ "curl {0} {1} -o {2}",
202
+ StringCommand(*curl_args),
203
+ QuoteString(src),
204
+ QuoteString(temp_file),
205
+ )
206
+ wget_command = make_formatted_string_command(
207
+ "wget {0} {1} -O {2} || ( rm -f {2} ; exit 1 )",
208
+ StringCommand(*wget_args),
209
+ QuoteString(src),
210
+ QuoteString(temp_file),
211
+ )
212
+
213
+ if host.get_fact(Which, command="curl"):
214
+ yield curl_command
215
+ elif host.get_fact(Which, command="wget"):
216
+ yield wget_command
217
+ else:
218
+ yield "( {0} ) || ( {1} )".format(curl_command, wget_command)
219
+
220
+ yield StringCommand("mv", QuoteString(temp_file), QuoteString(dest))
221
+
222
+ if user or group:
223
+ yield file_utils.chown(dest, user, group)
224
+
225
+ if mode:
226
+ yield file_utils.chmod(dest, mode)
227
+
228
+ if sha1sum:
229
+ yield make_formatted_string_command(
230
+ (
231
+ "(( sha1sum {0} 2> /dev/null || shasum {0} || sha1 {0} ) | grep {1} ) "
232
+ "|| ( echo {2} && exit 1 )"
233
+ ),
234
+ QuoteString(dest),
235
+ sha1sum,
236
+ QuoteString("SHA1 did not match!"),
237
+ )
238
+
239
+ if sha256sum:
240
+ yield make_formatted_string_command(
241
+ (
242
+ "(( sha256sum {0} 2> /dev/null || shasum -a 256 {0} || sha256 {0} ) "
243
+ "| grep {1}) || ( echo {2} && exit 1 )"
244
+ ),
245
+ QuoteString(dest),
246
+ sha256sum,
247
+ QuoteString("SHA256 did not match!"),
248
+ )
249
+
250
+ if sha384sum:
251
+ yield make_formatted_string_command(
252
+ (
253
+ "(( sha384sum {0} 2> /dev/null || shasum -a 384 {0} ) "
254
+ "| grep {1}) || ( echo {2} && exit 1 )"
255
+ ),
256
+ QuoteString(dest),
257
+ sha384sum,
258
+ QuoteString("SHA384 did not match!"),
259
+ )
260
+
261
+ if md5sum:
262
+ yield make_formatted_string_command(
263
+ ("(( md5sum {0} 2> /dev/null || md5 {0} ) | grep {1}) || ( echo {2} && exit 1 )"),
264
+ QuoteString(dest),
265
+ md5sum,
266
+ QuoteString("MD5 did not match!"),
267
+ )
268
+ else:
269
+ host.noop("file {0} has already been downloaded".format(dest))
270
+
271
+
272
+ @operation()
273
+ def line(
274
+ path: str,
275
+ line: str,
276
+ present=True,
277
+ replace: str | None = None,
278
+ flags: list[str] | None = None,
279
+ backup=False,
280
+ interpolate_variables=False,
281
+ escape_regex_characters=False,
282
+ ensure_newline=False,
283
+ ):
284
+ """
285
+ Ensure lines in files using grep to locate and sed to replace.
286
+
287
+ + path: target remote file to edit
288
+ + line: string or regex matching the target line
289
+ + present: whether the line should be in the file
290
+ + replace: text to replace entire matching lines when ``present=True``
291
+ + flags: list of flags to pass to sed when replacing/deleting
292
+ + backup: whether to backup the file (see below)
293
+ + interpolate_variables: whether to interpolate variables in ``replace``
294
+ + escape_regex_characters: whether to escape regex characters from the matching line
295
+ + ensure_newline: ensures that the appended line is on a new line
296
+
297
+ Regex line matching:
298
+ Unless line matches a line (starts with ^, ends $), pyinfra will wrap it such that
299
+ it does, like: ``^.*LINE.*$``. This means we don't swap parts of lines out. To
300
+ change bits of lines, see ``files.replace``.
301
+
302
+ Regex line escaping:
303
+ If matching special characters (eg a crontab line containing ``*``), remember to escape
304
+ it first using Python's ``re.escape``.
305
+
306
+ Backup:
307
+ If set to ``True``, any editing of the file will place an old copy with the ISO
308
+ date (taken from the machine running ``pyinfra``) appended as the extension. If
309
+ you pass a string value this will be used as the extension of the backed up file.
310
+
311
+ Append:
312
+ If ``line`` is not in the file but we want it (``present`` set to ``True``), then
313
+ it will be append to the end of the file.
314
+
315
+ Ensure new line:
316
+ This will ensure that the ``line`` being appended is always on a separate new
317
+ line in case the file doesn't end with a newline character.
318
+
319
+
320
+ **Examples:**
321
+
322
+ .. code:: python
323
+
324
+ # prepare to do some maintenance
325
+ maintenance_line = "SYSTEM IS DOWN FOR MAINTENANCE"
326
+ files.line(
327
+ name="Add the down-for-maintenance line in /etc/motd",
328
+ path="/etc/motd",
329
+ line=maintenance_line,
330
+ )
331
+
332
+ # Then, after the maintenance is done, remove the maintenance line
333
+ files.line(
334
+ name="Remove the down-for-maintenance line in /etc/motd",
335
+ path="/etc/motd",
336
+ line=maintenance_line,
337
+ replace="",
338
+ present=False,
339
+ )
340
+
341
+ # example where there is '*' in the line
342
+ files.line(
343
+ name="Ensure /netboot/nfs is in /etc/exports",
344
+ path="/etc/exports",
345
+ line=r"/netboot/nfs .*",
346
+ replace="/netboot/nfs *(ro,sync,no_wdelay,insecure_locks,no_root_squash,"
347
+ "insecure,no_subtree_check)",
348
+ )
349
+
350
+ files.line(
351
+ name="Ensure myweb can run /usr/bin/python3 without password",
352
+ path="/etc/sudoers",
353
+ line=r"myweb .*",
354
+ replace="myweb ALL=(ALL) NOPASSWD: /usr/bin/python3",
355
+ )
356
+
357
+ # example when there are double quotes (")
358
+ line = 'QUOTAUSER=""'
359
+ files.line(
360
+ name="Example with double quotes (")",
361
+ path="/etc/adduser.conf",
362
+ line="^{}$".format(line),
363
+ replace=line,
364
+ )
365
+ """
366
+
367
+ match_line = adjust_regex(line, escape_regex_characters)
368
+ #
369
+ # if escape_regex_characters:
370
+ # match_line = re.sub(r"([\.\\\+\*\?\[\^\]\$\(\)\{\}\-])", r"\\\1", match_line)
371
+ #
372
+ # # Ensure we're matching a whole line, note: match may be a partial line so we
373
+ # # put any matches on either side.
374
+ # if not match_line.startswith("^"):
375
+ # match_line = "^.*{0}".format(match_line)
376
+ # if not match_line.endswith("$"):
377
+ # match_line = "{0}.*$".format(match_line)
378
+
379
+ # Is there a matching line in this file?
380
+ present_lines = host.get_fact(
381
+ FindInFile,
382
+ path=path,
383
+ pattern=match_line,
384
+ interpolate_variables=interpolate_variables,
385
+ )
386
+
387
+ # If replace present, use that over the matching line
388
+ if replace:
389
+ line = replace
390
+ # We must provide some kind of replace to sed_replace_command below
391
+ else:
392
+ replace = ""
393
+
394
+ # Save commands for re-use in dynamic script when file not present at fact stage
395
+ if ensure_newline:
396
+ echo_command = make_formatted_string_command(
397
+ "( [ $(tail -c1 {1} | wc -l) -eq 0 ] && echo ; echo {0} ) >> {1}",
398
+ '"{0}"'.format(line) if interpolate_variables else QuoteString(line),
399
+ QuoteString(path),
400
+ )
401
+ else:
402
+ echo_command = make_formatted_string_command(
403
+ "echo {0} >> {1}",
404
+ '"{0}"'.format(line) if interpolate_variables else QuoteString(line),
405
+ QuoteString(path),
406
+ )
407
+
408
+ if backup:
409
+ backup_filename = "{0}.{1}".format(path, get_timestamp())
410
+ echo_command = StringCommand(
411
+ make_formatted_string_command(
412
+ "cp {0} {1} && ",
413
+ QuoteString(path),
414
+ QuoteString(backup_filename),
415
+ ),
416
+ echo_command,
417
+ )
418
+
419
+ sed_replace_command = sed_replace(
420
+ path,
421
+ match_line,
422
+ replace,
423
+ flags=flags,
424
+ backup=backup,
425
+ interpolate_variables=interpolate_variables,
426
+ )
427
+
428
+ # No line and we want it, append it
429
+ if not present_lines and present:
430
+ # If we're doing replacement, only append if the *replacement* line
431
+ # does not exist (as we are appending the replacement).
432
+ if replace:
433
+ # Ensure replace explicitly matches a whole line
434
+ replace_line = replace
435
+ if not replace_line.startswith("^"):
436
+ replace_line = f"^{replace_line}"
437
+ if not replace_line.endswith("$"):
438
+ replace_line = f"{replace_line}$"
439
+
440
+ present_lines = host.get_fact(
441
+ FindInFile,
442
+ path=path,
443
+ pattern=replace_line,
444
+ interpolate_variables=interpolate_variables,
445
+ )
446
+
447
+ if not present_lines:
448
+ yield echo_command
449
+ else:
450
+ host.noop('line "{0}" exists in {1}'.format(replace or line, path))
451
+
452
+ # Line(s) exists and we want to remove them
453
+ elif present_lines and not present:
454
+ yield sed_delete(
455
+ path,
456
+ match_line,
457
+ "",
458
+ flags=flags,
459
+ backup=backup,
460
+ interpolate_variables=interpolate_variables,
461
+ )
462
+
463
+ # Line(s) exists and we have want to ensure they're correct
464
+ elif present_lines and present:
465
+ # If any of lines are different, sed replace them
466
+ if replace and any(line != replace for line in present_lines):
467
+ yield sed_replace_command
468
+ else:
469
+ host.noop('line "{0}" exists in {1}'.format(replace or line, path))
470
+
471
+
472
+ @operation()
473
+ def replace(
474
+ path: str,
475
+ text: str | None = None,
476
+ replace: str | None = None,
477
+ flags: list[str] | None = None,
478
+ backup=False,
479
+ interpolate_variables=False,
480
+ match=None, # deprecated
481
+ ):
482
+ """
483
+ Replace contents of a file using ``sed``.
484
+
485
+ + path: target remote file to edit
486
+ + text: text/regex to match against
487
+ + replace: text to replace with
488
+ + flags: list of flags to pass to sed
489
+ + backup: whether to backup the file (see below)
490
+ + interpolate_variables: whether to interpolate variables in ``replace``
491
+
492
+ Backup:
493
+ If set to ``True``, any editing of the file will place an old copy with the ISO
494
+ date (taken from the machine running ``pyinfra``) appended as the extension. If
495
+ you pass a string value this will be used as the extension of the backed up file.
496
+
497
+ **Example:**
498
+
499
+ .. code:: python
500
+
501
+ files.replace(
502
+ name="Change part of a line in a file",
503
+ path="/etc/motd",
504
+ text="verboten",
505
+ replace="forbidden",
506
+ )
507
+ """
508
+
509
+ if text is None and match:
510
+ text = match
511
+ logger.warning(
512
+ (
513
+ "The `match` argument has been replaced by "
514
+ "`text` in the `files.replace` operation ({0})"
515
+ ).format(get_call_location()),
516
+ )
517
+
518
+ if text is None:
519
+ raise TypeError("Missing argument `text` required in `files.replace` operation")
520
+
521
+ if replace is None:
522
+ raise TypeError("Missing argument `replace` required in `files.replace` operation")
523
+
524
+ existing_lines = host.get_fact(
525
+ FindInFile,
526
+ path=path,
527
+ pattern=text,
528
+ interpolate_variables=interpolate_variables,
529
+ )
530
+
531
+ # Only do the replacement if the file does not exist (it may be created earlier)
532
+ # or we have matching lines.
533
+ if existing_lines is None or existing_lines:
534
+ yield sed_replace(
535
+ path,
536
+ text,
537
+ replace,
538
+ flags=flags,
539
+ backup=backup,
540
+ interpolate_variables=interpolate_variables,
541
+ )
542
+ else:
543
+ host.noop('string "{0}" does not exist in {1}'.format(text, path))
544
+
545
+
546
+ @operation()
547
+ def sync(
548
+ src: str,
549
+ dest: str,
550
+ user: str | None = None,
551
+ group: str | None = None,
552
+ mode: str | None = None,
553
+ dir_mode: str | None = None,
554
+ delete=False,
555
+ exclude: str | list[str] | tuple[str] | None = None,
556
+ exclude_dir: str | list[str] | tuple[str] | None = None,
557
+ add_deploy_dir=True,
558
+ ):
559
+ """
560
+ Syncs a local directory with a remote one, with delete support. Note that delete will
561
+ remove extra files on the remote side, but not extra directories.
562
+
563
+ + src: local directory to sync
564
+ + dest: remote directory to sync to
565
+ + user: user to own the files and directories
566
+ + group: group to own the files and directories
567
+ + mode: permissions of the files
568
+ + dir_mode: permissions of the directories
569
+ + delete: delete remote files not present locally
570
+ + exclude: string or list/tuple of strings to match & exclude files (eg ``*.pyc``)
571
+ + exclude_dir: string or list/tuple of strings to match & exclude directories (eg node_modules)
572
+ + add_deploy_dir: interpret src as relative to deploy directory instead of current directory
573
+
574
+ **Example:**
575
+
576
+ .. code:: python
577
+
578
+ # Sync local files/tempdir to remote /tmp/tempdir
579
+ files.sync(
580
+ name="Sync a local directory with remote",
581
+ src="files/tempdir",
582
+ dest="/tmp/tempdir",
583
+ )
584
+
585
+ Note: ``exclude`` and ``exclude_dir`` use ``fnmatch`` behind the scenes to do the filtering.
586
+
587
+ + ``exclude`` matches against the filename.
588
+ + ``exclude_dir`` matches against the path of the directory, relative to ``src``.
589
+ Since fnmatch does not treat path separators (``/`` or ``\\``) as special characters,
590
+ excluding all directories matching a given name, however deep under ``src`` they are,
591
+ can be done for example with ``exclude_dir=["__pycache__", "*/__pycache__"]``
592
+
593
+ """
594
+ original_src = src # Keep a copy to reference in errors
595
+ src = os.path.normpath(src)
596
+
597
+ # Add deploy directory?
598
+ if add_deploy_dir and state.cwd:
599
+ src = os.path.join(state.cwd, src)
600
+
601
+ # Ensure the source directory exists
602
+ if not os.path.isdir(src):
603
+ raise IOError("No such directory: {0}".format(original_src))
604
+
605
+ # Ensure exclude is a list/tuple
606
+ if exclude is not None:
607
+ if not isinstance(exclude, (list, tuple)):
608
+ exclude = [exclude]
609
+
610
+ # Ensure exclude_dir is a list/tuple
611
+ if exclude_dir is not None:
612
+ if not isinstance(exclude_dir, (list, tuple)):
613
+ exclude_dir = [exclude_dir]
614
+
615
+ put_files = []
616
+ ensure_dirnames = []
617
+ for dirpath, dirnames, filenames in os.walk(src, topdown=True):
618
+ remote_dirpath = Path(os.path.normpath(os.path.relpath(dirpath, src))).as_posix()
619
+
620
+ # Filter excluded dirs
621
+ for child_dir in dirnames[:]:
622
+ child_path = os.path.normpath(os.path.join(remote_dirpath, child_dir))
623
+ if exclude_dir and any(fnmatch(child_path, match) for match in exclude_dir):
624
+ dirnames.remove(child_dir)
625
+
626
+ if remote_dirpath and remote_dirpath != os.path.curdir:
627
+ ensure_dirnames.append((remote_dirpath, get_path_permissions_mode(dirpath)))
628
+
629
+ for filename in filenames:
630
+ full_filename = os.path.join(dirpath, filename)
631
+
632
+ # Should we exclude this file?
633
+ if exclude and any(fnmatch(full_filename, match) for match in exclude):
634
+ continue
635
+
636
+ remote_full_filename = unix_path_join(
637
+ *[
638
+ item
639
+ for item in (dest, remote_dirpath, filename)
640
+ if item and item != os.path.curdir
641
+ ]
642
+ )
643
+ put_files.append((full_filename, remote_full_filename))
644
+
645
+ # Ensure the destination directory - if the destination is a link, ensure
646
+ # the link target is a directory.
647
+ dest_to_ensure = dest
648
+ dest_link_info = host.get_fact(Link, path=dest)
649
+ if dest_link_info:
650
+ dest_to_ensure = dest_link_info["link_target"]
651
+
652
+ yield from directory._inner(
653
+ path=dest_to_ensure,
654
+ user=user,
655
+ group=group,
656
+ mode=dir_mode or get_path_permissions_mode(src),
657
+ )
658
+
659
+ # Ensure any remote dirnames
660
+ for dir_path_curr, dir_mode_curr in ensure_dirnames:
661
+ yield from directory._inner(
662
+ path=unix_path_join(dest, dir_path_curr),
663
+ user=user,
664
+ group=group,
665
+ mode=dir_mode or dir_mode_curr,
666
+ )
667
+
668
+ # Put each file combination
669
+ for local_filename, remote_filename in put_files:
670
+ yield from put._inner(
671
+ src=local_filename,
672
+ dest=remote_filename,
673
+ user=user,
674
+ group=group,
675
+ mode=mode or get_path_permissions_mode(local_filename),
676
+ add_deploy_dir=False,
677
+ create_remote_dir=False, # handled above
678
+ )
679
+
680
+ # Delete any extra files
681
+ if delete:
682
+ remote_filenames = set(host.get_fact(FindFiles, path=dest) or [])
683
+ wanted_filenames = set([remote_filename for _, remote_filename in put_files])
684
+ files_to_delete = remote_filenames - wanted_filenames
685
+ for filename in files_to_delete:
686
+ # Should we exclude this file?
687
+ if exclude and any(fnmatch(filename, match) for match in exclude):
688
+ continue
689
+
690
+ yield from file._inner(path=filename, present=False)
691
+
692
+
693
+ @memoize
694
+ def show_rsync_warning() -> None:
695
+ logger.warning("The `files.rsync` operation is in alpha!")
696
+
697
+
698
+ @operation(is_idempotent=False)
699
+ def rsync(src: str, dest: str, flags: list[str] | None = None):
700
+ """
701
+ Use ``rsync`` to sync a local directory to the remote system. This operation will actually call
702
+ the ``rsync`` binary on your system.
703
+
704
+ .. important::
705
+ The ``files.rsync`` operation is in alpha, and only supported using SSH
706
+ or ``@local`` connectors. When using the SSH connector, rsync will automatically use the
707
+ StrictHostKeyChecking setting, config and known_hosts file (when specified).
708
+
709
+ .. caution::
710
+ When using SSH, the ``files.rsync`` operation only supports the ``sudo`` and ``sudo_user``
711
+ global arguments.
712
+ """
713
+
714
+ if flags is None:
715
+ flags = ["-ax", "--delete"]
716
+ show_rsync_warning()
717
+
718
+ try:
719
+ host.check_can_rsync()
720
+ except NotImplementedError as e:
721
+ raise OperationError(*e.args)
722
+
723
+ yield RsyncCommand(src, dest, flags)
724
+
725
+
726
+ def _create_remote_dir(remote_filename, user, group):
727
+ # Always use POSIX style path as local might be Windows, remote always *nix
728
+ remote_dirname = posixpath.dirname(remote_filename)
729
+ if remote_dirname:
730
+ yield from directory._inner(
731
+ path=remote_dirname,
732
+ user=user,
733
+ group=group,
734
+ _no_check_owner_mode=True, # don't check existing user/mode
735
+ _no_fail_on_link=True, # don't fail if the path is a link
736
+ )
737
+
738
+
739
+ def _file_equal(local_path: str | IO[Any] | None, remote_path: str) -> bool:
740
+ if local_path is None:
741
+ return False
742
+ for fact, get_sum in [
743
+ (Sha1File, get_file_sha1),
744
+ (Md5File, get_file_md5),
745
+ (Sha256File, get_file_sha256),
746
+ ]:
747
+ remote_sum = host.get_fact(fact, path=remote_path)
748
+ if remote_sum:
749
+ local_sum = get_sum(local_path)
750
+ return local_sum == remote_sum
751
+ return False
752
+
753
+
754
+ @operation(
755
+ # We don't (currently) cache the local state, so there's nothing we can
756
+ # update to flag the local file as present.
757
+ is_idempotent=False,
758
+ )
759
+ def get(
760
+ src: str,
761
+ dest: str,
762
+ add_deploy_dir=True,
763
+ create_local_dir=False,
764
+ force=False,
765
+ ):
766
+ """
767
+ Download a file from the remote system.
768
+
769
+ + src: the remote filename to download
770
+ + dest: the local filename to download the file to
771
+ + add_deploy_dir: dest is relative to the deploy directory
772
+ + create_local_dir: create the local directory if it doesn't exist
773
+ + force: always download the file, even if the local copy matches
774
+
775
+ Note:
776
+ This operation is not suitable for large files as it may involve copying
777
+ the remote file before downloading it.
778
+
779
+ **Example:**
780
+
781
+ .. code:: python
782
+
783
+ files.get(
784
+ name="Download a file from a remote",
785
+ src="/etc/centos-release",
786
+ dest="/tmp/whocares",
787
+ )
788
+ """
789
+
790
+ if add_deploy_dir and state.cwd:
791
+ dest = os.path.join(state.cwd, dest)
792
+
793
+ if create_local_dir:
794
+ local_pathname = os.path.dirname(dest)
795
+ if not os.path.exists(local_pathname):
796
+ os.makedirs(local_pathname)
797
+
798
+ remote_file = host.get_fact(File, path=src)
799
+
800
+ # No remote file, so assume exists and download it "blind"
801
+ if not remote_file or force:
802
+ yield FileDownloadCommand(src, dest, remote_temp_filename=host.get_temp_filename(dest))
803
+
804
+ # No local file, so always download
805
+ elif not os.path.exists(dest):
806
+ yield FileDownloadCommand(src, dest, remote_temp_filename=host.get_temp_filename(dest))
807
+
808
+ # Remote file exists - check if it matches our local
809
+ else:
810
+ # Check hash sum, download if needed
811
+ if not _file_equal(dest, src):
812
+ yield FileDownloadCommand(src, dest, remote_temp_filename=host.get_temp_filename(dest))
813
+ else:
814
+ host.noop("file {0} has already been downloaded".format(dest))
815
+
816
+
817
+ def _canonicalize_timespec(field: MetadataTimeField, local_file, timespec):
818
+ if isinstance(timespec, datetime):
819
+ if not timespec.tzinfo:
820
+ # specify remote host timezone
821
+ timespec_with_tz = timespec.replace(tzinfo=host.get_fact(Date).tzinfo)
822
+ return timespec_with_tz
823
+ else:
824
+ return timespec
825
+ elif isinstance(timespec, bool) and timespec:
826
+ lf_ts = (
827
+ os.stat(local_file).st_atime
828
+ if field is MetadataTimeField.ATIME
829
+ else os.stat(local_file).st_mtime
830
+ )
831
+ return datetime.fromtimestamp(lf_ts, tz=timezone.utc)
832
+ else:
833
+ try:
834
+ isodatetime = datetime.fromisoformat(timespec)
835
+ if not isodatetime.tzinfo:
836
+ return isodatetime.replace(tzinfo=host.get_fact(Date).tzinfo)
837
+ else:
838
+ return isodatetime
839
+ except ValueError:
840
+ try:
841
+ timestamp = float(timespec)
842
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc)
843
+ except ValueError:
844
+ # verify there is a remote file matching path in timesrc
845
+ ref_file = host.get_fact(File, path=timespec)
846
+ if ref_file:
847
+ if field is MetadataTimeField.ATIME:
848
+ assert ref_file["atime"] is not None
849
+ return ref_file["atime"].replace(tzinfo=timezone.utc)
850
+ else:
851
+ assert ref_file["mtime"] is not None
852
+ return ref_file["mtime"].replace(tzinfo=timezone.utc)
853
+ else:
854
+ ValueError("Bad argument for `timesspec`: {0}".format(timespec))
855
+
856
+
857
+ # returns True for a visible difference in the second field between the datetime values
858
+ # in the ref's TZ
859
+ def _times_differ_in_s(ref, cand):
860
+ assert ref.tzinfo and cand.tzinfo
861
+ cand_in_ref_tz = cand.astimezone(ref.tzinfo)
862
+ return (abs((cand_in_ref_tz - ref).total_seconds()) >= 1.0) or (
863
+ ref.second != cand_in_ref_tz.second
864
+ )
865
+
866
+
867
+ @operation()
868
+ def put(
869
+ src: str | IO[Any],
870
+ dest: str,
871
+ user: str | None = None,
872
+ group: str | None = None,
873
+ mode: int | str | bool | None = None,
874
+ add_deploy_dir=True,
875
+ create_remote_dir=True,
876
+ force=False,
877
+ assume_exists=False,
878
+ atime: datetime | float | int | str | bool | None = None,
879
+ mtime: datetime | float | int | str | bool | None = None,
880
+ ):
881
+ """
882
+ Upload a local file, or file-like object, to the remote system.
883
+
884
+ + src: filename or IO-like object to upload
885
+ + dest: remote filename to upload to
886
+ + user: user to own the files
887
+ + group: group to own the files
888
+ + mode: permissions of the files, use ``True`` to copy the local file
889
+ + add_deploy_dir: src is relative to the deploy directory
890
+ + create_remote_dir: create the remote directory if it doesn't exist
891
+ + force: always upload the file, even if the remote copy matches
892
+ + assume_exists: whether to assume the local file exists
893
+ + atime: value of atime the file should have, use ``True`` to match the local file
894
+ + mtime: value of mtime the file should have, use ``True`` to match the local file
895
+
896
+ ``dest``:
897
+ If this is a directory that already exists on the remote side, the local
898
+ file will be uploaded to that directory with the same filename.
899
+
900
+ ``mode``:
901
+ When set to ``True`` the permissions of the local file are applied to the
902
+ remote file after the upload is complete. If set to an octal value with
903
+ digits for at least user, group, and other, either as an ``int`` or
904
+ ``str``, those permissions will be used.
905
+
906
+ ``create_remote_dir``:
907
+ If the remote directory does not exist it will be created using the same
908
+ user & group as passed to ``files.put``. The mode will *not* be copied over,
909
+ if this is required call ``files.directory`` separately.
910
+
911
+ ``atime`` and ``mtime``:
912
+ When set to values other than ``False`` or ``None``, the respective metadata
913
+ fields on the remote file will updated accordingly. Timestamp values are
914
+ considered equivalent if the difference is less than one second and they have
915
+ the identical number in the seconds field. If set to ``True`` the local
916
+ file is the source of the value. Otherwise, these values can be provided as
917
+ ``datetime`` objects, POSIX timestamps, or strings that can be parsed into
918
+ either of these date and time specifications. They can also be reference file
919
+ paths on the remote host, as with the ``-r`` argument to ``touch``. If a
920
+ ``datetime`` argument has no ``tzinfo`` value (i.e., it is naive), it is
921
+ assumed to be in the remote host's local timezone. There is no shortcut for
922
+ setting both ``atime` and ``mtime`` values with a single time specification,
923
+ unlike the native ``touch`` command.
924
+
925
+ Notes:
926
+ This operation is not suitable for large files as it may involve copying
927
+ the file before uploading it.
928
+
929
+ Currently, if the mode argument is anything other than a ``bool`` or a full
930
+ octal permission set and the remote file exists, the operation will always
931
+ behave as if the remote file does not match the specified permissions and
932
+ requires a change.
933
+
934
+ If the ``atime`` argument is set for a given file, unless the remote
935
+ filesystem is mounted ``noatime`` or ``relatime``, multiple runs of this
936
+ operation will trigger the change detection for that file, since the act of
937
+ reading and checksumming the file will cause the host OS to update the file's
938
+ ``atime``.
939
+
940
+ **Examples:**
941
+
942
+ .. code:: python
943
+
944
+ files.put(
945
+ name="Update the message of the day file",
946
+ src="files/motd",
947
+ dest="/etc/motd",
948
+ mode="644",
949
+ )
950
+
951
+ files.put(
952
+ name="Upload a StringIO object",
953
+ src=StringIO("file contents"),
954
+ dest="/etc/motd",
955
+ )
956
+ """
957
+
958
+ # Upload IO objects as-is
959
+ if hasattr(src, "read"):
960
+ local_file = src
961
+ local_sum_path = src
962
+
963
+ # Assume string filename
964
+ else:
965
+ assert isinstance(src, (str, Path))
966
+ # Add deploy directory?
967
+ if add_deploy_dir and state.cwd:
968
+ src = os.path.join(state.cwd, src)
969
+
970
+ local_file = src
971
+
972
+ if os.path.isfile(local_file):
973
+ local_sum_path = local_file
974
+ elif assume_exists:
975
+ local_sum_path = None
976
+ else:
977
+ raise IOError("No such file: {0}".format(local_file))
978
+
979
+ if mode is True:
980
+ if isinstance(local_file, str) and os.path.isfile(local_file):
981
+ mode = get_path_permissions_mode(local_file)
982
+ else:
983
+ logger.warning(
984
+ ("No local file exists to get permissions from with `mode=True` ({0})").format(
985
+ get_call_location(),
986
+ ),
987
+ )
988
+ else:
989
+ mode = ensure_mode_int(mode)
990
+
991
+ remote_file = host.get_fact(File, path=dest)
992
+
993
+ if not remote_file and bool(host.get_fact(Directory, path=dest)):
994
+ assert isinstance(src, str)
995
+ dest = unix_path_join(dest, os.path.basename(src))
996
+ remote_file = host.get_fact(File, path=dest)
997
+
998
+ if create_remote_dir:
999
+ yield from _create_remote_dir(dest, user, group)
1000
+
1001
+ # No remote file, always upload and user/group/mode if supplied
1002
+ if not remote_file or force:
1003
+ if state.config.DIFF:
1004
+ host.log(f"Will create {click.style(dest, bold=True)}", logger.info)
1005
+
1006
+ with get_file_io(src, "r") as f:
1007
+ desired_lines = f.readlines()
1008
+
1009
+ for line in generate_color_diff([], desired_lines):
1010
+ logger.info(f" {line}")
1011
+ logger.info("")
1012
+
1013
+ yield FileUploadCommand(
1014
+ local_file,
1015
+ dest,
1016
+ remote_temp_filename=host.get_temp_filename(dest),
1017
+ )
1018
+
1019
+ if user or group:
1020
+ yield file_utils.chown(dest, user, group)
1021
+
1022
+ if mode:
1023
+ yield file_utils.chmod(dest, mode)
1024
+
1025
+ # do mtime before atime to ensure atime setting isn't undone by mtime setting
1026
+ if mtime:
1027
+ yield file_utils.touch(
1028
+ dest,
1029
+ MetadataTimeField.MTIME,
1030
+ _canonicalize_timespec(MetadataTimeField.MTIME, src, mtime),
1031
+ )
1032
+
1033
+ if atime:
1034
+ yield file_utils.touch(
1035
+ dest,
1036
+ MetadataTimeField.ATIME,
1037
+ _canonicalize_timespec(MetadataTimeField.ATIME, src, atime),
1038
+ )
1039
+
1040
+ # File exists, check sum and check user/group/mode/atime/mtime if supplied
1041
+ else:
1042
+ if not _file_equal(local_sum_path, dest):
1043
+ if state.config.DIFF:
1044
+ # Generate diff when contents change
1045
+ current_contents = host.get_fact(FileContents, path=dest)
1046
+ if current_contents:
1047
+ current_lines = [line + "\n" for line in current_contents]
1048
+ else:
1049
+ current_lines = []
1050
+
1051
+ host.log(f"Will modify {click.style(dest, bold=True)}", logger.info)
1052
+
1053
+ with get_file_io(src, "r") as f:
1054
+ desired_lines = f.readlines()
1055
+
1056
+ for line in generate_color_diff(current_lines, desired_lines):
1057
+ logger.info(f" {line}")
1058
+ logger.info("")
1059
+
1060
+ yield FileUploadCommand(
1061
+ local_file,
1062
+ dest,
1063
+ remote_temp_filename=host.get_temp_filename(dest),
1064
+ )
1065
+
1066
+ if user or group:
1067
+ yield file_utils.chown(dest, user, group)
1068
+
1069
+ if mode:
1070
+ yield file_utils.chmod(dest, mode)
1071
+
1072
+ if mtime:
1073
+ yield file_utils.touch(
1074
+ dest,
1075
+ MetadataTimeField.MTIME,
1076
+ _canonicalize_timespec(MetadataTimeField.MTIME, src, mtime),
1077
+ )
1078
+
1079
+ if atime:
1080
+ yield file_utils.touch(
1081
+ dest,
1082
+ MetadataTimeField.ATIME,
1083
+ _canonicalize_timespec(MetadataTimeField.ATIME, src, atime),
1084
+ )
1085
+
1086
+ else:
1087
+ changed = False
1088
+
1089
+ # Check mode
1090
+ if mode and remote_file["mode"] != mode:
1091
+ yield file_utils.chmod(dest, mode)
1092
+ changed = True
1093
+
1094
+ # Check user/group
1095
+ if (user and remote_file["user"] != user) or (group and remote_file["group"] != group):
1096
+ yield file_utils.chown(dest, user, group)
1097
+ changed = True
1098
+
1099
+ # Check mtime
1100
+ if mtime:
1101
+ canonical_mtime = _canonicalize_timespec(MetadataTimeField.MTIME, src, mtime)
1102
+ assert remote_file["mtime"] is not None
1103
+ if _times_differ_in_s(
1104
+ canonical_mtime, remote_file["mtime"].replace(tzinfo=timezone.utc)
1105
+ ):
1106
+ yield file_utils.touch(dest, MetadataTimeField.MTIME, canonical_mtime)
1107
+ changed = True
1108
+
1109
+ # Check atime
1110
+ if atime:
1111
+ canonical_atime = _canonicalize_timespec(MetadataTimeField.ATIME, src, atime)
1112
+ assert remote_file["atime"] is not None
1113
+ if _times_differ_in_s(
1114
+ canonical_atime, remote_file["atime"].replace(tzinfo=timezone.utc)
1115
+ ):
1116
+ yield file_utils.touch(dest, MetadataTimeField.ATIME, canonical_atime)
1117
+ changed = True
1118
+
1119
+ if not changed:
1120
+ host.noop("file {0} is already uploaded".format(dest))
1121
+
1122
+
1123
+ @operation()
1124
+ def template(
1125
+ src: str | IO[Any],
1126
+ dest: str,
1127
+ user: str | None = None,
1128
+ group: str | None = None,
1129
+ mode: str | None = None,
1130
+ create_remote_dir: bool = True,
1131
+ jinja_env_kwargs: dict[str, Any] | None = None,
1132
+ **data,
1133
+ ):
1134
+ '''
1135
+ Generate a template using jinja2 and write it to the remote system.
1136
+
1137
+ + src: template filename or IO-like object
1138
+ + dest: remote filename
1139
+ + user: user to own the files
1140
+ + group: group to own the files
1141
+ + mode: permissions of the files
1142
+ + create_remote_dir: create the remote directory if it doesn't exist
1143
+ + jinja_env_kwargs: keyword arguments to be passed into the jinja Environment()
1144
+
1145
+ ``create_remote_dir``:
1146
+ If the remote directory does not exist it will be created using the same
1147
+ user & group as passed to ``files.put``. The mode will *not* be copied over,
1148
+ if this is required call ``files.directory`` separately.
1149
+
1150
+ ``jinja_env_kwargs``:
1151
+ To have more control over how jinja2 renders your template, you can pass
1152
+ a dict with arguments that will be passed as keyword args to the jinja2
1153
+ `Environment() <https://jinja.palletsprojects.com/en/3.0.x/api/#jinja2.Environment>`_.
1154
+
1155
+ The ``host``, ``state``, and ``inventory`` objects will be automatically passed to the template
1156
+ if not set explicitly.
1157
+
1158
+ Notes:
1159
+ Common convention is to store templates in a "templates" directory and
1160
+ have a filename suffix with '.j2' (for jinja2).
1161
+
1162
+ For information on the template syntax, see
1163
+ `the jinja2 docs <https://jinja.palletsprojects.com>`_.
1164
+
1165
+ **Examples:**
1166
+
1167
+ .. code:: python
1168
+
1169
+ files.template(
1170
+ name="Create a templated file",
1171
+ src="templates/somefile.conf.j2",
1172
+ dest="/etc/somefile.conf",
1173
+ )
1174
+
1175
+ files.template(
1176
+ name="Create service file",
1177
+ src="templates/myweb.service.j2",
1178
+ dest="/etc/systemd/system/myweb.service",
1179
+ mode="755",
1180
+ user="root",
1181
+ group="root",
1182
+ )
1183
+
1184
+ # Example showing how to pass python variable to template file. You can also
1185
+ # use dicts and lists. The .j2 file can use `{{ foo_variable }}` to be interpolated.
1186
+ foo_variable = 'This is some foo variable contents'
1187
+ foo_dict = {
1188
+ "str1": "This is string 1",
1189
+ "str2": "This is string 2"
1190
+ }
1191
+ foo_list = [
1192
+ "entry 1",
1193
+ "entry 2"
1194
+ ]
1195
+
1196
+ template = StringIO("""
1197
+ name: "{{ foo_variable }}"
1198
+ dict_contents:
1199
+ str1: "{{ foo_dict.str1 }}"
1200
+ str2: "{{ foo_dict.str2 }}"
1201
+ list_contents:
1202
+ {% for entry in foo_list %}
1203
+ - "{{ entry }}"
1204
+ {% endfor %}
1205
+ """)
1206
+
1207
+ files.template(
1208
+ name="Create a templated file",
1209
+ src=template,
1210
+ dest="/tmp/foo.yml",
1211
+ foo_variable=foo_variable,
1212
+ foo_dict=foo_dict,
1213
+ foo_list=foo_list
1214
+ )
1215
+
1216
+ # Example showing how to use host and inventory in a template file.
1217
+ template = StringIO("""
1218
+ name: "{{ host.name }}"
1219
+ list_contents:
1220
+ {% for entry in inventory.groups.my_servers %}
1221
+ - "{{ entry }}"
1222
+ {% endfor %}
1223
+ """)
1224
+
1225
+ files.template(
1226
+ name="Create a templated file",
1227
+ src=template,
1228
+ dest="/tmp/foo.yml"
1229
+ )
1230
+ '''
1231
+
1232
+ if not hasattr(src, "read") and state.cwd:
1233
+ src = os.path.join(state.cwd, src)
1234
+
1235
+ # Ensure host/state/inventory are available inside templates (if not set)
1236
+ data.setdefault("host", host)
1237
+ data.setdefault("state", state)
1238
+ data.setdefault("inventory", state.inventory)
1239
+
1240
+ # Render and make file-like it's output
1241
+ try:
1242
+ output = get_template(src, jinja_env_kwargs).render(data)
1243
+ except (TemplateRuntimeError, TemplateSyntaxError, UndefinedError) as e:
1244
+ trace_frames = [
1245
+ frame
1246
+ for frame in traceback.extract_tb(sys.exc_info()[2])
1247
+ if frame[2] in ("template", "<module>", "top-level template code")
1248
+ ] # thank you https://github.com/saltstack/salt/blob/master/salt/utils/templates.py
1249
+
1250
+ line_number = trace_frames[-1][1]
1251
+
1252
+ # Quickly read the line in question and one above/below for nicer debugging
1253
+ with get_file_io(src, "r") as f:
1254
+ template_lines = f.readlines()
1255
+
1256
+ template_lines = [line.strip() for line in template_lines]
1257
+ relevant_lines = template_lines[max(line_number - 2, 0) : line_number + 1]
1258
+
1259
+ raise OperationError(
1260
+ "Error in template: {0} (L{1}): {2}\n...\n{3}\n...".format(
1261
+ src,
1262
+ line_number,
1263
+ e,
1264
+ "\n".join(relevant_lines),
1265
+ ),
1266
+ ) from None
1267
+
1268
+ output_file = StringIO(output)
1269
+ # Set the template attribute for nicer debugging
1270
+ output_file.template = src # type: ignore[attr-defined]
1271
+
1272
+ # Pass to the put function
1273
+ yield from put._inner(
1274
+ src=output_file,
1275
+ dest=dest,
1276
+ user=user,
1277
+ group=group,
1278
+ mode=mode,
1279
+ add_deploy_dir=False,
1280
+ create_remote_dir=create_remote_dir,
1281
+ )
1282
+
1283
+
1284
+ @operation()
1285
+ def move(src: str, dest: str, overwrite=False):
1286
+ """
1287
+ Move remote file/directory/link into remote directory
1288
+
1289
+ + src: remote file/directory to move
1290
+ + dest: remote directory to move `src` into
1291
+ + overwrite: whether to overwrite dest, if present
1292
+ """
1293
+
1294
+ if host.get_fact(File, src) is None:
1295
+ raise OperationError("src {0} does not exist".format(src))
1296
+
1297
+ if not host.get_fact(Directory, dest):
1298
+ raise OperationError("dest {0} is not an existing directory".format(dest))
1299
+
1300
+ full_dest_path = os.path.join(dest, os.path.basename(src))
1301
+ if host.get_fact(File, full_dest_path) is not None:
1302
+ if overwrite:
1303
+ yield StringCommand("rm", "-rf", QuoteString(full_dest_path))
1304
+ else:
1305
+ raise OperationError(
1306
+ "dest {0} already exists and `overwrite` is unset".format(full_dest_path)
1307
+ )
1308
+
1309
+ yield StringCommand("mv", QuoteString(src), QuoteString(dest))
1310
+
1311
+
1312
+ def _validate_path(path):
1313
+ try:
1314
+ return os.fspath(path)
1315
+ except TypeError:
1316
+ raise OperationTypeError("`path` must be a string or `os.PathLike` object")
1317
+
1318
+
1319
+ def _raise_or_remove_invalid_path(fs_type, path, force, force_backup, force_backup_dir):
1320
+ if force:
1321
+ if force_backup:
1322
+ backup_path = "{0}.{1}".format(path, get_timestamp())
1323
+ if force_backup_dir:
1324
+ backup_path = os.path.basename(backup_path)
1325
+ backup_path = "{0}/{1}".format(force_backup_dir, backup_path)
1326
+ yield StringCommand("mv", QuoteString(path), QuoteString(backup_path))
1327
+ else:
1328
+ yield StringCommand("rm", "-rf", QuoteString(path))
1329
+ else:
1330
+ raise OperationError("{0} exists and is not a {1}".format(path, fs_type))
1331
+
1332
+
1333
+ @operation()
1334
+ def link(
1335
+ path: str,
1336
+ target: str | None = None,
1337
+ present=True,
1338
+ user: str | None = None,
1339
+ group: str | None = None,
1340
+ symbolic=True,
1341
+ create_remote_dir=True,
1342
+ force=False,
1343
+ force_backup=True,
1344
+ force_backup_dir: str | None = None,
1345
+ ):
1346
+ """
1347
+ Add/remove/update links.
1348
+
1349
+ + path: the name of the link
1350
+ + target: the file/directory the link points to
1351
+ + present: whether the link should exist
1352
+ + user: user to own the link
1353
+ + group: group to own the link
1354
+ + symbolic: whether to make a symbolic link (vs hard link)
1355
+ + create_remote_dir: create the remote directory if it doesn't exist
1356
+ + force: if the target exists and is not a file, move or remove it and continue
1357
+ + force_backup: set to ``False`` to remove any existing non-file when ``force=True``
1358
+ + force_backup_dir: directory to move any backup to when ``force=True``
1359
+
1360
+ ``create_remote_dir``:
1361
+ If the remote directory does not exist it will be created using the same
1362
+ user & group as passed to ``files.put``. The mode will *not* be copied over,
1363
+ if this is required call ``files.directory`` separately.
1364
+
1365
+ Source changes:
1366
+ If the link exists and points to a different target, pyinfra will remove it and
1367
+ recreate a new one pointing to then new target.
1368
+
1369
+ **Examples:**
1370
+
1371
+ .. code:: python
1372
+
1373
+ files.link(
1374
+ name="Create link /etc/issue2 that points to /etc/issue",
1375
+ path="/etc/issue2",
1376
+ target="/etc/issue",
1377
+ )
1378
+ """
1379
+
1380
+ path = _validate_path(path)
1381
+
1382
+ if present and not target:
1383
+ raise OperationError("If present is True target must be provided")
1384
+
1385
+ info = host.get_fact(Link, path=path)
1386
+
1387
+ if info is False: # not a link
1388
+ yield from _raise_or_remove_invalid_path(
1389
+ "link",
1390
+ path,
1391
+ force,
1392
+ force_backup,
1393
+ force_backup_dir,
1394
+ )
1395
+ info = None
1396
+
1397
+ add_args = ["ln"]
1398
+ if symbolic:
1399
+ add_args.append("-s")
1400
+
1401
+ remove_cmd = StringCommand("rm", "-f", QuoteString(path))
1402
+
1403
+ if not present:
1404
+ if info:
1405
+ yield remove_cmd
1406
+ else:
1407
+ host.noop("link {link} does not exist")
1408
+ return
1409
+
1410
+ assert target is not None # appease typing QuoteString below
1411
+ add_cmd = StringCommand(" ".join(add_args), QuoteString(target), QuoteString(path))
1412
+
1413
+ if info is None: # create
1414
+ if create_remote_dir:
1415
+ yield from _create_remote_dir(path, user, group)
1416
+
1417
+ yield add_cmd
1418
+
1419
+ if user or group:
1420
+ yield file_utils.chown(path, user, group, dereference=False)
1421
+
1422
+ else: # edit
1423
+ changed = False
1424
+
1425
+ # If the target is wrong, remove & recreate the link
1426
+ if not info or info["link_target"] != target:
1427
+ changed = True
1428
+ yield remove_cmd
1429
+ yield add_cmd
1430
+
1431
+ # Check user/group
1432
+ if (user and info["user"] != user) or (group and info["group"] != group):
1433
+ yield file_utils.chown(path, user, group, dereference=False)
1434
+ changed = True
1435
+
1436
+ if not changed:
1437
+ host.noop("link {0} already exists".format(path))
1438
+
1439
+
1440
+ @operation()
1441
+ def file(
1442
+ path: str,
1443
+ present=True,
1444
+ user: str | None = None,
1445
+ group: str | None = None,
1446
+ mode: int | str | None = None,
1447
+ touch=False,
1448
+ create_remote_dir=True,
1449
+ force=False,
1450
+ force_backup=True,
1451
+ force_backup_dir: str | None = None,
1452
+ ):
1453
+ """
1454
+ Add/remove/update files.
1455
+
1456
+ + path: name/path of the remote file
1457
+ + present: whether the file should exist
1458
+ + user: user to own the files
1459
+ + group: group to own the files
1460
+ + mode: permissions of the files as an integer, eg: 755
1461
+ + touch: whether to touch the file
1462
+ + create_remote_dir: create the remote directory if it doesn't exist
1463
+ + force: if the target exists and is not a file, move or remove it and continue
1464
+ + force_backup: set to ``False`` to remove any existing non-file when ``force=True``
1465
+ + force_backup_dir: directory to move any backup to when ``force=True``
1466
+
1467
+ ``create_remote_dir``:
1468
+ If the remote directory does not exist it will be created using the same
1469
+ user & group as passed to ``files.put``. The mode will *not* be copied over,
1470
+ if this is required call ``files.directory`` separately.
1471
+
1472
+ **Example:**
1473
+
1474
+ .. code:: python
1475
+
1476
+ # Note: The directory /tmp/secret will get created with the default umask.
1477
+ files.file(
1478
+ name="Create /tmp/secret/file",
1479
+ path="/tmp/secret/file",
1480
+ mode="600",
1481
+ user="root",
1482
+ group="root",
1483
+ touch=True,
1484
+ create_remote_dir=True,
1485
+ )
1486
+ """
1487
+
1488
+ path = _validate_path(path)
1489
+
1490
+ mode = ensure_mode_int(mode)
1491
+ info = host.get_fact(File, path=path)
1492
+
1493
+ if info is False: # not a file
1494
+ yield from _raise_or_remove_invalid_path(
1495
+ "file",
1496
+ path,
1497
+ force,
1498
+ force_backup,
1499
+ force_backup_dir,
1500
+ )
1501
+ info = None
1502
+
1503
+ if not present:
1504
+ if info:
1505
+ yield StringCommand("rm", "-f", QuoteString(path))
1506
+ else:
1507
+ host.noop("file {0} does not exist")
1508
+ return
1509
+
1510
+ if info is None: # create
1511
+ if create_remote_dir:
1512
+ yield from _create_remote_dir(path, user, group)
1513
+
1514
+ yield StringCommand("touch", QuoteString(path))
1515
+
1516
+ if mode:
1517
+ yield file_utils.chmod(path, mode)
1518
+ if user or group:
1519
+ yield file_utils.chown(path, user, group)
1520
+
1521
+ else: # update
1522
+ changed = False
1523
+
1524
+ if touch:
1525
+ changed = True
1526
+ yield StringCommand("touch", QuoteString(path))
1527
+
1528
+ # Check mode
1529
+ if mode and (not info or info["mode"] != mode):
1530
+ yield file_utils.chmod(path, mode)
1531
+ changed = True
1532
+
1533
+ # Check user/group
1534
+ if (user and info["user"] != user) or (group and info["group"] != group):
1535
+ yield file_utils.chown(path, user, group)
1536
+ changed = True
1537
+
1538
+ if not changed:
1539
+ host.noop("file {0} already exists".format(path))
1540
+
1541
+
1542
+ @operation()
1543
+ def directory(
1544
+ path: str,
1545
+ present=True,
1546
+ user: str | None = None,
1547
+ group: str | None = None,
1548
+ mode: int | str | None = None,
1549
+ recursive=False,
1550
+ force=False,
1551
+ force_backup=True,
1552
+ force_backup_dir: str | None = None,
1553
+ _no_check_owner_mode=False,
1554
+ _no_fail_on_link=False,
1555
+ ):
1556
+ """
1557
+ Add/remove/update directories.
1558
+
1559
+ + path: path of the remote folder
1560
+ + present: whether the folder should exist
1561
+ + user: user to own the folder
1562
+ + group: group to own the folder
1563
+ + mode: permissions of the folder
1564
+ + recursive: recursively apply user/group/mode
1565
+ + force: if the target exists and is not a file, move or remove it and continue
1566
+ + force_backup: set to ``False`` to remove any existing non-file when ``force=True``
1567
+ + force_backup_dir: directory to move any backup to when ``force=True``
1568
+
1569
+ ``recursive``:
1570
+ Mode is only applied recursively if the base directory mode does not match
1571
+ the specified value. User and group are both applied recursively if the
1572
+ base directory does not match either one; otherwise they are unchanged for
1573
+ the whole tree.
1574
+
1575
+ **Examples:**
1576
+
1577
+ .. code:: python
1578
+
1579
+ files.directory(
1580
+ name="Ensure the /tmp/dir_that_we_want_removed is removed",
1581
+ path="/tmp/dir_that_we_want_removed",
1582
+ present=False,
1583
+ )
1584
+
1585
+ files.directory(
1586
+ name="Ensure /web exists",
1587
+ path="/web",
1588
+ user="myweb",
1589
+ group="myweb",
1590
+ )
1591
+
1592
+ # Multiple directories
1593
+ for dir in ["/netboot/tftp", "/netboot/nfs"]:
1594
+ files.directory(
1595
+ name="Ensure the directory `{}` exists".format(dir),
1596
+ path=dir,
1597
+ )
1598
+ """
1599
+
1600
+ path = _validate_path(path)
1601
+
1602
+ mode = ensure_mode_int(mode)
1603
+ info = host.get_fact(Directory, path=path)
1604
+
1605
+ if info is False: # not a directory
1606
+ if _no_fail_on_link and host.get_fact(Link, path=path):
1607
+ host.noop("directory {0} already exists (as a link)".format(path))
1608
+ return
1609
+ yield from _raise_or_remove_invalid_path(
1610
+ "directory",
1611
+ path,
1612
+ force,
1613
+ force_backup,
1614
+ force_backup_dir,
1615
+ )
1616
+ info = None
1617
+
1618
+ if not present:
1619
+ if info:
1620
+ yield StringCommand("rm", "-rf", QuoteString(path))
1621
+ else:
1622
+ host.noop("directory {0} does not exist")
1623
+ return
1624
+
1625
+ if info is None: # create
1626
+ yield StringCommand("mkdir", "-p", QuoteString(path))
1627
+ if mode:
1628
+ yield file_utils.chmod(path, mode, recursive=recursive)
1629
+ if user or group:
1630
+ yield file_utils.chown(path, user, group, recursive=recursive)
1631
+
1632
+ else: # update
1633
+ if _no_check_owner_mode:
1634
+ return
1635
+
1636
+ changed = False
1637
+
1638
+ if mode and (not info or info["mode"] != mode):
1639
+ yield file_utils.chmod(path, mode, recursive=recursive)
1640
+ changed = True
1641
+
1642
+ if (user and info["user"] != user) or (group and info["group"] != group):
1643
+ yield file_utils.chown(path, user, group, recursive=recursive)
1644
+ changed = True
1645
+
1646
+ if not changed:
1647
+ host.noop("directory {0} already exists".format(path))
1648
+
1649
+
1650
+ @operation()
1651
+ def flags(path: str, flags: list[str] | None = None, present=True):
1652
+ """
1653
+ Set/clear file flags.
1654
+
1655
+ + path: path of the remote folder
1656
+ + flags: a list of the file flags to be set or cleared
1657
+ + present: whether the flags should be set or cleared
1658
+
1659
+ **Examples:**
1660
+
1661
+ .. code:: python
1662
+
1663
+ files.flags(
1664
+ name="Ensure ~/Library is visible in the GUI",
1665
+ path="~/Library",
1666
+ flags="hidden",
1667
+ present=False
1668
+ )
1669
+
1670
+ files.directory(
1671
+ name="Ensure no one can change these files",
1672
+ path="/something/very/important",
1673
+ flags=["uchg", "schg"],
1674
+ present=True,
1675
+ _sudo=True
1676
+ )
1677
+ """
1678
+ flags = flags or []
1679
+ if not isinstance(flags, list):
1680
+ flags = [flags]
1681
+
1682
+ if len(flags) == 0:
1683
+ host.noop(f"no changes requested to flags for '{path}'")
1684
+ else:
1685
+ current_set = set(host.get_fact(Flags, path=path))
1686
+ to_change = list(set(flags) - current_set) if present else list(current_set & set(flags))
1687
+
1688
+ if len(to_change) > 0:
1689
+ prefix = "" if present else "no"
1690
+ new_flags = ",".join([prefix + flag for flag in sorted(to_change)])
1691
+ yield StringCommand("chflags", new_flags, QuoteString(path))
1692
+ else:
1693
+ host.noop(
1694
+ f"'{path}' already has '{','.join(flags)}' {'set' if present else 'clear'}",
1695
+ )
1696
+
1697
+
1698
+ @operation()
1699
+ def block(
1700
+ path: str,
1701
+ content: str | list[str] | None = None,
1702
+ present=True,
1703
+ line: str | None = None,
1704
+ backup=False,
1705
+ escape_regex_characters=False,
1706
+ try_prevent_shell_expansion=False,
1707
+ before=False,
1708
+ after=False,
1709
+ marker: str | None = None,
1710
+ begin: str | None = None,
1711
+ end: str | None = None,
1712
+ ):
1713
+ """
1714
+ Ensure content, surrounded by the appropriate markers, is present (or not) in the file.
1715
+
1716
+ + path: target remote file
1717
+ + content: what should be present in the file (between markers).
1718
+ + present: whether the content should be present in the file
1719
+ + before: should the content be added before ``line`` if it doesn't exist
1720
+ + after: should the content be added after ``line`` if it doesn't exist
1721
+ + line: regex before or after which the content should be added if it doesn't exist.
1722
+ + backup: whether to backup the file (see ``files.line``). Default False.
1723
+ + escape_regex_characters: whether to escape regex characters from the matching line
1724
+ + try_prevent_shell_expansion: tries to prevent shell expanding by values like `$`
1725
+ + marker: the base string used to mark the text. Default is ``# {mark} PYINFRA BLOCK``
1726
+ + begin: the value for ``{mark}`` in the marker before the content. Default is ``BEGIN``
1727
+ + end: the value for ``{mark}`` in the marker after the content. Default is ``END``
1728
+
1729
+ Content appended if ``line`` not found in the file
1730
+ If ``content`` is not in the file but is required (``present=True``) and ``line`` is not
1731
+ found in the file, ``content`` (surrounded by markers) will be appended to the file. The
1732
+ file is created if necessary.
1733
+
1734
+ Content prepended or appended if ``line`` not specified
1735
+ If ``content`` is not in the file but is required and ``line`` was not provided the content
1736
+ will either be prepended to the file (if both ``before`` and ``after``
1737
+ are ``True``) or appended to the file (if both are ``False``).
1738
+
1739
+ If the file is created, it is created with the default umask; otherwise the umask is preserved
1740
+ as is the owner.
1741
+
1742
+ Removal ignores ``content`` and ``line``
1743
+
1744
+ Preventing shell expansion works by wrapping the content in '`' before passing to `awk`.
1745
+ WARNING: This will break if the content contains raw single quotes.
1746
+
1747
+ **Examples:**
1748
+
1749
+ .. code:: python
1750
+
1751
+ # add entry to /etc/host
1752
+ files.block(
1753
+ name="add IP address for red server",
1754
+ path="/etc/hosts",
1755
+ content="10.0.0.1 mars-one",
1756
+ before=True,
1757
+ line=".*localhost",
1758
+ )
1759
+
1760
+ # have two entries in /etc/host
1761
+ files.block(
1762
+ name="add IP address for red server",
1763
+ path="/etc/hosts",
1764
+ content="10.0.0.1 mars-one\\n10.0.0.2 mars-two",
1765
+ before=True,
1766
+ line=".*localhost",
1767
+ )
1768
+
1769
+ # remove marked entry from /etc/hosts
1770
+ files.block(
1771
+ name="remove all 10.* addresses from /etc/hosts",
1772
+ path="/etc/hosts",
1773
+ present=False
1774
+ )
1775
+
1776
+ # add out of date warning to web page
1777
+ files.block(
1778
+ name="add out of date warning to web page",
1779
+ path="/var/www/html/something.html",
1780
+ content= "<p>Warning: this page is out of date.</p>",
1781
+ line=".*<body>.*",
1782
+ after=True
1783
+ marker="<!-- {mark} PYINFRA BLOCK -->",
1784
+ )
1785
+
1786
+ # put complex alias into .zshrc
1787
+ files.block(
1788
+ path="/home/user/.zshrc",
1789
+ content="eval $(thefuck -a)",
1790
+ try_prevent_shell_expansion=True,
1791
+ marker="## {mark} ALIASES ##"
1792
+ )
1793
+ """
1794
+
1795
+ logger.warning("The `files.block` operation is currently in beta!")
1796
+
1797
+ mark_1 = (marker or MARKER_DEFAULT).format(mark=begin or MARKER_BEGIN_DEFAULT)
1798
+ mark_2 = (marker or MARKER_DEFAULT).format(mark=end or MARKER_END_DEFAULT)
1799
+
1800
+ current = host.get_fact(Block, path=path, marker=marker, begin=begin, end=end)
1801
+ cmd = None
1802
+
1803
+ # standard awk doesn't have an "in-place edit" option so we write to a tempfile and
1804
+ # if edits were successful move to dest i.e. we do: <out_prep> ... do some work ... <real_out>
1805
+ q_path = QuoteString(path)
1806
+ mode_get = (
1807
+ ""
1808
+ if current is None
1809
+ else (
1810
+ 'MODE="$(stat -c %a',
1811
+ q_path,
1812
+ "2>/dev/null || stat -f %Lp",
1813
+ q_path,
1814
+ '2>/dev/null)" &&',
1815
+ )
1816
+ )
1817
+ out_prep = StringCommand(
1818
+ 'OUT="$(TMPDIR=/tmp mktemp -t pyinfra.XXXXXX)" && ',
1819
+ *mode_get,
1820
+ 'OWNER="$(stat -c "%u:%g"',
1821
+ q_path,
1822
+ '2>/dev/null || stat -f "%u:%g"',
1823
+ q_path,
1824
+ '2>/dev/null || echo $(id -un):$(id -gn))" &&',
1825
+ )
1826
+
1827
+ mode_change = "" if current is None else ' && chmod "$MODE"'
1828
+ real_out = StringCommand(
1829
+ ' && mv "$OUT"', q_path, ' && chown "$OWNER"', q_path, mode_change, q_path
1830
+ )
1831
+
1832
+ if backup and (current is not None): # can't back up something that doesn't exist
1833
+ out_prep = StringCommand(
1834
+ "cp",
1835
+ q_path,
1836
+ QuoteString(f"{path}.{get_timestamp()}"),
1837
+ "&&",
1838
+ out_prep,
1839
+ )
1840
+
1841
+ current = host.get_fact(Block, path=path, marker=marker, begin=begin, end=end)
1842
+ # None means file didn't exist, empty list means marker was not found
1843
+ cmd = None
1844
+ if present:
1845
+ if not content:
1846
+ raise OperationValueError("'content' must be supplied when 'present' == True")
1847
+ if line:
1848
+ if before == after:
1849
+ raise OperationValueError(
1850
+ "only one of 'before' or 'after' used when 'line` is specified"
1851
+ )
1852
+ elif before != after:
1853
+ raise OperationValueError(
1854
+ "'line' must be supplied or 'before' and 'after' must be equal"
1855
+ )
1856
+ if isinstance(content, str):
1857
+ # convert string to list of lines
1858
+ content = content.split("\n")
1859
+
1860
+ the_block = "\n".join([mark_1, *content, mark_2])
1861
+ if try_prevent_shell_expansion:
1862
+ the_block = f"'{the_block}'"
1863
+ if any("'" in line for line in content):
1864
+ logger.warning(
1865
+ "content contains single quotes, shell expansion prevention may fail"
1866
+ )
1867
+ else:
1868
+ the_block = f'"{the_block}"'
1869
+
1870
+ if (current is None) or ((current == []) and (before == after)):
1871
+ # a) no file or b) file but no markers and we're adding at start or end.
1872
+ # here = hex(random.randint(0, 2147483647)) # not used as not testable
1873
+ here = "PYINFRAHERE"
1874
+ original = q_path if current is not None else QuoteString("/dev/null")
1875
+ cmd = StringCommand(
1876
+ out_prep,
1877
+ "(",
1878
+ "awk '{{print}}'",
1879
+ original if not before else " - ",
1880
+ original if before else " - ",
1881
+ '> "$OUT"',
1882
+ f"<<{here}\n{the_block[1:-1]}\n{here}\n",
1883
+ ")",
1884
+ real_out,
1885
+ )
1886
+ elif current == []: # markers not found and have a pattern to match (not start or end)
1887
+ if not isinstance(line, str):
1888
+ raise OperationTypeError("'line' must be a regex or a string")
1889
+ regex = adjust_regex(line, escape_regex_characters)
1890
+ print_before = "{ print }" if before else ""
1891
+ print_after = "{ print }" if after else ""
1892
+ prog = (
1893
+ 'awk \'BEGIN {x=ARGV[2]; ARGV[2]=""} '
1894
+ f"{print_after} f!=1 && /{regex}/ {{ print x; f=1}} "
1895
+ f"END {{if (f==0) print x }} {print_before}'"
1896
+ )
1897
+ cmd = StringCommand(
1898
+ out_prep,
1899
+ prog,
1900
+ q_path,
1901
+ the_block,
1902
+ '> "$OUT"',
1903
+ real_out,
1904
+ )
1905
+ else:
1906
+ if (len(current) != len(content)) or (
1907
+ not all(lines[0] == lines[1] for lines in zip(content, current, strict=True))
1908
+ ): # marked_block found but text is different
1909
+ prog = (
1910
+ 'awk \'BEGIN {{f=1; x=ARGV[2]; ARGV[2]=""}}'
1911
+ f"/{mark_1}/ {{print; print x; f=0}} /{mark_2}/ {{print; f=1; next}} f'"
1912
+ )
1913
+ cmd = StringCommand(
1914
+ out_prep,
1915
+ prog,
1916
+ q_path,
1917
+ (
1918
+ '"' + "\n".join(content) + '"'
1919
+ if not try_prevent_shell_expansion
1920
+ else "'" + "\n".join(content) + "'"
1921
+ ),
1922
+ '> "$OUT"',
1923
+ real_out,
1924
+ )
1925
+ else:
1926
+ host.noop("content already present")
1927
+
1928
+ if cmd:
1929
+ yield cmd
1930
+ else: # remove the marked_block
1931
+ if content:
1932
+ logger.warning("'content' ignored when removing a marked_block")
1933
+ if current is None:
1934
+ host.noop("no remove required: file did not exist")
1935
+ elif current == []:
1936
+ host.noop("no remove required: markers not found")
1937
+ else:
1938
+ cmd = StringCommand(f"awk '/{mark_1}/,/{mark_2}/ {{next}} 1'")
1939
+ yield StringCommand(out_prep, cmd, q_path, "> $OUT", real_out)