pyinfra 3.1.1__py2.py3-none-any.whl → 3.3__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.
Files changed (104) hide show
  1. pyinfra/api/arguments.py +9 -2
  2. pyinfra/api/arguments_typed.py +4 -5
  3. pyinfra/api/command.py +22 -3
  4. pyinfra/api/config.py +5 -2
  5. pyinfra/api/deploy.py +4 -2
  6. pyinfra/api/facts.py +3 -0
  7. pyinfra/api/host.py +15 -7
  8. pyinfra/api/operation.py +2 -1
  9. pyinfra/api/state.py +1 -1
  10. pyinfra/connectors/base.py +34 -8
  11. pyinfra/connectors/chroot.py +7 -2
  12. pyinfra/connectors/docker.py +24 -8
  13. pyinfra/connectors/dockerssh.py +7 -2
  14. pyinfra/connectors/local.py +7 -2
  15. pyinfra/connectors/ssh.py +9 -2
  16. pyinfra/connectors/sshuserclient/client.py +42 -14
  17. pyinfra/connectors/sshuserclient/config.py +2 -0
  18. pyinfra/connectors/terraform.py +1 -1
  19. pyinfra/connectors/util.py +13 -9
  20. pyinfra/context.py +9 -2
  21. pyinfra/facts/apk.py +8 -1
  22. pyinfra/facts/apt.py +68 -0
  23. pyinfra/facts/brew.py +13 -0
  24. pyinfra/facts/bsdinit.py +3 -0
  25. pyinfra/facts/cargo.py +5 -0
  26. pyinfra/facts/choco.py +6 -0
  27. pyinfra/facts/crontab.py +195 -0
  28. pyinfra/facts/deb.py +10 -0
  29. pyinfra/facts/dnf.py +5 -0
  30. pyinfra/facts/docker.py +16 -0
  31. pyinfra/facts/efibootmgr.py +113 -0
  32. pyinfra/facts/files.py +112 -7
  33. pyinfra/facts/flatpak.py +7 -0
  34. pyinfra/facts/freebsd.py +75 -0
  35. pyinfra/facts/gem.py +5 -0
  36. pyinfra/facts/git.py +12 -2
  37. pyinfra/facts/gpg.py +7 -0
  38. pyinfra/facts/hardware.py +13 -0
  39. pyinfra/facts/iptables.py +9 -1
  40. pyinfra/facts/launchd.py +5 -0
  41. pyinfra/facts/lxd.py +5 -0
  42. pyinfra/facts/mysql.py +9 -2
  43. pyinfra/facts/npm.py +5 -0
  44. pyinfra/facts/openrc.py +8 -0
  45. pyinfra/facts/opkg.py +245 -0
  46. pyinfra/facts/pacman.py +9 -1
  47. pyinfra/facts/pip.py +5 -0
  48. pyinfra/facts/pipx.py +82 -0
  49. pyinfra/facts/pkg.py +4 -0
  50. pyinfra/facts/pkgin.py +5 -0
  51. pyinfra/facts/podman.py +54 -0
  52. pyinfra/facts/postgres.py +10 -2
  53. pyinfra/facts/rpm.py +11 -0
  54. pyinfra/facts/runit.py +7 -0
  55. pyinfra/facts/selinux.py +16 -0
  56. pyinfra/facts/server.py +87 -79
  57. pyinfra/facts/snap.py +7 -0
  58. pyinfra/facts/systemd.py +5 -0
  59. pyinfra/facts/sysvinit.py +4 -0
  60. pyinfra/facts/upstart.py +5 -0
  61. pyinfra/facts/util/__init__.py +4 -1
  62. pyinfra/facts/util/units.py +30 -0
  63. pyinfra/facts/vzctl.py +5 -0
  64. pyinfra/facts/xbps.py +6 -1
  65. pyinfra/facts/yum.py +5 -0
  66. pyinfra/facts/zfs.py +41 -21
  67. pyinfra/facts/zypper.py +5 -0
  68. pyinfra/local.py +3 -2
  69. pyinfra/operations/apt.py +36 -22
  70. pyinfra/operations/crontab.py +189 -0
  71. pyinfra/operations/docker.py +61 -56
  72. pyinfra/operations/files.py +65 -1
  73. pyinfra/operations/freebsd/__init__.py +12 -0
  74. pyinfra/operations/freebsd/freebsd_update.py +70 -0
  75. pyinfra/operations/freebsd/pkg.py +219 -0
  76. pyinfra/operations/freebsd/service.py +116 -0
  77. pyinfra/operations/freebsd/sysrc.py +92 -0
  78. pyinfra/operations/git.py +23 -7
  79. pyinfra/operations/opkg.py +88 -0
  80. pyinfra/operations/pip.py +3 -2
  81. pyinfra/operations/pipx.py +90 -0
  82. pyinfra/operations/postgres.py +114 -27
  83. pyinfra/operations/runit.py +2 -0
  84. pyinfra/operations/server.py +9 -181
  85. pyinfra/operations/util/docker.py +44 -22
  86. pyinfra/operations/zfs.py +3 -3
  87. {pyinfra-3.1.1.dist-info → pyinfra-3.3.dist-info}/LICENSE.md +1 -1
  88. {pyinfra-3.1.1.dist-info → pyinfra-3.3.dist-info}/METADATA +25 -25
  89. pyinfra-3.3.dist-info/RECORD +187 -0
  90. pyinfra_cli/exceptions.py +5 -0
  91. pyinfra_cli/inventory.py +26 -9
  92. pyinfra_cli/log.py +3 -0
  93. pyinfra_cli/main.py +9 -8
  94. pyinfra_cli/prints.py +19 -4
  95. pyinfra_cli/util.py +3 -0
  96. pyinfra_cli/virtualenv.py +1 -1
  97. tests/test_cli/test_cli_deploy.py +15 -13
  98. tests/test_cli/test_cli_inventory.py +53 -0
  99. tests/test_connectors/test_ssh.py +302 -182
  100. tests/test_connectors/test_sshuserclient.py +68 -1
  101. pyinfra-3.1.1.dist-info/RECORD +0 -172
  102. {pyinfra-3.1.1.dist-info → pyinfra-3.3.dist-info}/WHEEL +0 -0
  103. {pyinfra-3.1.1.dist-info → pyinfra-3.3.dist-info}/entry_points.txt +0 -0
  104. {pyinfra-3.1.1.dist-info → pyinfra-3.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,195 @@
1
+ import re
2
+ from typing import Dict, List, Optional, TypedDict, Union
3
+
4
+ from typing_extensions import NotRequired, override
5
+
6
+ from pyinfra.api import FactBase
7
+ from pyinfra.api.util import try_int
8
+
9
+
10
+ class CrontabDict(TypedDict):
11
+ command: NotRequired[str]
12
+ # handles cases like CRON_TZ=UTC
13
+ env: NotRequired[str]
14
+ minute: NotRequired[Union[int, str]]
15
+ hour: NotRequired[Union[int, str]]
16
+ month: NotRequired[Union[int, str]]
17
+ day_of_month: NotRequired[Union[int, str]]
18
+ day_of_week: NotRequired[Union[int, str]]
19
+ comments: NotRequired[List[str]]
20
+ special_time: NotRequired[str]
21
+
22
+
23
+ # for compatibility, also keeps a dict of command -> crontab dict
24
+ class CrontabFile:
25
+ commands: List[CrontabDict]
26
+
27
+ def __init__(self, input_dict: Optional[Dict[str, CrontabDict]] = None):
28
+ super().__init__()
29
+ self.commands = []
30
+ if input_dict:
31
+ for command, others in input_dict.items():
32
+ val = others.copy()
33
+ val["command"] = command
34
+ self.add_item(val)
35
+
36
+ def add_item(self, item: CrontabDict):
37
+ self.commands.append(item)
38
+
39
+ def __len__(self):
40
+ return len(self.commands)
41
+
42
+ def __bool__(self):
43
+ return len(self) > 0
44
+
45
+ def items(self):
46
+ return {item.get("command") or item.get("env"): item for item in self.commands}
47
+
48
+ def get_command(
49
+ self, command: Optional[str] = None, name: Optional[str] = None
50
+ ) -> Optional[CrontabDict]:
51
+ assert command or name, "Either command or name must be provided"
52
+
53
+ name_comment = "# pyinfra-name={0}".format(name)
54
+ for cmd in self.commands:
55
+ if cmd.get("command") == command:
56
+ return cmd
57
+ if cmd.get("comments") and name_comment in cmd["comments"]:
58
+ return cmd
59
+ return None
60
+
61
+ def get_env(self, env: str) -> Optional[CrontabDict]:
62
+ for cmd in self.commands:
63
+ if cmd.get("env") == env:
64
+ return cmd
65
+ return None
66
+
67
+ def get(self, item: str) -> Optional[CrontabDict]:
68
+ return self.get_command(command=item, name=item) or self.get_env(item)
69
+
70
+ def __getitem__(self, item) -> Optional[CrontabDict]:
71
+ return self.get(item)
72
+
73
+ @override
74
+ def __repr__(self):
75
+ return f"CrontabResult({self.commands})"
76
+
77
+ # noinspection PyMethodMayBeStatic
78
+ def format_item(self, item: CrontabDict):
79
+ lines = []
80
+ for comment in item.get("comments", []):
81
+ lines.append(comment)
82
+
83
+ if "env" in item:
84
+ lines.append(item["env"])
85
+ elif "special_time" in item:
86
+ lines.append(f"{item['special_time']} {item['command']}")
87
+ else:
88
+ lines.append(
89
+ f"{item['minute']} {item['hour']} "
90
+ f"{item['day_of_month']} {item['month']} {item['day_of_week']} "
91
+ f"{item['command']}"
92
+ )
93
+ return "\n".join(lines)
94
+
95
+ @override
96
+ def __str__(self):
97
+ return "\n".join(self.format_item(item) for item in self.commands)
98
+
99
+ def to_json(self):
100
+ return self.commands
101
+
102
+
103
+ _crontab_env_re = re.compile(r"^\s*([A-Z_]+)=(.*)$")
104
+
105
+
106
+ class Crontab(FactBase[CrontabFile]):
107
+ """
108
+ Returns a dictionary of CrontabFile.
109
+
110
+ .. code:: python
111
+
112
+ # CrontabFile.items()
113
+ {
114
+ "/path/to/command": {
115
+ "minute": "*",
116
+ "hour": "*",
117
+ "month": "*",
118
+ "day_of_month": "*",
119
+ "day_of_week": "*",
120
+ },
121
+ "echo another command": {
122
+ "special_time": "@daily",
123
+ },
124
+ }
125
+ # or CrontabFile.to_json()
126
+ [
127
+ {
128
+ command: "/path/to/command",
129
+ minute: "*",
130
+ hour: "*",
131
+ month: "*",
132
+ day_of_month: "*",
133
+ day_of_week: "*",
134
+ },
135
+ {
136
+ "command": "echo another command
137
+ "special_time": "@daily",
138
+ }
139
+ ]
140
+ """
141
+
142
+ default = CrontabFile
143
+
144
+ @override
145
+ def requires_command(self, user=None) -> str:
146
+ return "crontab"
147
+
148
+ @override
149
+ def command(self, user=None):
150
+ if user:
151
+ return "crontab -l -u {0} || true".format(user)
152
+ return "crontab -l || true"
153
+
154
+ @override
155
+ def process(self, output):
156
+ crons = CrontabFile()
157
+ current_comments = []
158
+
159
+ for line in output:
160
+ line = line.strip()
161
+ if not line or line.startswith("#"):
162
+ current_comments.append(line)
163
+ continue
164
+
165
+ if line.startswith("@"):
166
+ special_time, command = line.split(None, 1)
167
+ item = CrontabDict(
168
+ command=command,
169
+ special_time=special_time,
170
+ comments=current_comments,
171
+ )
172
+ crons.add_item(item)
173
+
174
+ elif _crontab_env_re.match(line):
175
+ # handle environment variables
176
+ item = CrontabDict(
177
+ env=line,
178
+ comments=current_comments,
179
+ )
180
+ crons.add_item(item)
181
+ else:
182
+ minute, hour, day_of_month, month, day_of_week, command = line.split(None, 5)
183
+ item = CrontabDict(
184
+ command=command,
185
+ minute=try_int(minute),
186
+ hour=try_int(hour),
187
+ month=try_int(month),
188
+ day_of_month=try_int(day_of_month),
189
+ day_of_week=try_int(day_of_week),
190
+ comments=current_comments,
191
+ )
192
+ crons.add_item(item)
193
+
194
+ current_comments = []
195
+ return crons
pyinfra/facts/deb.py CHANGED
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  import re
4
4
  import shlex
5
5
 
6
+ from typing_extensions import override
7
+
6
8
  from pyinfra.api import FactBase
7
9
 
8
10
  from .util.packaging import parse_packages
@@ -16,9 +18,11 @@ class DebArch(FactBase):
16
18
  Returns the architecture string used in apt repository sources, eg ``amd64``.
17
19
  """
18
20
 
21
+ @override
19
22
  def command(self) -> str:
20
23
  return "dpkg --print-architecture"
21
24
 
25
+ @override
22
26
  def requires_command(self) -> str:
23
27
  return "dpkg"
24
28
 
@@ -34,9 +38,11 @@ class DebPackages(FactBase):
34
38
  }
35
39
  """
36
40
 
41
+ @override
37
42
  def command(self) -> str:
38
43
  return "dpkg -l"
39
44
 
45
+ @override
40
46
  def requires_command(self) -> str:
41
47
  return "dpkg"
42
48
 
@@ -47,6 +53,7 @@ class DebPackages(FactBase):
47
53
  DEB_PACKAGE_VERSION_REGEX,
48
54
  )
