pyinfra 3.1.1__py2.py3-none-any.whl → 3.2__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 (45) hide show
  1. pyinfra/api/arguments.py +9 -2
  2. pyinfra/api/deploy.py +4 -2
  3. pyinfra/api/host.py +5 -3
  4. pyinfra/connectors/docker.py +17 -6
  5. pyinfra/connectors/sshuserclient/client.py +26 -14
  6. pyinfra/facts/apk.py +3 -1
  7. pyinfra/facts/apt.py +60 -0
  8. pyinfra/facts/crontab.py +190 -0
  9. pyinfra/facts/docker.py +6 -0
  10. pyinfra/facts/efibootmgr.py +108 -0
  11. pyinfra/facts/files.py +93 -6
  12. pyinfra/facts/git.py +3 -2
  13. pyinfra/facts/mysql.py +1 -2
  14. pyinfra/facts/opkg.py +233 -0
  15. pyinfra/facts/pipx.py +74 -0
  16. pyinfra/facts/podman.py +47 -0
  17. pyinfra/facts/postgres.py +2 -0
  18. pyinfra/facts/server.py +39 -77
  19. pyinfra/facts/util/units.py +30 -0
  20. pyinfra/facts/zfs.py +22 -19
  21. pyinfra/local.py +3 -2
  22. pyinfra/operations/apt.py +27 -20
  23. pyinfra/operations/crontab.py +189 -0
  24. pyinfra/operations/docker.py +13 -12
  25. pyinfra/operations/files.py +18 -0
  26. pyinfra/operations/git.py +23 -7
  27. pyinfra/operations/opkg.py +88 -0
  28. pyinfra/operations/pip.py +3 -2
  29. pyinfra/operations/pipx.py +90 -0
  30. pyinfra/operations/postgres.py +15 -11
  31. pyinfra/operations/runit.py +2 -0
  32. pyinfra/operations/server.py +3 -177
  33. pyinfra/operations/zfs.py +3 -3
  34. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/METADATA +11 -12
  35. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/RECORD +45 -36
  36. pyinfra_cli/inventory.py +26 -9
  37. pyinfra_cli/prints.py +18 -3
  38. pyinfra_cli/util.py +3 -0
  39. tests/test_cli/test_cli_deploy.py +15 -13
  40. tests/test_cli/test_cli_inventory.py +53 -0
  41. tests/test_connectors/test_sshuserclient.py +68 -1
  42. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/LICENSE.md +0 -0
  43. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/WHEEL +0 -0
  44. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/entry_points.txt +0 -0
  45. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/top_level.txt +0 -0
pyinfra/facts/files.py CHANGED
@@ -12,9 +12,11 @@ from typing import TYPE_CHECKING, List, Optional, Tuple, Union
12
12
 
13
13
  from typing_extensions import Literal, NotRequired, TypedDict
14
14
 
15
+ from pyinfra.api import StringCommand
15
16
  from pyinfra.api.command import QuoteString, make_formatted_string_command
16
17
  from pyinfra.api.facts import FactBase
17
18
  from pyinfra.api.util import try_int
19
+ from pyinfra.facts.util.units import parse_size
18
20
 
19
21
  LINUX_STAT_COMMAND = "stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'"
20
22
  BSD_STAT_COMMAND = "stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'"
