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,251 @@
1
+ import dataclasses
2
+ from typing import Any, Dict, List
3
+
4
+ from pyinfra.api import OperationError
5
+
6
+
7
+ @dataclasses.dataclass
8
+ class ContainerSpec:
9
+ image: str = ""
10
+ ports: List[str] = dataclasses.field(default_factory=list)
11
+ networks: List[str] = dataclasses.field(default_factory=list)
12
+ volumes: List[str] = dataclasses.field(default_factory=list)
13
+ env_vars: List[str] = dataclasses.field(default_factory=list)
14
+ pull_always: bool = False
15
+
16
+ def container_create_args(self):
17
+ args = []
18
+ for network in self.networks:
19
+ args.append("--network {0}".format(network))
20
+
21
+ for port in self.ports:
22
+ args.append("-p {0}".format(port))
23
+
24
+ for volume in self.volumes:
25
+ args.append("-v {0}".format(volume))
26
+
27
+ for env_var in self.env_vars:
28
+ args.append("-e {0}".format(env_var))
29
+
30
+ if self.pull_always:
31
+ args.append("--pull always")
32
+
33
+ args.append(self.image)
34
+
35
+ return args
36
+
37
+ def diff_from_inspect(self, inspect_dict: Dict[str, Any]) -> List[str]:
38
+ # TODO(@minor-fixes): Diff output of "docker inspect" against this spec
39
+ # to determine if the container needs to be recreated. Currently, this
40
+ # function will never recreate when attributes change, which is
41
+ # consistent with prior behavior.
42
+ del inspect_dict
43
+ return []
44
+
45
+
46
+ def _create_container(**kwargs):
47
+ if "spec" not in kwargs:
48
+ raise OperationError("missing 1 required argument: 'spec'")
49
+
50
+ spec = kwargs["spec"]
51
+
52
+ if not spec.image:
53
+ raise OperationError("Docker image not specified")
54
+
55
+ command = [
56
+ "docker container create --name {0}".format(kwargs["container"])
57
+ ] + spec.container_create_args()
58
+
59
+ return " ".join(command)
60
+
61
+
62
+ def _remove_container(**kwargs):
63
+ return "docker container rm -f {0}".format(kwargs["container"])
64
+
65
+
66
+ def _start_container(**kwargs):
67
+ return "docker container start {0}".format(kwargs["container"])
68
+
69
+
70
+ def _stop_container(**kwargs):
71
+ return "docker container stop {0}".format(kwargs["container"])
72
+
73
+
74
+ def _pull_image(**kwargs):
75
+ return "docker image pull {0}".format(kwargs["image"])
76
+
77
+
78
+ def _remove_image(**kwargs):
79
+ return "docker image rm {0}".format(kwargs["image"])
80
+
81
+
82
+ def _prune_command(**kwargs):
83
+ command = ["docker system prune"]
84
+
85
+ if kwargs["all"]:
86
+ command.append("-a")
87
+
88
+ if kwargs["filter"] != "":
89
+ command.append("--filter={0}".format(kwargs["filter"]))
90
+
91
+ if kwargs["volumes"]:
92
+ command.append("--volumes")
93
+
94
+ command.append("-f")
95
+
96
+ return " ".join(command)
97
+
98
+
99
+ def _create_volume(**kwargs):
100
+ command = []
101
+ labels = kwargs["labels"] if kwargs["labels"] else []
102
+
103
+ command.append("docker volume create {0}".format(kwargs["volume"]))
104
+
105
+ if kwargs["driver"] != "":
106
+ command.append("-d {0}".format(kwargs["driver"]))
107
+
108
+ for label in labels:
109
+ command.append("--label {0}".format(label))
110
+
111
+ return " ".join(command)
112
+
113
+
114
+ def _remove_volume(**kwargs):
115
+ return "docker image rm {0}".format(kwargs["volume"])
116
+
117
+
118
+ def _create_network(**kwargs):
119
+ command = []
120
+ aux_addresses = kwargs["aux_addresses"] if kwargs["aux_addresses"] else {}
121
+ opts = kwargs["opts"] if kwargs["opts"] else []
122
+ ipam_opts = kwargs["ipam_opts"] if kwargs["ipam_opts"] else []
123
+ labels = kwargs["labels"] if kwargs["labels"] else []
124
+
125
+ command.append("docker network create {0}".format(kwargs["network"]))
126
+ if kwargs["driver"] != "":
127
+ command.append("-d {0}".format(kwargs["driver"]))
128
+
129
+ if kwargs["gateway"] != "":
130
+ command.append("--gateway {0}".format(kwargs["gateway"]))
131
+
132
+ if kwargs["ip_range"] != "":
133
+ command.append("--ip-range {0}".format(kwargs["ip_range"]))
134
+
135
+ if kwargs["ipam_driver"] != "":
136
+ command.append("--ipam-driver {0}".format(kwargs["ipam_driver"]))
137
+
138
+ if kwargs["subnet"] != "":
139
+ command.append("--subnet {0}".format(kwargs["subnet"]))
140
+
141
+ if kwargs["scope"] != "":
142
+ command.append("--scope {0}".format(kwargs["scope"]))
143
+
144
+ if kwargs["ingress"]:
145
+ command.append("--ingress")
146
+
147
+ if kwargs["attachable"]:
148
+ command.append("--attachable")
149
+
150
+ for host, address in aux_addresses.items():
151
+ command.append("--aux-address '{0}={1}'".format(host, address))
152
+
153
+ for opt in opts:
154
+ command.append("--opt {0}".format(opt))
155
+
156
+ for opt in ipam_opts:
157
+ command.append("--ipam-opt {0}".format(opt))
158
+
159
+ for label in labels:
160
+ command.append("--label {0}".format(label))
161
+ return " ".join(command)
162
+
163
+
164
+ def _remove_network(**kwargs):
165
+ return "docker network rm {0}".format(kwargs["network"])
166
+
167
+
168
+ def _install_plugin(**kwargs):
169
+ command = ["docker plugin install {0} --grant-all-permissions".format(kwargs["plugin"])]
170
+
171
+ plugin_options = kwargs["plugin_options"] if kwargs["plugin_options"] else {}
172
+
173
+ if kwargs["alias"]:
174
+ command.append("--alias {0}".format(kwargs["alias"]))
175
+
176
+ if not kwargs["enabled"]:
177
+ command.append("--disable")
178
+
179
+ for option, value in plugin_options.items():
180
+ command.append("{0}={1}".format(option, value))
181
+
182
+ return " ".join(command)
183
+
184
+
185
+ def _remove_plugin(**kwargs):
186
+ return "docker plugin rm -f {0}".format(kwargs["plugin"])
187
+
188
+
189
+ def _enable_plugin(**kwargs):
190
+ return "docker plugin enable {0}".format(kwargs["plugin"])
191
+
192
+
193
+ def _disable_plugin(**kwargs):
194
+ return "docker plugin disable {0}".format(kwargs["plugin"])
195
+
196
+
197
+ def _set_plugin_options(**kwargs):
198
+ command = ["docker plugin set {0}".format(kwargs["plugin"])]
199
+ existent_options = kwargs.get("existing_options", {})
200
+ required_options = kwargs.get("required_options", {})
201
+ options_to_set = existent_options | required_options
202
+ for option, value in options_to_set.items():
203
+ command.append("{0}={1}".format(option, value))
204
+ return " ".join(command)
205
+
206
+
207
+ def handle_docker(resource: str, command: str, **kwargs):
208
+ container_commands = {
209
+ "create": _create_container,
210
+ "remove": _remove_container,
211
+ "start": _start_container,
212
+ "stop": _stop_container,
213
+ }
214
+
215
+ image_commands = {
216
+ "pull": _pull_image,
217
+ "remove": _remove_image,
218
+ }
219
+
220
+ volume_commands = {
221
+ "create": _create_volume,
222
+ "remove": _remove_volume,
223
+ }
224
+
225
+ network_commands = {
226
+ "create": _create_network,
227
+ "remove": _remove_network,
228
+ }
229
+
230
+ system_commands = {
231
+ "prune": _prune_command,
232
+ }
233
+
234
+ plugin_commands = {
235
+ "install": _install_plugin,
236
+ "remove": _remove_plugin,
237
+ "enable": _enable_plugin,
238
+ "disable": _disable_plugin,
239
+ "set": _set_plugin_options,
240
+ }
241
+
242
+ docker_commands = {
243
+ "container": container_commands,
244
+ "image": image_commands,
245
+ "volume": volume_commands,
246
+ "network": network_commands,
247
+ "system": system_commands,
248
+ "plugin": plugin_commands,
249
+ }
250
+
251
+ return docker_commands[resource][command](**kwargs)
@@ -0,0 +1,247 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import re
5
+ from datetime import datetime, timezone
6
+ from enum import Enum
7
+ from typing import Callable, Generator
8
+
9
+ import click
10
+
11
+ from pyinfra.api import QuoteString, StringCommand
12
+
13
+
14
+ class MetadataTimeField(Enum):
15
+ ATIME = "atime"
16
+ MTIME = "mtime"
17
+
18
+
19
+ def unix_path_join(*parts) -> str:
20
+ part_list = list(parts)
21
+ part_list[0:-1] = [part.rstrip("/") for part in part_list[0:-1]]
22
+ return "/".join(part_list)
23
+
24
+
25
+ def ensure_mode_int(mode: str | int | None) -> int | str | None:
26
+ # Already an int (/None)?
27
+ if isinstance(mode, int) or mode is None:
28
+ return mode
29
+
30
+ try:
31
+ # Try making an int ('700' -> 700)
32
+ return int(mode)
33
+
34
+ except (TypeError, ValueError):
35
+ pass
36
+
37
+ # Return as-is (ie +x which we don't need to normalise, it always gets run)
38
+ return mode
39
+
40
+
41
+ def get_timestamp() -> str:
42
+ return datetime.now().strftime("%y%m%d%H%M")
43
+
44
+
45
+ _sed_ignore_case = re.compile("[iI]")
46
+
47
+
48
+ def _sed_delete_builder(line: str, replace: str, flags: str, interpolate_variables: bool) -> str:
49
+ return (
50
+ '"/{0}/{1}d"'
51
+ if interpolate_variables
52
+ # fmt: skip
53
+ else "'/{0}/{1}d'"
54
+ ).format(line, "I" if _sed_ignore_case.search(flags) else "")
55
+
56
+
57
+ def sed_delete(
58
+ filename: str,
59
+ line: str,
60
+ replace: str,
61
+ flags: list[str] | None = None,
62
+ backup=False,
63
+ interpolate_variables=False,
64
+ ) -> StringCommand:
65
+ return _sed_command(**locals(), sed_script_builder=_sed_delete_builder)
66
+
67
+
68
+ def _sed_replace_builder(line: str, replace: str, flags: str, interpolate_variables: bool) -> str:
69
+ return (
70
+ '"s/{0}/{1}/{2}"'
71
+ if interpolate_variables
72
+ # fmt: skip
73
+ else "'s/{0}/{1}/{2}'"
74
+ ).format(line, replace, flags)
75
+
76
+
77
+ def sed_replace(
78
+ filename: str,
79
+ line: str,
80
+ replace: str,
81
+ flags: list[str] | None = None,
82
+ backup=False,
83
+ interpolate_variables=False,
84
+ ) -> StringCommand:
85
+ return _sed_command(**locals(), sed_script_builder=_sed_replace_builder)
86
+
87
+
88
+ def _sed_command(
89
+ filename: str,
90
+ line: str,
91
+ replace: str,
92
+ flags: list[str] | None = None,
93
+ backup=False,
94
+ interpolate_variables=False,
95
+ # Python requires a default value here, so use _sed_replace_builder for
96
+ # backwards compatibility.
97
+ sed_script_builder: Callable[[str, str, str, bool], str] = _sed_replace_builder,
98
+ ) -> StringCommand:
99
+ flags_str = "".join(flags) if flags else ""
100
+
101
+ line = line.replace("/", r"\/")
102
+ replace = str(replace)
103
+ replace = replace.replace("/", r"\/")
104
+ replace = replace.replace("&", r"\&")
105
+ backup_extension = get_timestamp()
106
+
107
+ if interpolate_variables:
108
+ line = line.replace('"', '\\"')
109
+ replace = replace.replace('"', '\\"')
110
+ else:
111
+ # Single quotes cannot contain other single quotes, even when escaped , so turn
112
+ # each ' into '"'"' (end string, double quote the single quote, (re)start string)
113
+ line = line.replace("'", "'\"'\"'")
114
+ replace = replace.replace("'", "'\"'\"'")
115
+
116
+ sed_script = sed_script_builder(line, replace, flags_str, interpolate_variables)
117
+
118
+ sed_command = StringCommand(
119
+ "sed",
120
+ "-i.{0}".format(backup_extension),
121
+ sed_script,
122
+ QuoteString(filename),
123
+ )
124
+
125
+ if not backup: # if we're not backing up, remove the file *if* sed succeeds
126
+ backup_filename = "{0}.{1}".format(filename, backup_extension)
127
+ sed_command = StringCommand(sed_command, "&&", "rm", "-f", QuoteString(backup_filename))
128
+
129
+ return sed_command
130
+
131
+
132
+ def chmod(target: str, mode: str | int, recursive=False) -> StringCommand:
133
+ args = ["chmod"]
134
+ if recursive:
135
+ args.append("-R")
136
+
137
+ args.append("{0}".format(mode))
138
+
139
+ return StringCommand(" ".join(args), QuoteString(target))
140
+
141
+
142
+ def chown(
143
+ target: str,
144
+ user: str | None = None,
145
+ group: str | None = None,
146
+ recursive=False,
147
+ dereference=True,
148
+ ) -> StringCommand:
149
+ command = "chown"
150
+ user_group = None
151
+
152
+ if user and group:
153
+ user_group = "{0}:{1}".format(user, group)
154
+
155
+ elif user:
156
+ user_group = user
157
+
158
+ elif group:
159
+ command = "chgrp"
160
+ user_group = group
161
+
162
+ args = [command]
163
+ if recursive:
164
+ args.append("-R")
165
+
166
+ if not dereference:
167
+ args.append("-h")
168
+
169
+ return StringCommand(" ".join(args), user_group, QuoteString(target))
170
+
171
+
172
+ # like the touch command, but only supports setting one field at a time, and expects any
173
+ # reference times to have been read from the reference file metadata and turned into
174
+ # aware datetimes
175
+ def touch(
176
+ target: str,
177
+ timefield: MetadataTimeField,
178
+ timesrc: datetime,
179
+ dereference=True,
180
+ ) -> StringCommand:
181
+ args = ["touch"]
182
+
183
+ if timefield is MetadataTimeField.ATIME:
184
+ args.append("-a")
185
+ else:
186
+ args.append("-m")
187
+
188
+ if not dereference:
189
+ args.append("-h")
190
+
191
+ # don't reinvent the wheel; use isoformat()
192
+ timestr = timesrc.astimezone(timezone.utc).isoformat()
193
+ # but replace the ISO format TZ offset with "Z" for BSD
194
+ timestr = timestr.replace("+00:00", "Z")
195
+ args.extend(["-d", timestr])
196
+
197
+ return StringCommand(" ".join(args), QuoteString(target))
198
+
199
+
200
+ def adjust_regex(line: str, escape_regex_characters: bool) -> str:
201
+ """
202
+ Ensure the regex starts with '^' and ends with '$' and escape regex characters if requested
203
+ """
204
+ match_line = line
205
+
206
+ if escape_regex_characters:
207
+ match_line = re.sub(r"([\.\\\+\*\?\[\^\]\$\(\)\{\}\-])", r"\\\1", match_line)
208
+
209
+ # Ensure we're matching a whole line, note: match may be a partial line so we
210
+ # put any matches on either side.
211
+ if not match_line.startswith("^"):
212
+ match_line = "^.*{0}".format(match_line)
213
+ if not match_line.endswith("$"):
214
+ match_line = "{0}.*$".format(match_line)
215
+
216
+ return match_line
217
+
218
+
219
+ def generate_color_diff(
220
+ current_lines: list[str], desired_lines: list[str]
221
+ ) -> Generator[str, None, None]:
222
+ def _format_range_unified(start: int, stop: int) -> str:
223
+ beginning = start + 1 # lines start numbering with one
224
+ length = stop - start
225
+ if length == 1:
226
+ return "{}".format(beginning)
227
+ if not length:
228
+ beginning -= 1 # empty ranges begin at line just before the range
229
+ return "{},{}".format(beginning, length)
230
+
231
+ for group in difflib.SequenceMatcher(None, current_lines, desired_lines).get_grouped_opcodes(2):
232
+ first, last = group[0], group[-1]
233
+ file1_range = _format_range_unified(first[1], last[2])
234
+ file2_range = _format_range_unified(first[3], last[4])
235
+ yield "@@ -{} +{} @@".format(file1_range, file2_range)
236
+
237
+ for tag, i1, i2, j1, j2 in group:
238
+ if tag == "equal":
239
+ for line in current_lines[i1:i2]:
240
+ yield " " + line.rstrip()
241
+ continue
242
+ if tag in {"replace", "delete"}:
243
+ for line in current_lines[i1:i2]:
244
+ yield click.style("- " + line.rstrip(), "red")
245
+ if tag in {"replace", "insert"}:
246
+ for line in desired_lines[j1:j2]:
247
+ yield click.style("+ " + line.rstrip(), "green")