pyinfra 3.4.1__py2.py3-none-any.whl → 3.5__py2.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.
pyinfra/connectors/ssh.py CHANGED
@@ -5,7 +5,7 @@ from random import uniform
5
5
  from shutil import which
6
6
  from socket import error as socket_error, gaierror
7
7
  from time import sleep
8
- from typing import TYPE_CHECKING, Any, Iterable, Optional, Tuple
8
+ 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
@@ -17,6 +17,7 @@ from pyinfra.api.exceptions import ConnectError
17
17
  from pyinfra.api.util import get_file_io, memoize
18
18
 
19
19
  from .base import BaseConnector, DataMeta
20
+ from .scp import SCPClient
20
21
  from .ssh_util import get_private_key, raise_connect_error
21
22
  from .sshuserclient import SSHClient
22
23
  from .util import (
@@ -53,6 +54,7 @@ class ConnectorData(TypedDict):
53
54
  ssh_connect_retries: int
54
55
  ssh_connect_retry_min_delay: float
55
56
  ssh_connect_retry_max_delay: float
57
+ ssh_file_transfer_protocol: str
56
58
 
57
59
 
58
60
  connector_data_meta: dict[str, DataMeta] = {
@@ -92,9 +94,27 @@ connector_data_meta: dict[str, DataMeta] = {
92
94
  "Upper bound for random delay between retries",
93
95
  0.5,
94
96
  ),
97
+ "ssh_file_transfer_protocol": DataMeta(
98
+ "Protocol to use for file transfers. Can be ``sftp`` or ``scp``.",
99
+ "sftp",
100
+ ),
95
101
  }
96
102
 
97
103
 
104
+ class FileTransferClient(Protocol):
105
+ def getfo(self, remote_filename: str, fl: IO) -> Any | None:
106
+ """
107
+ Get a file from the remote host, writing to the provided file-like object.
108
+ """
109
+ ...
110
+
111
+ def putfo(self, fl: IO, remote_filename: str) -> Any | None:
112
+ """
113
+ Put a file to the remote host, reading from the provided file-like object.
114
+ """
115
+ ...
116
+
117
+
98
118
  class SSHConnector(BaseConnector):
99
119
  """
100
120
  Connect to hosts over SSH. This is the default connector and all targets default
@@ -268,7 +288,7 @@ class SSHConnector(BaseConnector):
268
288
 
269
289
  @override
270
290
  def disconnect(self) -> None:
271
- self.get_sftp_connection.cache.clear()
291
+ self.get_file_transfer_connection.cache.clear()
272
292
 
273
293
  @override
274
294
  def run_shell_command(
@@ -353,13 +373,25 @@ class SSHConnector(BaseConnector):
353
373
  return status, combined_output
354
374
 
355
375
  @memoize
356
- def get_sftp_connection(self):
376
+ def get_file_transfer_connection(self) -> FileTransferClient | None:
357
377
  assert self.client is not None
358
378
  transport = self.client.get_transport()
359
379
  assert transport is not None, "No transport"
360
380
  try:
361
- return SFTPClient.from_transport(transport)
381
+ if self.data["ssh_file_transfer_protocol"] == "sftp":
382
+ logger.debug("Using SFTP for file transfer")
383
+ return SFTPClient.from_transport(transport)
384
+ elif self.data["ssh_file_transfer_protocol"] == "scp":
385
+ logger.debug("Using SCP for file transfer")
386
+ return SCPClient(transport)
387
+ else:
388
+ raise ConnectError(
389
+ "Unsupported file transfer protocol: {0}".format(
390
+ self.data["ssh_file_transfer_protocol"],
391
+ ),
392
+ )
362
393
  except SSHException as e:
394
+
363
395
  raise ConnectError(
364
396
  (
365
397
  "Unable to establish SFTP connection. Check that the SFTP subsystem "
@@ -367,9 +399,9 @@ class SSHConnector(BaseConnector):
367
399
  ).format(self.host),
368
400
  ) from e
369
401
 
370
- def _get_file(self, remote_filename: str, filename_or_io):
402
+ def _get_file(self, remote_filename: str, filename_or_io: str | IO):
371
403
  with get_file_io(filename_or_io, "wb") as file_io:
372
- sftp = self.get_sftp_connection()
404
+ sftp = self.get_file_transfer_connection()
373
405
  sftp.getfo(remote_filename, file_io)
374
406
 
375
407
  @override
@@ -448,7 +480,7 @@ class SSHConnector(BaseConnector):
448
480
  while attempts < 3:
449
481
  try:
450
482
  with get_file_io(filename_or_io) as file_io:
451
- sftp = self.get_sftp_connection()
483
+ sftp = self.get_file_transfer_connection()
452
484
  sftp.putfo(file_io, remote_location)
453
485
  return
454
486
  except OSError as e:
@@ -314,6 +314,10 @@ def make_unix_command(
314
314
  # Doas config
315
315
  _doas=False,
316
316
  _doas_user=None,
317
+ # Retry config (ignored in command generation but passed through)
318
+ _retries=0,
319
+ _retry_delay=0,
320
+ _retry_until=None,
317
321
  ) -> StringCommand:
318
322
  """
319
323
  Builds a shell command with various kwargs.
pyinfra/facts/dnf.py CHANGED
@@ -16,11 +16,15 @@ class DnfRepositories(FactBase):
16
16
 
17
17
  [
18
18
  {
19
- "name": "CentOS-$releasever - Apps",
20
- "baseurl": "http://mirror.centos.org/$contentdir/$releasever/Apps/$basearch/os/",
21
- "gpgcheck": "1",
19
+ "repoid": "baseos",
20
+ "name": "AlmaLinux $releasever - BaseOS",
21
+ "mirrorlist": "https://mirrors.almalinux.org/mirrorlist/$releasever/baseos",
22
22
  "enabled": "1",
23
- "gpgkey": "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial",
23
+ "gpgcheck": "1",
24
+ "countme": "1",
25
+ "gpgkey": "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-AlmaLinux-9",
26
+ "metadata_expire": "86400",
27
+ "enabled_metadata": "1"
24
28
  },
25
29
  ]
26
30
  """
pyinfra/facts/docker.py CHANGED
@@ -76,6 +76,28 @@ class DockerNetworks(DockerFactBase):
76
76
  return "docker network inspect `docker network ls -q`"
77
77
 
78
78
 
79
+ class DockerVolumes(DockerFactBase):
80
+ """
81
+ Returns ``docker inspect`` output for all Docker volumes.
82
+ """
83
+
84
+ @override
85
+ def command(self) -> str:
86
+ return "docker volume inspect `docker volume ls -q`"
87
+
88
+
89
+ class DockerPlugins(DockerFactBase):
90
+ """
91
+ Returns ``docker plugin inspect`` output for all Docker plugins.
92
+ """
93
+
94
+ @override
95
+ def command(self) -> str:
96
+ return """
97
+ ids=$(docker plugin ls -q) && [ -n "$ids" ] && docker plugin inspect $ids || echo "[]"
98
+ """.strip()
99
+
100
+
79
101
  # Single Docker objects
80
102
  #
81
103
 
@@ -113,19 +135,17 @@ class DockerNetwork(DockerSingleMixin):
113
135
  docker_type = "network"
114
136
 
115
137
 
116
- class DockerVolumes(DockerFactBase):
138
+ class DockerVolume(DockerSingleMixin):
117
139
  """
118
- Returns ``docker inspect`` output for all Docker volumes.
140
+ Returns ``docker inspect`` output for a single Docker container.
119
141
  """
120
142
 
121
- @override
122
- def command(self) -> str:
123
- return "docker volume inspect `docker volume ls -q`"
143
+ docker_type = "volume"
124
144
 
125
145
 
126
- class DockerVolume(DockerSingleMixin):
146
+ class DockerPlugin(DockerSingleMixin):
127
147
  """
128
- Returns ``docker inspect`` output for a single Docker container.
148
+ Returns ``docker plugin inspect`` output for a single Docker plugin.
129
149
  """
130
150
 
131
- docker_type = "volume"
151
+ docker_type = "plugin"
pyinfra/facts/files.py CHANGED
@@ -24,13 +24,21 @@ from pyinfra.facts.util.units import parse_size
24
24
 
25
25
  LINUX_STAT_COMMAND = "stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'"
26
26
  BSD_STAT_COMMAND = "stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'"
27
+ LS_COMMAND = "ls -ld"
27
28
 
28
29
  STAT_REGEX = (
29
30
  r"user=(.*) group=(.*) mode=(.*) "
30
- r"atime=([0-9]*) mtime=([0-9]*) ctime=([0-9]*) "
31
+ r"atime=(-?[0-9]*) mtime=(-?[0-9]*) ctime=(-?[0-9]*) "
31
32
  r"size=([0-9]*) (.*)"
32
33
  )
33
34
 
35
+ # ls -ld output: permissions links user group size month day year/time path
36
+ # Supports attribute markers: . (SELinux), @ (extended attrs), + (ACL)
37
+ # Handles both "MMM DD" and "DD MMM" date formats
38
+ LS_REGEX = (
39
+ r"^([dlbcsp-][-rwxstST]{9}[.@+]?)\s+\d+\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$"
40
+ )
41
+
34
42
  FLAG_TO_TYPE = {
35
43
  "b": "block",
36
44
  "c": "character",
@@ -82,6 +90,108 @@ def _parse_datetime(value: str) -> Optional[datetime]:
82
90
  return None
83
91
 
84
92
 
93
+ def _parse_ls_timestamp(month: str, day: str, year_or_time: str) -> Optional[datetime]:
94
+ """
95
+ Parse ls timestamp format.
96
+ Examples: "Jan 1 1970", "Apr 2 2025", "Dec 31 12:34"
97
+ """
98
+ try:
99
+ # Month abbreviation to number mapping
100
+ month_map = {
101
+ "Jan": 1,
102
+ "Feb": 2,
103
+ "Mar": 3,
104
+ "Apr": 4,
105
+ "May": 5,
106
+ "Jun": 6,
107
+ "Jul": 7,
108
+ "Aug": 8,
109
+ "Sep": 9,
110
+ "Oct": 10,
111
+ "Nov": 11,
112
+ "Dec": 12,
113
+ }
114
+
115
+ month_num = month_map.get(month)
116
+ if month_num is None:
117
+ return None
118
+
119
+ day_num = int(day)
120
+
121
+ # Check if year_or_time is a year (4 digits) or time (HH:MM)
122
+ if ":" in year_or_time:
123
+ # It's a time, assume current year
124
+ import time
125
+
126
+ current_year = time.gmtime().tm_year
127
+ hour, minute = map(int, year_or_time.split(":"))
128
+ return datetime(current_year, month_num, day_num, hour, minute)
129
+ else:
130
+ # It's a year
131
+ year_num = int(year_or_time)
132
+ return datetime(year_num, month_num, day_num)
133
+
134
+ except (ValueError, TypeError):
135
+ return None
136
+
137
+
138
+ def _parse_ls_output(output: str) -> Optional[tuple[FileDict, str]]:
139
+ """
140
+ Parse ls -ld output and extract file information.
141
+ Example: drwxr-xr-x 1 root root 416 Jan 1 1970 /
142
+ """
143
+ match = re.match(LS_REGEX, output.strip())
144
+ if not match:
145
+ return None
146
+
147
+ permissions = match.group(1)
148
+ user = match.group(2)
149
+ group = match.group(3)
150
+ size = match.group(4)
151
+ date_part1 = match.group(5)
152
+ date_part2 = match.group(6)
153
+ year_or_time = match.group(7)
154
+ path = match.group(8)
155
+
156
+ # Determine if it's "MMM DD" or "DD MMM" format
157
+ if date_part1.isdigit():
158
+ # "DD MMM" format (e.g., "22 Jun")
159
+ day = date_part1
160
+ month = date_part2
161
+ else:
162
+ # "MMM DD" format (e.g., "Jun 22")
163
+ month = date_part1
164
+ day = date_part2
165
+
166
+ # Extract file type from first character of permissions
167
+ path_type = FLAG_TO_TYPE[permissions[0]]
168
+
169
+ # Parse mode (skip first character which is file type, and any trailing attribute markers)
170
+ # Remove trailing attribute markers (.@+) if present
171
+ mode_str = permissions[1:10] # Take exactly 9 characters after file type
172
+ mode = _parse_mode(mode_str)
173
+
174
+ # Parse timestamp - ls shows modification time
175
+ mtime = _parse_ls_timestamp(month, day, year_or_time)
176
+
177
+ data: FileDict = {
178
+ "user": user,
179
+ "group": group,
180
+ "mode": mode,
181
+ "atime": None, # ls doesn't provide atime
182
+ "mtime": mtime,
183
+ "ctime": None, # ls doesn't provide ctime
184
+ "size": try_int(size),
185
+ }
186
+
187
+ # Handle symbolic links
188
+ if path_type == "link" and " -> " in path:
189
+ filename, target = path.split(" -> ", 1)
190
+ data["link_target"] = target.strip("'").lstrip("`")
191
+
192
+ return data, path_type
193
+
194
+
85
195
  class FileDict(TypedDict):
86
196
  mode: int
87
197
  size: Union[int, str]
@@ -127,41 +237,54 @@ class File(FactBase[Union[FileDict, Literal[False], None]]):
127
237
  (
128
238
  # only stat if the path exists (file or symlink)
129
239
  "! (test -e {0} || test -L {0} ) || "
130
- "( {linux_stat_command} {0} 2> /dev/null || {bsd_stat_command} {0} )"
240
+ "( {linux_stat_command} {0} 2> /dev/null || "
241
+ "{bsd_stat_command} {0} || {ls_command} {0} )"
131
242
  ),
132
243
  path,
133
244
  linux_stat_command=LINUX_STAT_COMMAND,
134
245
  bsd_stat_command=BSD_STAT_COMMAND,
246
+ ls_command=LS_COMMAND,
135
247
  )
136
248
 
137
249
  @override
138
250
  def process(self, output) -> Union[FileDict, Literal[False], None]:
251
+ # Try to parse as stat output first
139
252
  match = re.match(STAT_REGEX, output[0])
140
- if not match:
141
- return None
253
+ if match:
254
+ mode = match.group(3)
255
+ path_type = FLAG_TO_TYPE[mode[0]]
142
256
 
143
- mode = match.group(3)
144
- path_type = FLAG_TO_TYPE[mode[0]]
145
-
146
- data: FileDict = {
147
- "user": match.group(1),
148
- "group": match.group(2),
149
- "mode": _parse_mode(mode[1:]),
150
- "atime": _parse_datetime(match.group(4)),
151
- "mtime": _parse_datetime(match.group(5)),
152
- "ctime": _parse_datetime(match.group(6)),
153
- "size": try_int(match.group(7)),
154
- }
257
+ data: FileDict = {
258
+ "user": match.group(1),
259
+ "group": match.group(2),
260
+ "mode": _parse_mode(mode[1:]),
261
+ "atime": _parse_datetime(match.group(4)),
262
+ "mtime": _parse_datetime(match.group(5)),
263
+ "ctime": _parse_datetime(match.group(6)),
264
+ "size": try_int(match.group(7)),
265
+ }
266
+
267
+ if path_type != self.type:
268
+ return False
269
+
270
+ if path_type == "link":
271
+ filename = match.group(8)
272
+ filename, target = filename.split(" -> ")
273
+ data["link_target"] = target.strip("'").lstrip("`")
274
+
275
+ return data
155
276
 
156
- if path_type != self.type:
157
- return False
277
+ # Try to parse as ls output
278
+ ls_result = _parse_ls_output(output[0])
279
+ if ls_result is not None:
280
+ data, path_type = ls_result
158
281
 
159
- if path_type == "link":
160
- filename = match.group(8)
161
- filename, target = filename.split(" -> ")
162
- data["link_target"] = target.strip("'").lstrip("`")
282
+ if path_type != self.type:
283
+ return False
163
284
 
164
- return data
285
+ return data
286
+
287
+ return None
165
288
 
166
289
 
167
290
  class Link(File):
@@ -520,10 +643,13 @@ class Block(FactBase):
520
643
  QuoteString(f"{EXISTS}{path}"),
521
644
  QuoteString(f"{MISSING}{path}"),
522
645
  )
523
- # m_f_s_c inserts blanks in unfortunate places, e.g. after first slash
524
- cmd = make_formatted_string_command(
525
- f"awk \\'/{end}/{{{{f=0}}}} f; /{start}/{{{{f=1}}}}\\' {{0}} || {backstop}",
646
+
647
+ cmd = StringCommand(
648
+ f"awk '/{end}/{{ f=0}} f; /{start}/{{ f=1}} ' ",
526
649
  QuoteString(path),
650
+ " || ",
651
+ backstop,
652
+ _separator="",
527
653
  )
528
654
  return cmd
529
655
 
@@ -534,3 +660,18 @@ class Block(FactBase):
534
660
  if output and (output[0] == f"{MISSING}{self.path}"):
535
661
  return None
536
662
  return output
663
+
664
+
665
+ class FileContents(FactBase):
666
+ """
667
+ Returns the contents of a file as a list of lines. Works with both sha1sum and sha1. Returns
668
+ ``None`` if the file doest not exist.
669
+ """
670
+
671
+ @override
672
+ def command(self, path):
673
+ return make_formatted_string_command("cat {0}", QuoteString(path))
674
+
675
+ @override
676
+ def process(self, output):
677
+ return output
pyinfra/facts/server.py CHANGED
@@ -6,7 +6,7 @@ import re
6
6
  import shutil
7
7
  from datetime import datetime
8
8
  from tempfile import mkdtemp
9
- from typing import Dict, Iterable, List, Optional, Tuple
9
+ from typing import Dict, Iterable, List, Optional, Tuple, Union
10
10
 
11
11
  from dateutil.parser import parse as parse_date
12
12
  from distro import distro
@@ -280,7 +280,7 @@ class Mounts(FactBase[Dict[str, MountsDict]]):
280
280
  return devices
281
281
 
282
282
 
283
- class Port(FactBase[Tuple[str, int] | Tuple[None, None]]):
283
+ class Port(FactBase[Union[Tuple[str, int], Tuple[None, None]]]):
284
284
  """
285
285
  Returns the process occuping a port and its PID
286
286
  """
@@ -290,7 +290,7 @@ class Port(FactBase[Tuple[str, int] | Tuple[None, None]]):
290
290
  return f"ss -lptnH 'src :{port}'"
291
291
 
292
292
  @override
293
- def process(self, output: Iterable[str]) -> Tuple[str, int] | Tuple[None, None]:
293
+ def process(self, output: Iterable[str]) -> Union[Tuple[str, int], Tuple[None, None]]:
294
294
  for line in output:
295
295
  proc, pid = line.split('"')[1], int(line.split("pid=")[1].split(",")[0])
296
296
  return (proc, pid)
@@ -661,7 +661,9 @@ class LinuxDistribution(FactBase[LinuxDistributionDict]):
661
661
 
662
662
  for filename, content in parts.items():
663
663
  with open(
664
- os.path.join(temp_etc_dir, os.path.basename(filename)), "w", encoding="utf-8"
664
+ os.path.join(temp_etc_dir, os.path.basename(filename)),
665
+ "w",
666
+ encoding="utf-8",
665
667
  ) as fp:
666
668
  fp.write(content)
667
669
 
@@ -901,3 +903,52 @@ class SecurityLimits(FactBase):
901
903
  )
902
904
 
903
905
  return limits
906
+
907
+
908
+ class RebootRequired(FactBase[bool]):
909
+ """
910
+ Returns a boolean indicating whether the system requires a reboot.
911
+
912
+ On Linux systems:
913
+ - Checks /var/run/reboot-required and /var/run/reboot-required.pkgs
914
+ - On Alpine Linux, compares installed kernel with running kernel
915
+
916
+ On FreeBSD systems:
917
+ - Compares running kernel version with installed kernel version
918
+ """
919
+
920
+ @override
921
+ def command(self) -> str:
922
+ return """
923
+ # Get OS type
924
+ OS_TYPE=$(uname -s)
925
+ if [ "$OS_TYPE" = "Linux" ]; then
926
+ # Check if it's Alpine Linux
927
+ if [ -f /etc/alpine-release ]; then
928
+ RUNNING_KERNEL=$(uname -r)
929
+ INSTALLED_KERNEL=$(ls -1 /lib/modules | sort -V | tail -n1)
930
+ if [ "$RUNNING_KERNEL" != "$INSTALLED_KERNEL" ]; then
931
+ echo "reboot_required"
932
+ exit 0
933
+ fi
934
+ else
935
+ # Check standard Linux reboot required files
936
+ if [ -f /var/run/reboot-required ] || [ -f /var/run/reboot-required.pkgs ]; then
937
+ echo "reboot_required"
938
+ exit 0
939
+ fi
940
+ fi
941
+ elif [ "$OS_TYPE" = "FreeBSD" ]; then
942
+ RUNNING_VERSION=$(freebsd-version -r)
943
+ INSTALLED_VERSION=$(freebsd-version -k)
944
+ if [ "$RUNNING_VERSION" != "$INSTALLED_VERSION" ]; then
945
+ echo "reboot_required"
946
+ exit 0
947
+ fi
948
+ fi
949
+ echo "no_reboot_required"
950
+ """
951
+
952
+ @override
953
+ def process(self, output) -> bool:
954
+ return list(output)[0].strip() == "reboot_required"
@@ -32,6 +32,7 @@ def _parse_yum_or_zypper_repositories(output):
32
32
  repos.append(current_repo)
33
33
  current_repo = {}
34
34
 
35
+ current_repo["repoid"] = line[1:-1]
35
36
  current_repo["name"] = line[1:-1]
36
37
 
37
38
  if current_repo and "=" in line:
pyinfra/facts/yum.py CHANGED
@@ -16,11 +16,15 @@ class YumRepositories(FactBase):
16
16
 
17
17
  [
18
18
  {
19
- "name": "CentOS-$releasever - Apps",
20
- "baseurl": "http://mirror.centos.org/$contentdir/$releasever/Apps/$basearch/os/",
21
- "gpgcheck": "1",
19
+ "repoid": "baseos",
20
+ "name": "AlmaLinux $releasever - BaseOS",
21
+ "mirrorlist": "https://mirrors.almalinux.org/mirrorlist/$releasever/baseos",
22
22
  "enabled": "1",
23
- "gpgkey": "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial",
23
+ "gpgcheck": "1",
24
+ "countme": "1",
25
+ "gpgkey": "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-AlmaLinux-9",
26
+ "metadata_expire": "86400",
27
+ "enabled_metadata": "1"
24
28
  },
25
29
  ]
26
30
  """
pyinfra/facts/zypper.py CHANGED
@@ -16,11 +16,11 @@ class ZypperRepositories(FactBase):
16
16
 
17
17
  [
18
18
  {
19
+ "repoid": "repo-oss",
19
20
  "name": "Main Repository",
20
21
  "enabled": "1",
21
- "autorefresh": "0",
22
- "baseurl": "http://download.opensuse.org/distribution/leap/$releasever/repo/oss/",
23
- "type": "rpm-md",
22
+ "autorefresh": "1",
23
+ "baseurl": "http://download.opensuse.org/distribution/leap/$releasever/repo/oss/"
24
24
  },
25
25
  ]
26
26
  """
@@ -157,7 +157,7 @@ def crontab(
157
157
  ),
158
158
  ):
159
159
  if not exists_name and cron_name:
160
- new_crontab_line = f"{name_comment}\n{new_crontab_line}"
160
+ new_crontab_line = f"{name_comment}\\n{new_crontab_line}"
161
161
  edit_commands.append(
162
162
  sed_replace(
163
163
  temp_filename,