49
55
 
56
+ @override
50
57
  def process(self, output):
51
58
  return parse_packages(self.regex, output)
52
59
 
@@ -61,14 +68,17 @@ class DebPackage(FactBase):
61
68
  "version": r"^Version:\s+({0})$".format(DEB_PACKAGE_VERSION_REGEX),
62
69
  }
63
70
 
71
+ @override
64
72
  def requires_command(self, package) -> str:
65
73
  return "dpkg"
66
74
 
75
+ @override
67
76
  def command(self, package):
68
77
  return "! test -e {0} && (dpkg -s {0} 2>/dev/null || true) || dpkg -I {0}".format(
69
78
  shlex.quote(package)
70
79
  )
71
80
 
81
+ @override
72
82
  def process(self, output):
73
83
  data = {}
74
84
 
pyinfra/facts/dnf.py CHANGED
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing_extensions import override
4
+
3
5
  from pyinfra.api import FactBase
4
6
 
5
7
  from .util import make_cat_files_command
@@ -23,6 +25,7 @@ class DnfRepositories(FactBase):
23
25
  ]
24
26
  """
25
27
 
28
+ @override
26
29
  def command(self) -> str:
27
30
  return make_cat_files_command(
28
31
  "/etc/dnf.conf",
@@ -30,10 +33,12 @@ class DnfRepositories(FactBase):
30
33
  "/etc/yum.repos.d/*.repo",
31
34
  )
32
35
 
36
+ @override
33
37
  def requires_command(self) -> str:
34
38
  return "dnf"
35
39
 
36
40
  default = list
37
41
 
42
+ @override
38
43
  def process(self, output):
39
44
  return parse_yum_repositories(output)
pyinfra/facts/docker.py CHANGED
@@ -1,7 +1,15 @@
1
+ """
2
+ Facts about Docker containers, volumes and networks. These facts give you information from the view
3
+ of the current inventory host. See the :doc:`../connectors/docker` to use Docker containers as
4
+ inventory directly.
5
+ """
6
+
1
7
  from __future__ import annotations
2
8
 
3
9
  import json
4
10
 
11
+ from typing_extensions import override
12
+
5
13
  from pyinfra.api import FactBase
6
14
 
7
15
 
@@ -10,9 +18,11 @@ class DockerFactBase(FactBase):
10
18
 
11
19
  docker_type: str
12
20
 
21
+ @override
13
22
  def requires_command(self, *args, **kwargs) -> str:
14
23
  return "docker"
15
24
 
25
+ @override
16
26
  def process(self, output):
17
27
  output = "".join(output)
18
28
  return json.loads(output)
@@ -23,6 +33,7 @@ class DockerSystemInfo(DockerFactBase):
23
33
  Returns ``docker system info`` output in JSON format.
24
34
  """