@@ -274,6 +276,12 @@ class Sha256File(HashFileFactBase, digits=64, cmds=["sha256sum", "shasum -a 256"
274
276
  """
275
277
 
276
278
 
279
+ class Sha384File(HashFileFactBase, digits=96, cmds=["sha384sum", "shasum -a 384", "sha384"]):
280
+ """
281
+ Returns a SHA384 hash of a file, or ``None`` if the file does not exist.
282
+ """
283
+
284
+
277
285
  class Md5File(HashFileFactBase, digits=32, cmds=["md5sum", "md5"]):
278
286
  """
279
287
  Returns an MD5 hash of a file, or ``None`` if the file does not exist.
@@ -322,12 +330,91 @@ class FindFilesBase(FactBase):
322
330
  def process(self, output):
323
331
  return output
324
332
 
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
- )
333
+ def command(
334
+ self,
335
+ path: str,
336
+ size: Optional[str | int] = None,
337
+ min_size: Optional[str | int] = None,
338
+ max_size: Optional[str | int] = None,
339
+ maxdepth: Optional[int] = None,
340
+ fname: Optional[str] = None,
341
+ iname: Optional[str] = None,
342
+ regex: Optional[str] = None,
343
+ args: Optional[List[str]] = None,
344
+ quote_path=True,
345
+ ):
346
+ """
347
+ @param path: the path to start the search from
348
+ @param size: exact size in bytes or human-readable format.
349
+ GB means 1e9 bytes, GiB means 2^30 bytes
350
+ @param min_size: minimum size in bytes or human-readable format
351
+ @param max_size: maximum size in bytes or human-readable format
352
+ @param maxdepth: maximum depth to descend to
353
+ @param name: True if the last component of the pathname being examined matches pattern.
354
+ Special shell pattern matching characters (“[”, “]”, “*”, and “?”)
355
+ may be used as part of pattern.
356
+ These characters may be matched explicitly
357
+ by escaping them with a backslash (“\\”).
358
+
359
+ @param iname: Like -name, but the match is case insensitive.
360
+ @param regex: True if the whole path of the file matches pattern using regular expression.
361
+ @param args: additional arguments to pass to find
362
+ @param quote_path: if the path should be quoted
363
+ @return:
364
+ """
365
+ if args is None:
366
+ args = []
367
+
368
+ def maybe_quote(value):
369
+ return QuoteString(value) if quote_path else value
370
+
371
+ command = [
372
+ "find",
373
+ maybe_quote(path),
374
+ "-type",
375
+ self.type_flag,
376
+ ]
377
+
378
+ """
379
+ Why we need special handling for size:
380
+ https://unix.stackexchange.com/questions/275925/why-does-find-size-1g-not-find-any-files
381
+ In short, 'c' means bytes, without it, it means 512-byte blocks.
382
+ If we use any units other than 'c', it has a weird rounding behavior,
383
+ and is implementation-specific. So, we always use 'c'
384
+ """
385
+ if "-size" not in args:
386
+ if min_size is not None:
387
+ command.append("-size")
388
+ command.append("+{0}c".format(parse_size(min_size)))
389
+
390
+ if max_size is not None:
391
+ command.append("-size")
392
+ command.append("-{0}c".format(parse_size(max_size)))
393
+
394
+ if size is not None:
395
+ command.append("-size")
396
+ command.append("{0}c".format(size))
397
+
398
+ if maxdepth is not None and "-maxdepth" not in args:
399
+ command.append("-maxdepth")
400
+ command.append("{0}".format(maxdepth))
401
+
402
+ if fname is not None and "-fname" not in args:
403
+ command.append("-name")
404
+ command.append(maybe_quote(fname))
405
+
406
+ if iname is not None and "-iname" not in args:
407
+ command.append("-iname")
408
+ command.append(maybe_quote(iname))
409
+
410
+ if regex is not None and "-regex" not in args:
411
+ command.append("-regex")
412
+ command.append(maybe_quote(regex))
413
+
414
+ command.append("||")
415
+ command.append("true")
416
+
417
+ return StringCommand(*command)
331
418
 
332
419
 
333
420
  class FindFiles(FindFilesBase):
pyinfra/facts/git.py CHANGED
@@ -21,9 +21,10 @@ class GitBranch(GitFactBase):
21
21
  class GitConfig(GitFactBase):
22
22
  default = dict
23
23
 
24
- def command(self, repo=None) -> str:
24
+ def command(self, repo=None, system=False) -> str:
25
25
  if repo is None:
26
- return "git config --global -l || true"
26
+ level = "--system" if system else "--global"
27
+ return f"git config {level} -l || true"
27
28
 
28
29
  return "! test -d {0} || (cd {0} && git config --local -l)".format(repo)
29
30
 
pyinfra/facts/mysql.py CHANGED
@@ -214,8 +214,7 @@ class MysqlUserGrants(MysqlFactBase):
214
214
  mysql_port,
215
215
  )
