pyinfra 3.5.1__py3-none-any.whl → 3.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. pyinfra/api/__init__.py +1 -0
  2. pyinfra/api/arguments.py +7 -0
  3. pyinfra/api/exceptions.py +6 -0
  4. pyinfra/api/facts.py +17 -1
  5. pyinfra/api/host.py +3 -0
  6. pyinfra/api/metadata.py +69 -0
  7. pyinfra/api/operations.py +3 -3
  8. pyinfra/api/util.py +22 -5
  9. pyinfra/connectors/docker.py +25 -1
  10. pyinfra/connectors/ssh.py +57 -0
  11. pyinfra/connectors/util.py +16 -9
  12. pyinfra/facts/crontab.py +7 -7
  13. pyinfra/facts/files.py +1 -2
  14. pyinfra/facts/npm.py +1 -1
  15. pyinfra/facts/server.py +18 -2
  16. pyinfra/operations/apk.py +2 -1
  17. pyinfra/operations/apt.py +15 -7
  18. pyinfra/operations/brew.py +1 -0
  19. pyinfra/operations/crontab.py +4 -1
  20. pyinfra/operations/dnf.py +4 -1
  21. pyinfra/operations/docker.py +62 -16
  22. pyinfra/operations/files.py +87 -12
  23. pyinfra/operations/flatpak.py +1 -0
  24. pyinfra/operations/gem.py +1 -0
  25. pyinfra/operations/git.py +1 -0
  26. pyinfra/operations/iptables.py +1 -0
  27. pyinfra/operations/lxd.py +1 -0
  28. pyinfra/operations/mysql.py +1 -0
  29. pyinfra/operations/opkg.py +2 -1
  30. pyinfra/operations/pacman.py +1 -0
  31. pyinfra/operations/pip.py +1 -0
  32. pyinfra/operations/pipx.py +1 -0
  33. pyinfra/operations/pkg.py +1 -0
  34. pyinfra/operations/pkgin.py +1 -0
  35. pyinfra/operations/postgres.py +1 -0
  36. pyinfra/operations/puppet.py +1 -0
  37. pyinfra/operations/python.py +1 -0
  38. pyinfra/operations/selinux.py +1 -0
  39. pyinfra/operations/server.py +1 -0
  40. pyinfra/operations/snap.py +2 -1
  41. pyinfra/operations/ssh.py +1 -0
  42. pyinfra/operations/systemd.py +1 -0
  43. pyinfra/operations/sysvinit.py +2 -1
  44. pyinfra/operations/util/docker.py +164 -8
  45. pyinfra/operations/util/packaging.py +2 -0
  46. pyinfra/operations/xbps.py +1 -0
  47. pyinfra/operations/yum.py +4 -1
  48. pyinfra/operations/zfs.py +1 -0
  49. pyinfra/operations/zypper.py +1 -0
  50. {pyinfra-3.5.1.dist-info → pyinfra-3.6.dist-info}/METADATA +2 -1
  51. {pyinfra-3.5.1.dist-info → pyinfra-3.6.dist-info}/RECORD +55 -54
  52. {pyinfra-3.5.1.dist-info → pyinfra-3.6.dist-info}/WHEEL +1 -1
  53. pyinfra_cli/cli.py +13 -0
  54. {pyinfra-3.5.1.dist-info → pyinfra-3.6.dist-info}/entry_points.txt +0 -0
  55. {pyinfra-3.5.1.dist-info → pyinfra-3.6.dist-info}/licenses/LICENSE.md +0 -0
pyinfra/api/__init__.py CHANGED
@@ -14,6 +14,7 @@ from .exceptions import ( # noqa: F401
14
14
  FactError,
15
15
  FactTypeError,
16
16
  FactValueError,
17
+ FactProcessError,
17
18
  InventoryError,
18
19
  OperationError,
19
20
  OperationTypeError,
pyinfra/api/arguments.py CHANGED
@@ -77,6 +77,9 @@ class ConnectorArguments(TypedDict, total=False):
77
77
  _retry_delay: Union[int, float]
78
78
  _retry_until: Optional[Callable[[dict], bool]]
79
79
 
80
+ # Temp directory argument
81
+ _temp_dir: str
82
+
80
83
 
81
84
  def generate_env(config: "Config", value: dict) -> dict:
82
85
  env = config.ENV.copy()
@@ -163,6 +166,10 @@ shell_argument_meta: dict[str, ArgumentMeta] = {
163
166
  "String or buffer to send to the stdin of any commands.",
164
167
  default=lambda _: None,
165
168
  ),
169
+ "_temp_dir": ArgumentMeta(
170
+ "Temporary directory on the remote host for file operations.",
171
+ default=lambda config: config.TEMP_DIR,
172
+ ),
166
173
  }