25
35
 
36
+ @override
26
37
  def command(self) -> str:
27
38
  return 'docker system info --format="{{json .}}"'
28
39
 
@@ -36,6 +47,7 @@ class DockerContainers(DockerFactBase):
36
47
  Returns ``docker inspect`` output for all Docker containers.
37
48
  """
38
49
 
50
+ @override
39
51
  def command(self) -> str:
40
52
  return "docker container inspect `docker ps -qa`"
41
53
 
@@ -45,6 +57,7 @@ class DockerImages(DockerFactBase):
45
57
  Returns ``docker inspect`` output for all Docker images.
46
58
  """
47
59
 
60
+ @override
48
61
  def command(self) -> str:
49
62
  return "docker image inspect `docker images -q`"
50
63
 
@@ -54,6 +67,7 @@ class DockerNetworks(DockerFactBase):
54
67
  Returns ``docker inspect`` output for all Docker networks.
55
68
  """
56
69
 
70
+ @override
57
71
  def command(self) -> str:
58
72
  return "docker network inspect `docker network ls -q`"
59
73
 
@@ -63,6 +77,7 @@ class DockerNetworks(DockerFactBase):
63
77
 
64
78
 
65
79
  class DockerSingleMixin(DockerFactBase):
80
+ @override
66
81
  def command(self, object_id):
67
82
  return "docker {0} inspect {1} 2>&- || true".format(
68
83
  self.docker_type,
@@ -99,6 +114,7 @@ class DockerVolumes(DockerFactBase):
99
114
  Returns ``docker inspect`` output for all Docker volumes.
100
115
  """