216
216
 
217
- @staticmethod
218
- def process(output):
217
+ def process(self, output):
219
218
  database_table_privileges = defaultdict(set)
220
219
 
221
220
  for line in output:
pyinfra/facts/opkg.py ADDED
@@ -0,0 +1,233 @@
1
+ """
2
+ Gather the information provided by ``opkg`` on OpenWrt systems:
3
+ + ``opkg`` configuration
4
+ + feeds configuration
5
+ + list of installed packages
6
+ + list of packages with available upgrades
7
+
8
+
9
+ see https://openwrt.org/docs/guide-user/additional-software/opkg
10
+ """
11
+
12
+ import re
13
+ from typing import Dict, NamedTuple, Union
14
+
15
+ from pyinfra import logger
16
+ from pyinfra.api import FactBase
17
+ from pyinfra.facts.util.packaging import parse_packages
18
+
19
+ # TODO - change NamedTuple to dataclass Opkgbut need to figure out how to get json serialization
20
+ # to work without changing core code
21
+
22
+
23
+ class OpkgPkgUpgradeInfo(NamedTuple):
24
+ installed: str
25
+ available: str
26
+
27
+
28
+ class OpkgConfInfo(NamedTuple):
29
+ paths: Dict[str, str] # list of paths, e.g. {'root':'/', 'ram':'/tmp}
30
+ list_dir: str # where package lists are stored, e.g. /var/opkg-lists
31
+ options: Dict[
32
+ str, Union[str, bool]
33
+ ] # mapping from option to value, e.g. {'check_signature': True}
34
+ arch_cfg: Dict[str, int] # priorities for architectures
35
+
36
+
37
+ class OpkgFeedInfo(NamedTuple):
38
+ url: str # url for the feed
39
+ fmt: str # format of the feed, e.g. "src/gz"
40
+ kind: str # whether it comes from the 'distribution' or is 'custom'
41
+
42
+
43
+ class OpkgConf(FactBase):
44
+ """
45
+ Returns a NamedTuple with the current configuration:
46
+
47
+ .. code:: python
48
+
49
+ ConfInfo(
50
+ paths = {
51
+ "root": "/",
52
+ "ram": "/tmp",
53
+ },
54
+ list_dir = "/opt/opkg-lists",
55
+ options = {
56
+ "overlay_root": "/overlay"
57
+ },
58
+ arch_cfg = {
59
+ "all": 1,
60
+ "noarch": 1,
61
+ "i386_pentium": 10
62
+ }
63
+ )
64
+
65
+ """
66
+
67
+ regex = re.compile(
68
+ r"""
69
+ ^(?:\s*)
70
+ (?:
71
+ (?:arch\s+(?P<arch>\w+)\s+(?P<priority>\d+))|
72
+ (?:dest\s+(?P<dest>\w+)\s+(?P<dest_path>[\w/\-]+))|
73
+ (?:lists_dir\s+(?P<lists_dir>ext)\s+(?P<list_path>[\w/\-]+))|
74
+ (?:option\s+(?P<option>\w+)(?:\s+(?P<value>[^#]+))?)
75
+ )?
76
+ (?:\s*\#.*)?
77
+ $
78
+ """,
79
+ re.X,
80
+ )
81
+
82
+ @staticmethod
83
+ def default():
84
+ return OpkgConfInfo({}, "", {}, {})
85
+
86
+ def command(self) -> str:
87
+ return "cat /etc/opkg.conf"
88
+
89
+ def process(self, output):
90
+ dest, lists_dir, options, arch_cfg = {}, "", {}, {}
91
+ for line in output:
92
+ match = self.regex.match(line)
93
+
94
+ if match is None:
95
+ logger.warning(f"Opkg: could not parse opkg.conf line '{line}'")
96
+ elif match.group("arch") is not None:
97
+ arch_cfg[match.group("arch")] = int(match.group("priority"))
98
+ elif match.group("dest") is not None:
99
+ dest[match.group("dest")] = match.group("dest_path")
100
+ elif match.group("lists_dir") is not None:
101
+ lists_dir = match.group("list_path")
102
+ elif match.group("option") is not None:
103
+ options[match.group("option")] = match.group("value") or True
104
+
105
+ return OpkgConfInfo(dest, lists_dir, options, arch_cfg)
106
+
107
+
108
+ class OpkgFeeds(FactBase):
109
+ """
110
+ Returns a dictionary containing the information for the distribution-provided and
111
+ custom opkg feeds:
112
+
113
+ .. code:: python
114
+
115
+ {
116
+ 'openwrt_base': FeedInfo(url='http://downloads ... /i386_pentium/base', fmt='src/gz', kind='distribution'), # noqa: E501
117
+ 'openwrt_core': FeedInfo(url='http://downloads ... /x86/geode/packages', fmt='src/gz', kind='distribution'), # noqa: E501
118
+ 'openwrt_luci': FeedInfo(url='http://downloads ... /i386_pentium/luci', fmt='src/gz', kind='distribution'),# noqa: E501
119
+ 'openwrt_packages': FeedInfo(url='http://downloads ... /i386_pentium/packages', fmt='src/gz', kind='distribution'),# noqa: E501
120
+ 'openwrt_routing': FeedInfo(url='http://downloads ... /i386_pentium/routing', fmt='src/gz', kind='distribution'),# noqa: E501
121
+ 'openwrt_telephony': FeedInfo(url='http://downloads ... /i386_pentium/telephony', fmt='src/gz', kind='distribution') # noqa: E501
122
+ }
123
+ """
124
+
125
+ regex = re.compile(
126
+ r"^(CUSTOM)|(?:\s*(?P<fmt>[\w/]+)\s+(?P<name>[\w]+)\s+(?P<url>[\w./:]+))?(?:\s*#.*)?$"
127
+ )
128
+ default = dict
129
+
130
+ def command(self) -> str:
131
+ return "cat /etc/opkg/distfeeds.conf; echo CUSTOM; cat /etc/opkg/customfeeds.conf"
132
+
133
+ def process(self, output):
134
+ feeds, kind = {}, "distribution"
135
+ for line in output:
136
+ match = self.regex.match(line)
137
+
138
+ if match is None:
139
+ logger.warning(f"Opkg: could not parse /etc/opkg/*feeds.conf line '{line}'")
140
+ elif match.group(0) == "CUSTOM":
141
+ kind = "custom"
142
+ elif match.group("name") is not None:
143
+ feeds[match.group("name")] = OpkgFeedInfo(
144
+ match.group("url"), match.group("fmt"), kind
145
+ )
146
+
147
+ return feeds
148
+
149
+
150
+ class OpkgInstallableArchitectures(FactBase):
151
+ """
152
+ Returns a dictionary containing the currently installable architectures for this system along
153
+ with their priority:
154
+
155
+ .. code:: python
156
+
157
+ {
158
+ 'all': 1,
159
+ 'i386_pentium': 10,
160
+ 'noarch': 1
161
+ }
162
+ """
163
+
164
+ regex = re.compile(r"^(?:\s*arch\s+(?P<arch>[\w]+)\s+(?P<prio>\d+))?(\s*#.*)?$")
165
+ default = dict
166
+
167
+ def command(self) -> str:
168
+ return "/bin/opkg print-architecture"
169
+
170
+ def process(self, output):
171
+ arch_list = {}
172
+ for line in output:
173
+ match = self.regex.match(line)
174
+
175
+ if match is None:
176
+ logger.warning(f"could not parse arch line '{line}'")
177
+ elif match.group("arch") is not None:
178
+ arch_list[match.group("arch")] = int(match.group("prio"))
179
+
180
+ return arch_list
181
+
182
+
183
+ class OpkgPackages(FactBase):
184
+ """
185
+ Returns a dict of installed opkg packages:
186
+
187
+ .. code:: python
188
+
189
+ {
190
+ 'package_name': ['version'],
191
+ ...
192
+ }
193
+ """
194
+
195
+ regex = r"^([a-zA-Z0-9][\w\-\.]*)\s-\s([\w\-\.]+)"
196
+ default = dict
197
+
198
+ def command(self) -> str:
199
+ return "/bin/opkg list-installed"
200
+
201
+ def process(self, output):
202
+ return parse_packages(self.regex, sorted(output))
203
+
204
+
205
+ class OpkgUpgradeablePackages(FactBase):
206
+ """
207
+ Returns a dict of installed and upgradable opkg packages:
208
+
209
+ .. code:: python
210
+
211
+ {
212
+ 'package_name': (installed='1.2.3', available='1.2.8')
213
+ ...
214
+ }
215
+ """
216
+
217
+ regex = re.compile(r"^([a-zA-Z0-9][\w\-.]*)\s-\s([\w\-.]+)\s-\s([\w\-.]+)")
218
+ default = dict
219
+ use_default_on_error = True
220
+
221
+ def command(self) -> str:
222
+ return "/bin/opkg list-upgradable" # yes, really spelled that way
223
+
224
+ def process(self, output):
225
+ result = {}
226
+ for line in output:
227
+ match = self.regex.match(line)
228
+ if match and len(match.groups()) == 3:
229
+ result[match.group(1)] = OpkgPkgUpgradeInfo(match.group(2), match.group(3))
230
+ else:
231
+ logger.warning(f"Opkg: could not list-upgradable line '{line}'")
232
+
233
+ return result
pyinfra/facts/pipx.py ADDED
@@ -0,0 +1,74 @@
1
+ import re
2
+
3
+ from pyinfra.api import FactBase
4
+
5
+ from .util.packaging import parse_packages
6
+
7
+
8
+ # TODO: move to an utils file
9
+ def parse_environment(output):
10
+ environment_REGEX = r"^(?P<key>[A-Z_]+)=(?P<value>.*)$"
11
+ environment_variables = {}
12
+
13
+ for line in output:
14
+ matches = re.match(environment_REGEX, line)
15
+
16
+ if matches:
17
+ environment_variables[matches.group("key")] = matches.group("value")
18
+
19
+ return environment_variables
20
+
21
+
22
+ PIPX_REGEX = r"^([a-zA-Z0-9_\-\+\.]+)\s+([0-9\.]+[a-z0-9\-]*)$"
23
+
24
+
25
+ class PipxPackages(FactBase):
26
+ """
27
+ Returns a dict of installed pipx packages:
28
+
29
+ .. code:: python
30
+
31
+ {
32
+ "package_name": ["version"],
33
+ }
34
+ """
35
+
36
+ default = dict
37
+
38
+ def requires_command(self) -> str:
39
+ return "pipx"
40
+
41
+ def command(self) -> str:
42
+ return "pipx list --short"
43
+
44
+ def process(self, output):
45
+ return parse_packages(PIPX_REGEX, output)
46
+
47
+
48
+ class PipxEnvironment(FactBase):
49
+ """
50
+ Returns a dict of pipx environment variables:
51
+
52
+ .. code:: python
53
+
54
+ {
55
+ "PIPX_HOME": "/home/doodba/.local/pipx",
56
+ "PIPX_BIN_DIR": "/home/doodba/.local/bin",
57
+ "PIPX_SHARED_LIBS": "/home/doodba/.local/pipx/shared",
58
+ "PIPX_LOCAL_VENVS": "/home/doodba/.local/pipx/venvs",
59
+ "PIPX_LOG_DIR": "/home/doodba/.local/pipx/logs",
60
+ "PIPX_TRASH_DIR": "/home/doodba/.local/pipx/.trash",
61
+ "PIPX_VENV_CACHEDIR": "/home/doodba/.local/pipx/.cache",
62
+ }
63
+ """
64
+
65
+ default = dict
66
+
67
+ def requires_command(self) -> str:
68
+ return "pipx"
69
+
70
+ def command(self) -> str:
71
+ return "pipx environment"
72
+
73
+ def process(self, output):
74
+ return parse_environment(output)
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, Dict, Iterable, List, TypeVar
5
+
6
+ from pyinfra.api import FactBase
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ class PodmanFactBase(FactBase[T]):
12
+ """
13
+ Base for facts using `podman` to retrieve
14
+ """
15
+
16
+ abstract = True
17
+
18
+ def requires_command(self, *args, **kwargs) -> str:
19
+ return "podman"
20
+
21
+
22
+ class PodmanSystemInfo(PodmanFactBase[Dict[str, Any]]):
23
+ """
24
+ Output of 'podman system info'
25
+ """
26
+
27
+ def command(self) -> str:
28
+ return "podman system info --format=json"
29
+
30
+ def process(self, output: Iterable[str]) -> Dict[str, Any]:
31
+ output = json.loads(("").join(output))
32
+ assert isinstance(output, dict)
33
+ return output
34
+
35
+
36
+ class PodmanPs(PodmanFactBase[List[Dict[str, Any]]]):
37
+ """
38
+ Output of 'podman ps'
39
+ """
40
+
41
+ def command(self) -> str:
42
+ return "podman ps --format=json --all"
43
+
44
+ def process(self, output: Iterable[str]) -> List[Dict[str, Any]]:
45
+ output = json.loads(("").join(output))
46
+ assert isinstance(output, list)
47
+ return output # type: ignore
pyinfra/facts/postgres.py CHANGED
@@ -58,6 +58,7 @@ class PostgresFactBase(FactBase):
58
58
  psql_password=None,