167
174
 
168
175
 
pyinfra/api/exceptions.py CHANGED
@@ -29,6 +29,12 @@ class FactValueError(FactError, ValueError):
29
29
  """
30
30
 
31
31
 
32
+ class FactProcessError(FactError, RuntimeError):
33
+ """
34
+ Exception raised when the data gathered for a fact cannot be processed.
35
+ """
36
+
37
+
32
38
  class OperationError(PyinfraError):
33
39
  """
34
40
  Exception raised during fact gathering staging if an operation is unable to
pyinfra/api/facts.py CHANGED
@@ -24,6 +24,7 @@ from typing_extensions import override
24
24
  from pyinfra import logger
25
25
  from pyinfra.api import StringCommand
26
26
  from pyinfra.api.arguments import all_global_arguments, pop_global_arguments
27
+ from pyinfra.api.exceptions import FactProcessError
27
28
  from pyinfra.api.util import (
28
29
  get_kwargs_str,
29
30
  log_error_or_warning,
@@ -269,7 +270,22 @@ def _get_fact(
269
270
 
270
271
  if status:
271
272
  if stdout_lines:
272
- data = fact.process(stdout_lines)
273
+ try:
274
+ data = fact.process(stdout_lines)
275
+ except FactProcessError as e:
276
+ log_error_or_warning(
277
+ host,
278
+ global_kwargs["_ignore_errors"],
279
+ description=("could not process fact: {0} {1}").format(
280
+ name, get_kwargs_str(fact_kwargs)
281
+ ),
282
+ exception=e,
283
+ )
284
+
285
+ # Check we've not failed
286
+ if apply_failed_hosts and not global_kwargs["_ignore_errors"]:
287
+ state.fail_hosts({host})
288
+
273
289
  elif stderr_lines:
274
290
  # If we have error output and that error is sudo or su stating the user
275
291
  # does not exist, do not fail but instead return the default fact value.
pyinfra/api/host.py CHANGED
@@ -328,6 +328,9 @@ class Host:
328
328
 
329
329
  return temp_directory
330
330
 
331
+ def get_temp_dir_config(self):
332
+ return self.state.config.TEMP_DIR or self.state.config.DEFAULT_TEMP_DIR
333
+
331
334
  def get_temp_filename(
332
335
  self,
333
336
  hash_key: Optional[str] = None,
@@ -0,0 +1,69 @@
1
+ """
2
+ Support parsing pyinfra-metadata.toml
3
+
4
+ Currently just parses plugins and their metadata.
5
+ """
6
+
7
+ import tomllib
8
+ from typing import Literal, get_args
9
+
10
+ from pydantic import BaseModel, TypeAdapter, field_validator
11
+
12
+ AllowedTagType = Literal[
13
+ "boot",
14
+ "containers",
15
+ "database",
16
+ "service-management",
17
+ "package-manager",
18
+ "python",
19
+ "ruby",
20
+ "javascript",
21
+ "configuration-management",
22
+ "security",
23
+ "storage",
24
+ "system",
25
+ "rust",
26
+ "version-control-system",
27
+ ]
28
+
29
+
30
+ class Tag(BaseModel):
31
+ """Representation of a plugin tag."""
32
+
33
+ value: AllowedTagType
34
+
35
+ @field_validator("value", mode="before")
36
+ def _validate_value(cls, v) -> AllowedTagType:
37
+ allowed_tags = set(get_args(AllowedTagType))
38
+ if v not in allowed_tags:
39
+ raise ValueError(f"Invalid tag: {v}. Allowed: {allowed_tags}")
40
+ return v
41
+
42
+ @property
43
+ def title_case(self) -> str:
44
+ return " ".join([t.title() for t in self.value.split("-")])
45
+
46
+
47
+ ALLOWED_TAGS = [Tag(value=tag) for tag in set(get_args(AllowedTagType))]
48
+
49
+
50
+ class Plugin(BaseModel):
51
+ """Representation of a pyinfra plugin."""
52
+
53
+ name: str
54
+ # description: str # FUTURE we should grab these from doc strings
55
+ path: str
56
+ type: Literal["operation", "fact", "connector", "deploy"]
57
+ tags: list[Tag]
58
+
59
+ @field_validator("tags", mode="before")
60
+ def _wrap_tags(cls, v):
61
+ return [Tag(value=tag) if not isinstance(tag, Tag) else tag for tag in v]
62
+
63
+
64
+ def parse_plugins(metadata_text: str) -> list[Plugin]:
65
+ """Given the contents of a pyinfra-metadata.toml parse out the plugins."""
66
+ pyinfra_metadata = tomllib.loads(metadata_text).get("pyinfra", None)
67
+ if not pyinfra_metadata:
68
+ raise ValueError("Missing [pyinfra.plugins] section in pyinfra-metadata.toml")
69
+ return TypeAdapter(list[Plugin]).validate_python(pyinfra_metadata["plugins"].values())
pyinfra/api/operations.py CHANGED
@@ -194,11 +194,11 @@ def _run_host_op(state: "State", host: "Host", op_hash: str) -> Optional[bool]:
194
194
  host_results.ops += 1
195
195
  host_results.success_ops += 1
196
196
 
197
- _status_log = "Success" if executed_commands > 0 else "No changes"
197
+ _status_text = "Success" if executed_commands > 0 else "No changes"
198
198
  if retry_attempt > 0:
199
- _status_log = f"{_status_log} on retry {retry_attempt}"
199
+ _status_text = f"{_status_text} on retry {retry_attempt}"
200
200
 
201
- _click_log_status = click.style(_status_log, "green")
201
+ _click_log_status = click.style(_status_text, "green" if executed_commands > 0 else "cyan")
202
202
  logger.info("{0}{1}".format(host.print_prefix, _click_log_status))
203
203
 
204
204
  state.trigger_callbacks("operation_host_success", host, op_hash, retry_attempt)
pyinfra/api/util.py CHANGED
@@ -10,7 +10,7 @@ from socket import error as socket_error, timeout as timeout_error
10
10
  from typing import IO, TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union
11
11
 
12
12
  import click
13
- from jinja2 import Environment, FileSystemLoader, StrictUndefined
13
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined, Template
14
14
  from paramiko import SSHException
15
15
  from typeguard import TypeCheckError, check_type
16
16
 
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
26
26
  BLOCKSIZE = 65536
27
27
 
28
28
  # Caches
29
- TEMPLATES: Dict[Any, Any] = {}
29
+ TEMPLATES: Dict[str, Template] = {}
30
30
  FILE_SHAS: Dict[Any, Any] = {}
31
31
 
32
32
  PYINFRA_INSTALL_DIR = path.normpath(path.join(path.dirname(__file__), ".."))
@@ -139,7 +139,9 @@ def get_operation_order_from_stack(state: "State"):
139
139
  return line_numbers
140
140
 
141
141
 
142
- def get_template(filename_or_io: str | IO, jinja_env_kwargs: dict[str, Any] | None = None):
142
+ def get_template(
143
+ filename_or_io: str | IO, jinja_env_kwargs: dict[str, Any] | None = None
144
+ ) -> Template:
143
145
  """
144
146
  Gets a jinja2 ``Template`` object for the input filename or string, with caching
145
147
  based on the filename of the template, or the SHA1 of the input string.
@@ -155,10 +157,11 @@ def get_template(filename_or_io: str | IO, jinja_env_kwargs: dict[str, Any] | No
155
157
  with file_data as file_io:
156
158
  template_string = file_io.read()
157
159
 
160
+ default_loader = FileSystemLoader(getcwd())
158
161
  template = Environment(
159
162
  undefined=StrictUndefined,
160
163
  keep_trailing_newline=True,
161
- loader=FileSystemLoader(getcwd()),
164
+ loader=jinja_env_kwargs.pop("loader", default_loader),
162
165
  **jinja_env_kwargs,
163
166
  ).from_string(template_string)
164
167
 
@@ -219,7 +222,11 @@ def log_operation_start(
219
222
 
220
223
 
221
224
  def log_error_or_warning(
222
- host: "Host", ignore_errors: bool, description: str = "", continue_on_error: bool = False
225
+ host: "Host",
226
+ ignore_errors: bool,
227
+ description: str = "",
228
+ continue_on_error: bool = False,
229
+ exception: Exception | None = None,
223
230
  ) -> None:
224
231
  log_func = logger.error
225
232
  log_color = "red"
@@ -234,6 +241,16 @@ def log_error_or_warning(
234
241
  if description:
235
242
  log_text = f"{log_text}: "
236
243
 
244
+ if exception:
245
+ exc = exception.__cause__ or exception
246
+ exc_text = "{0}: {1}".format(type(exc).__name__, exc)
247
+ log_func(
248
+ "{0}{1}".format(
249
+ host.print_prefix,
250
+ click.style(exc_text, log_color),
251
+ ),
252
+ )
253
+
237
254
  log_func(
238
255
  "{0}{1}{2}".format(
239
256
  host.print_prefix,
@@ -26,10 +26,14 @@ if TYPE_CHECKING:
26
26
 
27
27
  class ConnectorData(TypedDict):
28
28
  docker_identifier: str
29
+ docker_platform: str
30
+ docker_architecture: str
29
31
 
30
32
 
31
33
  connector_data_meta: dict[str, DataMeta] = {
32
34
  "docker_identifier": DataMeta("ID of container or image to start from"),
35
+ "docker_platform": DataMeta("Platform to use for Docker image (e.g., linux/amd64)"),
36
+ "docker_architecture": DataMeta("Architecture to use for Docker image (e.g., amd64, arm64)"),
33
37
  }
34
38
 
35
39
 
@@ -108,9 +112,29 @@ class DockerConnector(BaseConnector):
108
112
  return container_id, True
109
113
 
110
114
  def _start_docker_image(self, image_name):
115
+ docker_cmd_parts = [
116
+ self.docker_cmd,
117
+ "run",
118
+ "-d",
119
+ ]
120
+
121
+ if self.data.get("docker_platform"):
122
+ docker_cmd_parts.extend(["--platform", self.data["docker_platform"]])
123
+ if self.data.get("docker_architecture"):
124
+ docker_cmd_parts.extend(["--arch", self.data["docker_architecture"]])
125
+
126
+ docker_cmd_parts.extend(
127
+ [
128
+ image_name,
129
+ "tail",
130
+ "-f",
131
+ "/dev/null",
132
+ ]
133
+ )
134
+
111
135
  try:
112
136
  return local.shell(
113
- f"{self.docker_cmd} run -d {image_name} tail -f /dev/null",
137
+ " ".join(docker_cmd_parts),
114
138
  splitlines=True,
115
139
  )[-1] # last line is the container ID
116
140
  except PyinfraError as e:
pyinfra/connectors/ssh.py CHANGED
@@ -9,6 +9,7 @@ from typing import IO, TYPE_CHECKING, Any, Iterable, Optional, Protocol, Tuple
9
9
 
10
10
  import click
11
11
  from paramiko import AuthenticationException, BadHostKeyException, SFTPClient, SSHException
12
+ from paramiko.agent import Agent
12
13
  from typing_extensions import TypedDict, Unpack, override
13
14
 
14
15
  from pyinfra import logger
@@ -286,10 +287,64 @@ class SSHConnector(BaseConnector):
286
287
  f"Host key for {e.hostname} does not match.",
287
288
  )
288
289
 
290
+ except SSHException as e:
291
+ if self._retry_paramiko_agent_keys(hostname, kwargs, e):
292
+ return
293
+ raise
294
+
289
295
  @override
290
296
  def disconnect(self) -> None:
291
297
  self.get_file_transfer_connection.cache.clear()
292
298
 
299
+ def _retry_paramiko_agent_keys(
300
+ self,
301
+ hostname: str,
302
+ kwargs: dict[str, Any],
303
+ error: SSHException,
304
+ ) -> bool:
305
+ # Workaround for Paramiko multi-key bug (paramiko/paramiko#1390).
306
+ if "no existing session" not in str(error).lower():
307
+ return False
308
+
309
+ if not kwargs.get("allow_agent"):
310
+ return False
311
+
312
+ try:
313
+ agent_keys = list(Agent().get_keys())
314
+ except Exception:
315
+ return False
316
+
317
+ if not agent_keys:
318
+ return False
319
+
320
+ # Skip the first agent key, since Paramiko already attempted it
321
+ attempt_keys = agent_keys[1:] if len(agent_keys) > 1 else agent_keys
322
+
323
+ for agent_key in attempt_keys:
324
+ if self.client is not None:
325
+ try:
326
+ self.client.close()
327
+ except Exception:
328
+ pass
329
+
330
+ self.client = SSHClient()
331
+
332
+ single_key_kwargs = dict(kwargs)
333
+ single_key_kwargs["allow_agent"] = False
334
+ single_key_kwargs["pkey"] = agent_key
335
+
336
+ try:
337
+ self.client.connect(hostname, **single_key_kwargs)
338
+ return True
339
+ except AuthenticationException:
340
+ continue
341
+ except SSHException as retry_error:
342
+ if "no existing session" in str(retry_error).lower():
343
+ continue
344
+ raise retry_error
345
+
346
+ return False
347
+
293
348
  @override
294
349
  def run_shell_command(
295
350
  self,
@@ -342,8 +397,10 @@ class SSHConnector(BaseConnector):
342
397
  get_pty=_get_pty,
343
398
  )
344
399
 
400
+ # Write any stdin and then close it
345
401
  if _stdin:
346
402
  write_stdin(_stdin, stdin_buffer)
403
+ stdin_buffer.close()
347
404
 
348
405
  combined_output = read_output_buffers(
349
406
  stdout_buffer,
@@ -22,17 +22,17 @@ if TYPE_CHECKING:
22
22
 
23
23
 
24
24
  SUDO_ASKPASS_ENV_VAR = "PYINFRA_SUDO_PASSWORD"
25
+
26
+
25
27
  SUDO_ASKPASS_COMMAND = r"""
26
- temp=$(mktemp "${{TMPDIR:=/tmp}}/pyinfra-sudo-askpass-XXXXXXXXXXXX")
28
+ temp=$(mktemp "${{TMPDIR:={0}}}/pyinfra-sudo-askpass-XXXXXXXXXXXX")
27
29
  cat >"$temp"<<'__EOF__'
28
30
  #!/bin/sh
29
- printf '%s\n' "${0}"
31
+ printf '%s\n' "${1}"
30
32
  __EOF__
31
33
  chmod 755 "$temp"
32
34
  echo "$temp"
33
- """.format(
34
- SUDO_ASKPASS_ENV_VAR,
35
- )
35
+ """
36
36
 
37
37
 
38
38
  def run_local_process(
@@ -44,11 +44,14 @@ def run_local_process(
44
44
  ) -> tuple[int, "CommandOutput"]:
45
45
  process = Popen(command, shell=True, stdout=PIPE, stderr=PIPE, stdin=PIPE)
46
46
 
47
- if stdin:
48
- write_stdin(stdin, process.stdin)
49
-
50
47
  assert process.stdout is not None
51
48
  assert process.stderr is not None
49
+ assert process.stdin is not None
50
+
51
+ # Write any stdin and then close it
52
+ if stdin:
53
+ write_stdin(stdin, process.stdin)
54
+ process.stdin.close()
52
55
 
53
56
  combined_output = read_output_buffers(
54
57
  process.stdout,
@@ -264,7 +267,9 @@ def extract_control_arguments(arguments: "ConnectorArguments") -> "ConnectorArgu
264
267
  def _ensure_sudo_askpass_set_for_host(host: "Host"):
265
268
  if host.connector_data.get("sudo_askpass_path"):
266
269
  return
267
- _, output = host.run_shell_command(SUDO_ASKPASS_COMMAND)
270
+ _, output = host.run_shell_command(
271
+ SUDO_ASKPASS_COMMAND.format(host.get_temp_dir_config(), SUDO_ASKPASS_ENV_VAR)
272
+ )
268
273
  host.connector_data["sudo_askpass_path"] = shlex.quote(output.stdout_lines[0])
269
274
 
270
275
 
@@ -318,6 +323,8 @@ def make_unix_command(
318
323
  _retries=0,
319
324
  _retry_delay=0,
320
325
  _retry_until=None,
326
+ # Temp dir config (ignored in command generation, used for temp file path generation)
327
+ _temp_dir=None,
321
328
  ) -> StringCommand:
322
329
  """
323
330
  Builds a shell command with various kwargs.
pyinfra/facts/crontab.py CHANGED
@@ -125,15 +125,15 @@ class Crontab(FactBase[CrontabFile]):
125
125
  # or CrontabFile.to_json()
126
126
  [
127
127
  {
128
- command: "/path/to/command",
129
- minute: "*",
130
- hour: "*",
131
- month: "*",
132
- day_of_month: "*",
133
- day_of_week: "*",
128
+ "command": "/path/to/command",
129
+ "minute": "*",
130
+ "hour": "*",
131
+ "month": "*",
132
+ "day_of_month": "*",
133
+ "day_of_week": "*",
134
134
  },
135
135
  {
136
- "command": "echo another command
136
+ "command": "echo another command",
137
137
  "special_time": "@daily",
138
138
  }
139
139
  ]
pyinfra/facts/files.py CHANGED
@@ -664,8 +664,7 @@ class Block(FactBase):
664
664
 
665
665
  class FileContents(FactBase):
666
666
  """
667
- Returns the contents of a file as a list of lines. Works with both sha1sum and sha1. Returns
668
- ``None`` if the file doest not exist.
667
+ Returns the contents of a file as a list of lines. Returns ``None`` if the file does not exist.
669
668
  """
670
669
 
671
670
  @override
pyinfra/facts/npm.py CHANGED
@@ -30,7 +30,7 @@ class NpmPackages(FactBase):
30
30
  @override
31
31
  def command(self, directory=None):
32
32
  if directory:
33
- return ("cd {0} && npm list -g --depth=0").format(directory)
33
+ return ("! test -d {0} || (cd {0} && npm list -g --depth=0)").format(directory)
34
34
  return "npm list -g --depth=0"
35
35
 
36
36
  @override
pyinfra/facts/server.py CHANGED
@@ -52,12 +52,28 @@ class Path(FactBase):
52
52
 
53
53
  class TmpDir(FactBase):
54
54
  """
55
- Returns the temporary directory of the current server, if configured.
55
+ Returns the temporary directory of the current server.
56
+
57
+ According to POSIX standards, checks environment variables in this order:
58
+ 1. TMPDIR (if set and accessible)
59
+ 2. TMP (if set and accessible)
60
+ 3. TEMP (if set and accessible)
61
+ 4. Falls back to empty string
56
62
  """
57
63
 
58
64
  @override
59
65
  def command(self):
60
- return "echo $TMPDIR"
66
+ return """
67
+ if [ -n "$TMPDIR" ] && [ -d "$TMPDIR" ] && [ -w "$TMPDIR" ]; then
68
+ echo "$TMPDIR"
69
+ elif [ -n "$TMP" ] && [ -d "$TMP" ] && [ -w "$TMP" ]; then
70
+ echo "$TMP"
71
+ elif [ -n "$TEMP" ] && [ -d "$TEMP" ] && [ -w "$TEMP" ]; then
72
+ echo "$TEMP"
73
+ else
74
+ echo ""
75
+ fi
76
+ """.strip()
61
77
 
62
78
 
63
79
  class Hostname(FactBase):
pyinfra/operations/apk.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- Manage apk packages.
2
+ Manage apk packages. (Alpine Linux)
3
3
  """
4
4
 
5
5
  from __future__ import annotations
@@ -64,6 +64,7 @@ def packages(
64
64
 
65
65
  .. code:: python
66
66
 
67
+ from pyinfra.operations import apk
67
68
  # Update package list and install packages
68
69
  apk.packages(
69
70
  name="Install Asterisk and Vim",
pyinfra/operations/apt.py CHANGED
@@ -4,7 +4,7 @@ Manage apt packages and repositories.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from datetime import timedelta
7
+ from datetime import datetime, timedelta, timezone
8
8
  from urllib.parse import urlparse
9
9
 
10
10
  from pyinfra import host
@@ -67,6 +67,7 @@ def key(src: str | None = None, keyserver: str | None = None, keyid: str | list[
67
67
 
68
68
  .. code:: python
69
69
 
70
+ from pyinfra.operations import apt
70
71
  # Note: If using URL, wget is assumed to be installed.
71
72
  apt.key(
72
73
  name="Add the Docker apt gpg key",
@@ -309,12 +310,19 @@ def update(cache_time: int | None = None):
309
310
  # Ubuntu provides this handy file
310
311
  cache_info = host.get_fact(File, path=APT_UPDATE_FILENAME)
311
312
 
312
- # Time on files is not tz-aware, and will be the same tz as the server's time,
313
- # so we can safely remove the tzinfo from the Date fact before comparison.
314
- host_cache_time = host.get_fact(Date).replace(tzinfo=None) - timedelta(seconds=cache_time)
315
- if cache_info and cache_info["mtime"] and cache_info["mtime"] > host_cache_time:
316
- host.noop("apt is already up to date")
317
- return
313
+ if cache_info and cache_info["mtime"]:
314
+ # The fact Date contains the date of the server in its timezone.
315
+ # cache_info["mtime"] ignores the timezone and consider the timestamp as UTC.
316
+ # So let's do the same here for the server current Date : ignore the
317
+ # timezone and consider it as UTC to have correct comparison with
318
+ # cache_info["mtime].
319
+ host_utc_current_time = datetime.fromtimestamp(
320
+ host.get_fact(Date).timestamp(), timezone.utc
321
+ ).replace(tzinfo=None)
322
+ host_cache_time = host_utc_current_time - timedelta(seconds=cache_time)
323
+ if cache_info["mtime"] > host_cache_time:
324
+ host.noop("apt is already up to date")
325
+ return
318
326
 
319
327
  yield "apt-get update"
320
328
 
@@ -61,6 +61,7 @@ def packages(
61
61
 
62
62
  .. code:: python
63
63
 
64
+ from pyinfra.operations import brew
64
65
  # Update package list and install packages
65
66
  brew.packages(
66
67
  name='Install Vim and vimpager',
@@ -54,6 +54,7 @@ def crontab(
54
54
 
55
55
  .. code:: python
56
56
 
57
+ from pyinfra.operations import crontab
57
58
  # simple example for a crontab
58
59
  crontab.crontab(
59
60
  name="Backup /etc weekly",
@@ -84,7 +85,9 @@ def crontab(
84
85
  ctb = ctb0
85
86
  name_comment = "# pyinfra-name={0}".format(cron_name)
86
87
 
87
- existing_crontab = ctb.get_command(command=command, name=cron_name)
88
+ existing_crontab = ctb.get_command(
89
+ command=command if cron_name is None else None, name=cron_name
90
+ )
88
91
  existing_crontab_command = existing_crontab["command"] if existing_crontab else command
89
92
  existing_crontab_match = existing_crontab["command"] if existing_crontab else command
90
93
 
pyinfra/operations/dnf.py CHANGED
@@ -25,9 +25,12 @@ def key(src: str):
25
25
 
26
26
  .. code:: python
27
27
 
28
+ from pyinfra import host
29
+ from pyinfra.operations import dnf
30
+ from pyinfra.facts.server import LinuxDistribution
28
31
  linux_id = host.get_fact(LinuxDistribution)["release_meta"].get("ID")
29
32
  dnf.key(
30
- name="Add the Docker CentOS gpg key",
33
+ name="Add the Docker gpg key",
31
34
  src=f"https://download.docker.com/linux/{linux_id}/gpg",
32
35
  )
33
36