101
116
 
117
+ @override
102
118
  def command(self) -> str:
103
119
  return "docker volume inspect `docker volume ls -q`"
104
120
 
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Iterable, List, Optional, Tuple, TypedDict
4
+
5
+ from typing_extensions import override
6
+
7
+ from pyinfra.api import FactBase
8
+
9
+ BootEntry = Tuple[bool, str]
10
+ EFIBootMgrInfoDict = TypedDict(
11
+ "EFIBootMgrInfoDict",
12
+ {
13
+ "BootNext": Optional[int],
14
+ "BootCurrent": Optional[int],
15
+ "Timeout": Optional[int],
16
+ "BootOrder": Optional[List[int]],
17
+ "Entries": Dict[int, BootEntry],
18
+ },
19
+ )
20
+
21
+
22
+ class EFIBootMgr(FactBase[Optional[EFIBootMgrInfoDict]]):
23
+ """
24
+ Returns information about the UEFI boot variables:
25
+
26
+ .. code:: python
27
+
28
+ {
29
+ "BootNext": 6,
30
+ "BootCurrent": 6,
31
+ "Timeout": 0,
32
+ "BootOrder": [1,4,3],
33
+ "Entries": {
34
+ 1: (True, "myefi1"),
35
+ 2: (False, "myefi2.efi"),
36
+ 3: (True, "myefi3.efi"),
37
+ 4: (True, "grub2.efi"),
38
+ },
39
+ }
40
+ """
41
+
42
+ @override
43
+ def requires_command(self, *args: Any, **kwargs: Any) -> str:
44
+ return "efibootmgr"
45
+
46
+ @override
47
+ def command(self) -> str:
48
+ # FIXME: Use '|| true' to properly handle the case where
49
+ # 'efibootmgr' is run on a non-UEFI system
50
+ return "efibootmgr || true"
51
+
52
+ @override
53
+ def process(self, output: Iterable[str]) -> Optional[EFIBootMgrInfoDict]:
54
+ # This parsing code closely follows the printing code of efibootmgr
55
+ # at <https://github.com/rhboot/efibootmgr/blob/main/src/efibootmgr.c#L2020-L2048>
56
+
57
+ info: EFIBootMgrInfoDict = {
58
+ "BootNext": None,
59
+ "BootCurrent": None,
60
+ "Timeout": None,
61
+ "BootOrder": [],
62
+ "Entries": {},
63
+ }
64
+
65
+ output = iter(output)
66
+
67
+ line: Optional[str] = next(output, None)
68
+
69
+ if line is None:
70
+ # efibootmgr run on a non-UEFI system, likely printed
71
+ # "EFI variables are not supported on this system."
72
+ # to stderr
73
+ return None
74
+
75
+ # 1. Maybe have BootNext
76
+ if line and line.startswith("BootNext: "):
77
+ info["BootNext"] = int(line[len("BootNext: ") :], 16)
78
+ line = next(output, None)
79
+
80
+ # 2. Maybe have BootCurrent
81
+ if line and line.startswith("BootCurrent: "):
82
+ info["BootCurrent"] = int(line[len("BootCurrent: ") :], 16)
83
+ line = next(output, None)
84
+
85
+ # 3. Maybe have Timeout
86
+ if line and line.startswith("Timeout: "):
87
+ info["Timeout"] = int(line[len("Timeout: ") : -len(" seconds")])
88
+ line = next(output, None)
89
+
90
+ # 4. `show_order`
91
+ if line and line.startswith("BootOrder: "):
92
+ entries = line[len("BootOrder: ") :]
93
+ info["BootOrder"] = list(map(lambda x: int(x, 16), entries.split(",")))
94
+ line = next(output, None)
95
+
96
+ # 5. `show_vars`: The actual boot entries
97
+ while line is not None and line.startswith("Boot"):
98
+ number = int(line[4:8], 16)
99
+
100
+ # Entries marked with a * are active
101
+ active = line[8:9] == "*"
102
+
103
+ # TODO: Maybe split and parse (name vs. arguments ?), might require --verbose ?
104
+ entry = line[10:]
105
+ info["Entries"][number] = (active, entry)
106
+ line = next(output, None)
107
+
108
+ # 6. `show_mirror`
109
+ # Currently not implemented, since I haven't actually encountered this in the wild.
110
+ if line is not None:
111
+ raise ValueError(f"Unexpected line '{line}' while parsing")
112
+
113
+ return info
pyinfra/facts/files.py CHANGED
@@ -1,5 +1,9 @@
1
1
  """
2
2
  The files facts provide information about the filesystem and it's contents on the target host.
3
+
4
+ Facts need to be imported before use, eg
5
+
6
+ from pyinfra.facts.files import File
3
7
  """