59
59
  psql_host=None,
60
60
  psql_port=None,
61
+ psql_database=None,
61
62
  ):
62
63
  return make_execute_psql_command(
63
64
  self.psql_command,
@@ -65,6 +66,7 @@ class PostgresFactBase(FactBase):
65
66
  password=psql_password,
66
67
  host=psql_host,
67
68
  port=psql_port,
69
+ database=psql_database,
68
70
  )
69
71
 
70
72
 
pyinfra/facts/server.py CHANGED
@@ -5,14 +5,15 @@ import re
5
5
  import shutil
6
6
  from datetime import datetime
7
7
  from tempfile import mkdtemp
8
- from typing import Dict, List, Optional, Union
8
+ from typing import Dict, List, Optional
9
9
 
10
10
  from dateutil.parser import parse as parse_date
11
11
  from distro import distro
12
- from typing_extensions import NotRequired, TypedDict
12
+ from typing_extensions import TypedDict
13
13
 
14
14
  from pyinfra.api import FactBase, ShortFactBase
15
15
  from pyinfra.api.util import try_int
16
+ from pyinfra.facts import crontab
16
17
 
17
18
  ISO_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
18
19
 
@@ -31,8 +32,7 @@ class Home(FactBase[Optional[str]]):
31
32
  Returns the home directory of the given user, or the current user if no user is given.
32
33
  """
33
34
 
34
- @staticmethod
35
- def command(user=""):
35
+ def command(self, user=""):
36
36
  return f"echo ~{user}"
37
37
 
38
38
 
@@ -123,8 +123,7 @@ class Command(FactBase[str]):
123
123
  Returns the raw output lines of a given command.
124
124
  """
125
125
 
126
- @staticmethod
127
- def command(command):
126
+ def command(self, command):
128
127
  return command
129
128
 
130
129
 
@@ -133,8 +132,7 @@ class Which(FactBase[Optional[str]]):
133
132
  Returns the path of a given command according to `command -v`, if available.
134
133
  """
135
134
 
136
- @staticmethod
137
- def command(command):
135
+ def command(self, command):
138
136
  return "command -v {0} || true".format(command)
139
137
 
140
138
 
@@ -307,6 +305,36 @@ class LsbRelease(FactBase):
307
305
  return items
308
306
 
309
307
 
308
+ class OsRelease(FactBase):
309
+ """
310
+ Returns a dictionary of release information stored in ``/etc/os-release``.
311
+
312
+ .. code:: python
313
+
314
+ {
315
+ "name": "EndeavourOS",
316
+ "pretty_name": "EndeavourOS",
317
+ "id": "endeavouros",
318
+ "id_like": "arch",
319
+ "build_id": "2024.06.25",
320
+ ...
321
+ }
322
+ """
323
+
324
+ def command(self):
325
+ return "cat /etc/os-release"
326
+
327
+ def process(self, output):
328
+ items = {}
329
+
330
+ for line in output:
331
+ if "=" in line:
332
+ key, value = line.split("=", 1)
333
+ items[key.strip().lower()] = value.strip().strip('"')
334
+
335
+ return items
336
+
337
+
310
338
  class Sysctl(FactBase):
311
339
  """