4
8
 
5
9
  from __future__ import annotations
@@ -10,11 +14,13 @@ import stat
10
14
  from datetime import datetime
11
15
  from typing import TYPE_CHECKING, List, Optional, Tuple, Union
12
16
 
13
- from typing_extensions import Literal, NotRequired, TypedDict
17
+ from typing_extensions import Literal, NotRequired, TypedDict, override
14
18
 
19
+ from pyinfra.api import StringCommand
15
20
  from pyinfra.api.command import QuoteString, make_formatted_string_command
16
21
  from pyinfra.api.facts import FactBase
17
22
  from pyinfra.api.util import try_int
23
+ from pyinfra.facts.util.units import parse_size
18
24
 
19
25
  LINUX_STAT_COMMAND = "stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'"
20
26
  BSD_STAT_COMMAND = "stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'"
@@ -109,6 +115,7 @@ class File(FactBase[Union[FileDict, Literal[False], None]]):
109
115
 
110
116
  type = "file"
111
117
 
118
+ @override
112
119
  def command(self, path):
113
120
  if path.startswith("~/"):
114
121
  # Do not quote leading tilde to ensure that it gets properly expanded by the shell
@@ -127,6 +134,7 @@ class File(FactBase[Union[FileDict, Literal[False], None]]):
127
134
  bsd_stat_command=BSD_STAT_COMMAND,