312
340
  Returns a dictionary of sysctl settings and values.
@@ -377,75 +405,9 @@ class Groups(FactBase[List[str]]):
377
405
  return groups
378
406
 
379
407
 
380
- class CrontabDict(TypedDict):
381
- minute: NotRequired[Union[int, str]]
382
- hour: NotRequired[Union[int, str]]
383
- month: NotRequired[Union[int, str]]
384
- day_of_month: NotRequired[Union[int, str]]
385
- day_of_week: NotRequired[Union[int, str]]
386
- comments: Optional[list[str]]
387
- special_time: NotRequired[str]
388
-
389
-
390
- class Crontab(FactBase[Dict[str, CrontabDict]]):
391
- """
392
- Returns a dictionary of cron command -> execution time.
393
-
394
- .. code:: python
395
-
396
- {
397
- "/path/to/command": {
398
- "minute": "*",
399
- "hour": "*",
400
- "month": "*",
401
- "day_of_month": "*",
402
- "day_of_week": "*",
403
- },
404
- "echo another command": {
405
- "special_time": "@daily",
406
- },
407
- }
408
- """
409
-
410
- default = dict
411
-
412
- def requires_command(self, user=None) -> str:
413
- return "crontab"
414
-
415
- def command(self, user=None):
416
- if user:
417
- return "crontab -l -u {0} || true".format(user)
418
- return "crontab -l || true"
419
-
420
- def process(self, output):
421
- crons: dict[str, CrontabDict] = {}
422
- current_comments = []
423
-
424
- for line in output:
425
- line = line.strip()
426
- if not line or line.startswith("#"):
427
- current_comments.append(line)
428
- continue
429
-
430
- if line.startswith("@"):
431
- special_time, command = line.split(None, 1)
432
- crons[command] = {
433
- "special_time": special_time,
434
- "comments": current_comments,
435
- }
436
- else:
437
- minute, hour, day_of_month, month, day_of_week, command = line.split(None, 5)
438
- crons[command] = {
439
- "minute": try_int(minute),
440
- "hour": try_int(hour),
441
- "month": try_int(month),
442
- "day_of_month": try_int(day_of_month),
443
- "day_of_week": try_int(day_of_week),
444
- "comments": current_comments,
445
- }
446
-
447
- current_comments = []
448
- return crons
408
+ # for compatibility
409
+ CrontabDict = crontab.CrontabDict
410
+ Crontab = crontab.Crontab
449
411
 
450
412
 
451
413
  class Users(FactBase):