128
135
  )
129
136
 
137
+ @override
130
138
  def process(self, output) -> Union[FileDict, Literal[False], None]:
131
139
  match = re.match(STAT_REGEX, output[0])
132
140
  if not match:
@@ -231,6 +239,7 @@ class HashFileFactBase(FactBaseOptionalStr):
231
239
  _raw_cmd: str
232
240
  _regexes: Tuple[str, str]
233
241
 
242
+ @override
234
243
  def __init_subclass__(cls, digits: int, cmds: List[str], **kwargs) -> None:
235
244
  super().__init_subclass__(**kwargs)
236
245
 
@@ -247,10 +256,12 @@ class HashFileFactBase(FactBaseOptionalStr):
247
256
  r"^%s\s+\(%%s\)\s+=\s+([a-fA-F0-9]{%d})$" % (hash_name, digits),
248
257
  )
249
258
 
259
+ @override
250
260
  def command(self, path):
251
261
  self.path = path
252
262
  return make_formatted_string_command(self._raw_cmd, QuoteString(path))
253
263
 
264
+ @override
254
265
  def process(self, output) -> Optional[str]:
255
266
  output = output[0]
256
267
  escaped_path = re.escape(self.path)
@@ -274,6 +285,12 @@ class Sha256File(HashFileFactBase, digits=64, cmds=["sha256sum", "shasum -a 256"
274
285
  """
275
286
 
276
287
 
288
+ class Sha384File(HashFileFactBase, digits=96, cmds=["sha384sum", "shasum -a 384", "sha384"]):
289
+ """
290
+ Returns a SHA384 hash of a file, or ``None`` if the file does not exist.
291
+ """
292
+
293
+
277
294
  class Md5File(HashFileFactBase, digits=32, cmds=["md5sum", "md5"]):
278
295
  """
279
296
  Returns an MD5 hash of a file, or ``None`` if the file does not exist.
@@ -286,6 +303,7 @@ class FindInFile(FactBase):
286
303
  lines if the file exists, and ``None`` if the file does not.
287
304
  """
288
305
 
306
+ @override
289
307
  def command(self, path, pattern, interpolate_variables=False):
290
308
  self.exists_flag = "__pyinfra_exists_{0}".format(path)
291
309
 
@@ -304,6 +322,7 @@ class FindInFile(FactBase):
304
322
  QuoteString(self.exists_flag),
305
323
  )
306
324
 
325
+ @override
307
326
  def process(self, output):
308
327
  # If output is the special string: no matches, so return an empty list;
309
328
  # this allows us to differentiate between no matches in an existing file
@@ -319,15 +338,96 @@ class FindFilesBase(FactBase):
319
338
  default = list
320
339
  type_flag: str
321
340
 
341
+ @override
322
342
  def process(self, output):
323
343
  return output
324
344
 
325
- def command(self, path, quote_path=True):
326
- return make_formatted_string_command(
327
- "find {0} -type {type_flag} || true",
328
- QuoteString(path) if quote_path else path,
329
- type_flag=self.type_flag,
330
- )
345
+ @override
346
+ def command(
347
+ self,
348
+ path: str,
349
+ size: Optional[str | int] = None,
350
+ min_size: Optional[str | int] = None,
351
+ max_size: Optional[str | int] = None,
352
+ maxdepth: Optional[int] = None,
353
+ fname: Optional[str] = None,
354
+ iname: Optional[str] = None,
355
+ regex: Optional[str] = None,
356
+ args: Optional[List[str]] = None,
357
+ quote_path=True,
358
+ ):
359
+ """
360
+ @param path: the path to start the search from
361
+ @param size: exact size in bytes or human-readable format.
362
+ GB means 1e9 bytes, GiB means 2^30 bytes
363
+ @param min_size: minimum size in bytes or human-readable format
364
+ @param max_size: maximum size in bytes or human-readable format
365
+ @param maxdepth: maximum depth to descend to
366
+ @param name: True if the last component of the pathname being examined matches pattern.
367
+ Special shell pattern matching characters (“[”, “]”, “*”, and “?”)
368
+ may be used as part of pattern.
369
+ These characters may be matched explicitly
370
+ by escaping them with a backslash (“\\”).
371
+
372
+ @param iname: Like -name, but the match is case insensitive.
373
+ @param regex: True if the whole path of the file matches pattern using regular expression.
374
+ @param args: additional arguments to pass to find
375
+ @param quote_path: if the path should be quoted
376
+ @return:
377
+ """
378
+ if args is None:
379
+ args = []
380
+
381
+ def maybe_quote(value):
382
+ return QuoteString(value) if quote_path else value
383
+
384
+ command = [
385
+ "find",
386
+ maybe_quote(path),
387
+ "-type",
388
+ self.type_flag,
389
+ ]
390
+
391
+ """
392
+ Why we need special handling for size:
393
+ https://unix.stackexchange.com/questions/275925/why-does-find-size-1g-not-find-any-files
394
+ In short, 'c' means bytes, without it, it means 512-byte blocks.
395
+ If we use any units other than 'c', it has a weird rounding behavior,
396
+ and is implementation-specific. So, we always use 'c'
397
+ """
398
+ if "-size" not in args:
399
+ if min_size is not None:
400
+ command.append("-size")
401
+ command.append("+{0}c".format(parse_size(min_size)))
402
+
403
+ if max_size is not None:
404
+ command.append("-size")
405
+ command.append("-{0}c".format(parse_size(max_size)))
406
+
407
+ if size is not None:
408
+ command.append("-size")
409
+ command.append("{0}c".format(size))
410
+
411
+ if maxdepth is not None and "-maxdepth" not in args:
412
+ command.append("-maxdepth")
413
+ command.append("{0}".format(maxdepth))
414
+
415
+ if fname is not None and "-fname" not in args:
416
+ command.append("-name")
417
+ command.append(maybe_quote(fname))
418
+
419
+ if iname is not None and "-iname" not in args:
420
+ command.append("-iname")
421
+ command.append(maybe_quote(iname))
422
+
423
+ if regex is not None and "-regex" not in args:
424
+ command.append("-regex")
425
+ command.append(maybe_quote(regex))
426
+
427
+ command.append("||")
428
+ command.append("true")
429
+
430
+ return StringCommand(*command)
331
431
 
332
432
 
333
433
  class FindFiles(FindFilesBase):
@@ -359,15 +459,18 @@ class Flags(FactBase):
359
459
  Returns a list of the file flags set for the specified file or directory.
360
460
  """
361
461
 
462
+ @override
362
463
  def requires_command(self, path) -> str:
363
464
  return "chflags" # don't try to retrieve them if we can't set them
364
465
 
466
+ @override
365
467
  def command(self, path):
366
468
  return make_formatted_string_command(
367
469
  "! test -e {0} || stat -f %Sf {0}",
368
470
  QuoteString(path),
369
471
  )
370
472
 
473
+ @override
371
474
  def process(self, output):
372
475
  return [flag for flag in output[0].split(",") if len(flag) > 0] if len(output) == 1 else []
373
476
 
@@ -403,6 +506,7 @@ class Block(FactBase):
403
506
  # the list with a single empty string.
404
507
  default = list
405
508
 
509
+ @override
406
510
  def command(self, path, marker=None, begin=None, end=None):
407
511
  self.path = path
408
512
  start = (marker or MARKER_DEFAULT).format(mark=begin or MARKER_BEGIN_DEFAULT)
@@ -423,6 +527,7 @@ class Block(FactBase):
423
527
  )
424
528
  return cmd
425
529
 
530
+ @override
426
531
  def process(self, output):
427
532
  if output and (output[0] == f"{EXISTS}{self.path}"):
428
533
  return []