pyinfra 3.0.dev0__py2.py3-none-any.whl → 3.0.1__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 (148) hide show
  1. pyinfra/api/__init__.py +3 -0
  2. pyinfra/api/arguments.py +115 -97
  3. pyinfra/api/arguments_typed.py +80 -0
  4. pyinfra/api/command.py +5 -3
  5. pyinfra/api/config.py +139 -39
  6. pyinfra/api/connectors.py +5 -2
  7. pyinfra/api/deploy.py +19 -19
  8. pyinfra/api/exceptions.py +35 -4
  9. pyinfra/api/facts.py +62 -86
  10. pyinfra/api/host.py +102 -15
  11. pyinfra/api/inventory.py +4 -0
  12. pyinfra/api/operation.py +184 -118
  13. pyinfra/api/operations.py +66 -113
  14. pyinfra/api/state.py +53 -34
  15. pyinfra/api/util.py +64 -33
  16. pyinfra/connectors/base.py +65 -20
  17. pyinfra/connectors/chroot.py +15 -13
  18. pyinfra/connectors/docker.py +62 -72
  19. pyinfra/connectors/dockerssh.py +20 -19
  20. pyinfra/connectors/local.py +32 -22
  21. pyinfra/connectors/ssh.py +162 -86
  22. pyinfra/connectors/sshuserclient/client.py +1 -1
  23. pyinfra/connectors/terraform.py +57 -39
  24. pyinfra/connectors/util.py +26 -27
  25. pyinfra/connectors/vagrant.py +27 -26
  26. pyinfra/context.py +1 -0
  27. pyinfra/facts/apk.py +7 -2
  28. pyinfra/facts/apt.py +15 -7
  29. pyinfra/facts/brew.py +28 -13
  30. pyinfra/facts/bsdinit.py +9 -6
  31. pyinfra/facts/cargo.py +6 -3
  32. pyinfra/facts/choco.py +8 -4
  33. pyinfra/facts/deb.py +21 -9
  34. pyinfra/facts/dnf.py +11 -6
  35. pyinfra/facts/docker.py +30 -5
  36. pyinfra/facts/files.py +49 -33
  37. pyinfra/facts/gem.py +7 -2
  38. pyinfra/facts/git.py +14 -21
  39. pyinfra/facts/gpg.py +4 -1
  40. pyinfra/facts/hardware.py +186 -138
  41. pyinfra/facts/launchd.py +7 -2
  42. pyinfra/facts/lxd.py +8 -2
  43. pyinfra/facts/mysql.py +19 -12
  44. pyinfra/facts/npm.py +3 -1
  45. pyinfra/facts/openrc.py +8 -2
  46. pyinfra/facts/pacman.py +13 -5
  47. pyinfra/facts/pip.py +2 -0
  48. pyinfra/facts/pkg.py +5 -1
  49. pyinfra/facts/pkgin.py +7 -2
  50. pyinfra/facts/postgres.py +170 -0
  51. pyinfra/facts/postgresql.py +5 -162
  52. pyinfra/facts/rpm.py +21 -15
  53. pyinfra/facts/runit.py +70 -0
  54. pyinfra/facts/selinux.py +12 -4
  55. pyinfra/facts/server.py +240 -82
  56. pyinfra/facts/snap.py +8 -2
  57. pyinfra/facts/systemd.py +37 -13
  58. pyinfra/facts/sysvinit.py +7 -4
  59. pyinfra/facts/upstart.py +7 -2
  60. pyinfra/facts/util/packaging.py +3 -2
  61. pyinfra/facts/vzctl.py +8 -4
  62. pyinfra/facts/xbps.py +7 -2
  63. pyinfra/facts/yum.py +10 -5
  64. pyinfra/facts/zypper.py +9 -4
  65. pyinfra/operations/apk.py +5 -3
  66. pyinfra/operations/apt.py +28 -25
  67. pyinfra/operations/brew.py +60 -29
  68. pyinfra/operations/bsdinit.py +6 -4
  69. pyinfra/operations/cargo.py +3 -1
  70. pyinfra/operations/choco.py +3 -1
  71. pyinfra/operations/dnf.py +16 -20
  72. pyinfra/operations/docker.py +339 -0
  73. pyinfra/operations/files.py +187 -168
  74. pyinfra/operations/gem.py +3 -1
  75. pyinfra/operations/git.py +23 -25
  76. pyinfra/operations/iptables.py +33 -25
  77. pyinfra/operations/launchd.py +5 -6
  78. pyinfra/operations/lxd.py +7 -4
  79. pyinfra/operations/mysql.py +59 -55
  80. pyinfra/operations/npm.py +8 -1
  81. pyinfra/operations/openrc.py +5 -3
  82. pyinfra/operations/pacman.py +6 -7
  83. pyinfra/operations/pip.py +19 -12
  84. pyinfra/operations/pkg.py +3 -1
  85. pyinfra/operations/pkgin.py +5 -3
  86. pyinfra/operations/postgres.py +349 -0
  87. pyinfra/operations/postgresql.py +18 -335
  88. pyinfra/operations/puppet.py +3 -1
  89. pyinfra/operations/python.py +8 -19
  90. pyinfra/operations/runit.py +182 -0
  91. pyinfra/operations/selinux.py +47 -29
  92. pyinfra/operations/server.py +138 -67
  93. pyinfra/operations/snap.py +3 -1
  94. pyinfra/operations/ssh.py +18 -16
  95. pyinfra/operations/systemd.py +18 -12
  96. pyinfra/operations/sysvinit.py +7 -5
  97. pyinfra/operations/upstart.py +7 -5
  98. pyinfra/operations/util/__init__.py +12 -0
  99. pyinfra/operations/util/docker.py +177 -0
  100. pyinfra/operations/util/files.py +24 -16
  101. pyinfra/operations/util/packaging.py +54 -38
  102. pyinfra/operations/util/service.py +39 -47
  103. pyinfra/operations/vzctl.py +12 -10
  104. pyinfra/operations/xbps.py +5 -3
  105. pyinfra/operations/yum.py +15 -19
  106. pyinfra/operations/zypper.py +9 -10
  107. pyinfra/version.py +5 -2
  108. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.1.dist-info}/METADATA +51 -58
  109. pyinfra-3.0.1.dist-info/RECORD +168 -0
  110. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.1.dist-info}/WHEEL +1 -1
  111. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.1.dist-info}/entry_points.txt +0 -3
  112. pyinfra_cli/__main__.py +4 -3
  113. pyinfra_cli/commands.py +3 -2
  114. pyinfra_cli/exceptions.py +75 -43
  115. pyinfra_cli/inventory.py +52 -31
  116. pyinfra_cli/log.py +10 -2
  117. pyinfra_cli/main.py +88 -65
  118. pyinfra_cli/prints.py +37 -109
  119. pyinfra_cli/util.py +15 -10
  120. tests/test_api/test_api.py +2 -0
  121. tests/test_api/test_api_arguments.py +9 -9
  122. tests/test_api/test_api_deploys.py +15 -19
  123. tests/test_api/test_api_facts.py +4 -5
  124. tests/test_api/test_api_operations.py +18 -20
  125. tests/test_api/test_api_util.py +41 -2
  126. tests/test_cli/test_cli.py +14 -50
  127. tests/test_cli/test_cli_deploy.py +10 -12
  128. tests/test_cli/test_cli_exceptions.py +50 -19
  129. tests/test_cli/test_cli_inventory.py +66 -0
  130. tests/test_cli/util.py +1 -1
  131. tests/test_connectors/test_dockerssh.py +11 -8
  132. tests/test_connectors/test_ssh.py +88 -23
  133. tests/test_connectors/test_sshuserclient.py +1 -1
  134. tests/test_connectors/test_terraform.py +11 -8
  135. tests/test_connectors/test_vagrant.py +6 -6
  136. pyinfra/connectors/ansible.py +0 -175
  137. pyinfra/connectors/mech.py +0 -189
  138. pyinfra/connectors/pyinfrawinrmsession/__init__.py +0 -28
  139. pyinfra/connectors/winrm.py +0 -312
  140. pyinfra/facts/windows.py +0 -366
  141. pyinfra/facts/windows_files.py +0 -90
  142. pyinfra/operations/windows.py +0 -59
  143. pyinfra/operations/windows_files.py +0 -538
  144. pyinfra-3.0.dev0.dist-info/RECORD +0 -170
  145. tests/test_connectors/test_ansible.py +0 -64
  146. tests/test_connectors/test_mech.py +0 -126
  147. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.1.dist-info}/LICENSE.md +0 -0
  148. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.1.dist-info}/top_level.txt +0 -0
pyinfra/api/config.py CHANGED
@@ -1,7 +1,14 @@
1
+ try:
2
+ import importlib_metadata
3
+ except ImportError:
4
+ import importlib.metadata as importlib_metadata # type: ignore[no-redef]
1
5
  from os import path
6
+ from typing import Iterable, Optional, Set
2
7
 
3
- # TODO: move to importlib.resources
4
- from pkg_resources import Requirement, ResolutionError, parse_version, require
8
+ from packaging.markers import Marker
9
+ from packaging.requirements import Requirement
10
+ from packaging.specifiers import SpecifierSet
11
+ from packaging.version import Version
5
12
 
6
13
  from pyinfra import __version__, state
7
14
 
@@ -10,37 +17,40 @@ from .exceptions import PyinfraError
10
17
 
11
18
  class ConfigDefaults:
12
19
  # % of hosts which have to fail for all operations to stop
13
- FAIL_PERCENT = None
20
+ FAIL_PERCENT: Optional[int] = None
14
21
  # Seconds to timeout SSH connections
15
- CONNECT_TIMEOUT = 10
16
- # Temporary directory (on the remote side) to use for caching any files/downloads
17
- TEMP_DIR = "/tmp"
22
+ CONNECT_TIMEOUT: int = 10
23
+ # Temporary directory (on the remote side) to use for caching any files/downloads, the default
24
+ # None value first tries to load the hosts' temporary directory configured via "TMPDIR" env
25
+ # variable, falling back to DEFAULT_TEMP_DIR if not set.
26
+ TEMP_DIR: Optional[str] = None
27
+ DEFAULT_TEMP_DIR: str = "/tmp"
18
28
  # Gevent pool size (defaults to #of target hosts)
19
- PARALLEL = 0
29
+ PARALLEL: int = 0
20
30
  # Specify the required pyinfra version (using PEP 440 setuptools specifier)
21
- REQUIRE_PYINFRA_VERSION = None
31
+ REQUIRE_PYINFRA_VERSION: Optional[str] = None
22
32
  # Specify any required packages (either using PEP 440 or a requirements file)
23
33
  # Note: this can also include pyinfra potentially replacing REQUIRE_PYINFRA_VERSION
24
- REQUIRE_PACKAGES = None
34
+ REQUIRE_PACKAGES: Optional[str] = None
25
35
  # All these can be overridden inside individual operation calls:
26
36
  # Switch to this user (from ssh_user) using su before executing operations
27
- SU_USER = None
28
- USE_SU_LOGIN = False
29
- SU_SHELL = None
30
- PRESERVE_SU_ENV = False
37
+ SU_USER: Optional[str] = None
38
+ USE_SU_LOGIN: bool = False
39
+ SU_SHELL: bool = False
40
+ PRESERVE_SU_ENV: bool = False
31
41
  # Use sudo and optional user
32
- SUDO = False
33
- SUDO_USER = None
34
- PRESERVE_SUDO_ENV = False
35
- USE_SUDO_LOGIN = False
36
- USE_SUDO_PASSWORD = False
42
+ SUDO: bool = False
43
+ SUDO_USER: Optional[str] = None
44
+ PRESERVE_SUDO_ENV: bool = False
45
+ USE_SUDO_LOGIN: bool = False
46
+ SUDO_PASSWORD: Optional[str] = None
37
47
  # Use doas and optional user
38
- DOAS = False
39
- DOAS_USER = None
48
+ DOAS: bool = False
49
+ DOAS_USER: Optional[str] = None
40
50
  # Only show errors but don't count as failure
41
- IGNORE_ERRORS = False
51
+ IGNORE_ERRORS: bool = False
42
52
  # Shell to use to execute commands
43
- SHELL = "sh"
53
+ SHELL: str = "sh"
44
54
 
45
55
 
46
56
  config_defaults = {key: value for key, value in ConfigDefaults.__dict__.items() if key.isupper()}
@@ -49,21 +59,113 @@ config_defaults = {key: value for key, value in ConfigDefaults.__dict__.items()
49
59
  def check_pyinfra_version(version: str):
50
60
  if not version:
51
61
  return
62
+ running_version = Version(__version__)
63
+ required_versions = SpecifierSet(version)
52
64
 
53
- running_version = parse_version(__version__)
54
- required_versions = Requirement.parse(
55
- "pyinfra{0}".format(version),
56
- )
57
-
58
- if running_version not in required_versions: # type: ignore[operator]
65
+ if running_version not in required_versions:
59
66
  raise PyinfraError(
60
- ("pyinfra version requirement not met " "(requires {0}, running {1})").format(
61
- version,
62
- __version__,
63
- ),
67
+ f"pyinfra version requirement not met (requires {version}, running {__version__})"
64
68
  )
65
69
 
66
70
 
71
+ def _check_requirements(requirements: Iterable[str]) -> Set[Requirement]:
72
+ """
73
+ Check whether each of the given requirements and all their dependencies are
74
+ installed.
75
+
76
+ Or more precisely, this checks that each of the given *requirements* is
77
+ satisfied by some installed *distribution package*, and so on recursively
78
+ for each of the dependencies of those distribution packages. The terminology
79
+ here is as follows:
80
+
81
+ * A *distribution package* is essentially a thing that can be installed with
82
+ ``pip``, from an sdist or wheel or Git repo or so on.
83
+ * A *requirement* is the expectation that a distribution package satisfying
84
+ some constraint is installed.
85
+ * A *dependency* is a requirement specified by a distribution package (as
86
+ opposed to the requirements passed in to this function).
87
+
88
+ So what this function does is start from the given requirements, for each
89
+ one check that it is satisfied by some installed distribution package, and
90
+ if so recursively perform the same check on all the dependencies of that
91
+ distribution package. In short, it's traversing the graph of package
92
+ requirements. It stops whenever it finds a requirement that is not satisfied
93
+ (i.e. a required package that is not installed), or when it runs out of
94
+ requirements to check.
95
+
96
+ .. note::
97
+ This is basically equivalent to ``pkg_resources.require()`` except that
98
+ when ``require()`` succeeds, it will return the list of distribution
99
+ packages that satisfy the given requirements and their dependencies, and
100
+ when it fails, it will raise an exception. This function just returns
101
+ the requirements which were not satisfied instead.
102
+
103
+ :param requirements: The requirements to check for in the set of installed
104
+ packages (along with their dependencies).
105
+ :return: The set of requirements that were not satisfied, which will be
106
+ an empty set if all requirements (recursively) were satisfied.
107
+ """
108
+
109
+ # Based on pkg_resources.require() from setuptools. The implementation of
110
+ # hbutils.system.check_reqs() from the hbutils package was also helpful in
111
+ # clarifying what this is supposed to do.
112
+
113
+ reqs_to_check: Set[Requirement] = set(Requirement(r) for r in requirements)
114
+ reqs_satisfied: Set[Requirement] = set()
115
+ reqs_not_satisfied: Set[Requirement] = set()
116
+
117
+ while reqs_to_check:
118
+ req = reqs_to_check.pop()
119
+ assert req not in reqs_satisfied and req not in reqs_not_satisfied
120
+
121
+ # Check for an installed distribution package with the right name and version
122
+ try:
123
+ dist = importlib_metadata.distribution(req.name)
124
+ except importlib_metadata.PackageNotFoundError:
125
+ # No installed package with the right name
126
+ # This would raise a DistributionNotFound error from pkg_resources.require()
127
+ reqs_not_satisfied.add(req)
128
+ continue
129
+
130
+ if dist.version not in req.specifier:
131
+ # There is a distribution with the right name but wrong version
132
+ # This would raise a VersionConflict error from pkg_resources.require()
133
+ reqs_not_satisfied.add(req)
134
+ continue
135
+
136
+ reqs_satisfied.add(req)
137
+
138
+ # If the distribution package has dependencies of its own, go through
139
+ # those dependencies and for each one add it to the set to be checked if
140
+ # - it's unconditional (no marker)
141
+ # - or it's conditional and the condition is satisfied (the marker
142
+ # evaluates to true) in the current environment
143
+ # Markers can check things like the Python version and system version
144
+ # etc., and/or they can check which extras of the distribution package
145
+ # were required. To facilitate checking extras we have to pass the extra
146
+ # in the environment when calling Marker.evaluate().
147
+ if dist.requires:
148
+ if req.extras:
149
+ extras_envs = [{"extra": extra} for extra in req.extras]
150
+
151
+ def evaluate_marker(marker: Marker) -> bool:
152
+ return any(map(marker.evaluate, extras_envs))
153
+
154
+ else:
155
+
156
+ def evaluate_marker(marker: Marker) -> bool:
157
+ return marker.evaluate()
158
+
159
+ for dist_req_str in dist.requires:
160
+ dist_req = Requirement(dist_req_str)
161
+ if dist_req in reqs_satisfied or dist_req in reqs_not_satisfied:
162
+ continue
163
+ if (not dist_req.marker) or evaluate_marker(dist_req.marker):
164
+ reqs_to_check.add(dist_req)
165
+
166
+ return reqs_not_satisfied
167
+
168
+
67
169
  def check_require_packages(requirements_config):
68
170
  if not requirements_config:
69
171
  return
@@ -74,14 +176,12 @@ def check_require_packages(requirements_config):
74
176
  with open(path.join(state.cwd or "", requirements_config), encoding="utf-8") as f:
75
177
  requirements = [line.split("#egg=")[-1] for line in f.read().splitlines()]
76
178
 
77
- try:
78
- require(requirements)
79
- except ResolutionError as e:
179
+ requirements_not_met = _check_requirements(requirements)
180
+ if requirements_not_met:
80
181
  raise PyinfraError(
81
- "Deploy requirements ({0}) not met: {1}".format(
82
- requirements_config,
83
- e,
84
- ),
182
+ "Deploy requirements ({0}) not met: missing {1}".format(
183
+ requirements_config, ", ".join(str(r) for r in requirements_not_met)
184
+ )
85
185
  )
86
186
 
87
187
 
pyinfra/api/connectors.py CHANGED
@@ -1,4 +1,7 @@
1
- import pkg_resources
1
+ try:
2
+ from importlib_metadata import entry_points
3
+ except ImportError:
4
+ from importlib.metadata import entry_points # type: ignore[assignment]
2
5
 
3
6
 
4
7
  def _load_connector(entrypoint):
@@ -8,7 +11,7 @@ def _load_connector(entrypoint):
8
11
  def get_all_connectors():
9
12
  return {
10
13
  entrypoint.name: _load_connector(entrypoint)
11
- for entrypoint in pkg_resources.iter_entry_points("pyinfra.connectors")
14
+ for entrypoint in entry_points(group="pyinfra.connectors")
12
15
  }
13
16
 
14
17
 
pyinfra/api/deploy.py CHANGED
@@ -5,13 +5,16 @@ creation (eg pyinfra-openstack).
5
5
  """
6
6
 
7
7
  from functools import wraps
8
- from typing import TYPE_CHECKING, Any, Callable, Union
8
+ from typing import TYPE_CHECKING, Any, Callable, Optional, cast
9
+
10
+ from typing_extensions import ParamSpec
9
11
 
10
12
  import pyinfra
11
13
  from pyinfra import context
12
14
  from pyinfra.context import ctx_host, ctx_state
13
15
 
14
16
  from .arguments import pop_global_arguments
17
+ from .arguments_typed import PyinfraOperation
15
18
  from .exceptions import PyinfraError
16
19
  from .host import Host
17
20
  from .util import get_call_location
@@ -48,40 +51,37 @@ def add_deploy(state: "State", deploy_func: Callable[..., Any], *args, **kwargs)
48
51
  deploy_func(*args, **kwargs)
49
52
 
50
53
 
51
- def deploy(func_or_name: Union[Callable[..., Any], str], data_defaults=None, _call_location=None):
54
+ P = ParamSpec("P")
55
+
56
+
57
+ def deploy(name: Optional[str] = None, data_defaults=None):
52
58
  """
53
59
  Decorator that takes a deploy function (normally from a pyinfra_* package)
54
60
  and wraps any operations called inside with any deploy-wide kwargs/data.
55
61
  """
56
62
 
57
- # If not decorating, return function with config attached
58
- if isinstance(func_or_name, str):
59
- name = func_or_name
60
-
61
- def decorator(f):
62
- f.deploy_name = name
63
- if data_defaults:
64
- f.deploy_data = data_defaults
65
- return deploy(f, _call_location=get_call_location())
63
+ def decorator(func: Callable[P, Any]) -> PyinfraOperation[P]:
64
+ func.deploy_name = name or func.__name__ # type: ignore[attr-defined]
65
+ if data_defaults:
66
+ func.deploy_data = data_defaults # type: ignore[attr-defined]
67
+ return _wrap_deploy(func)
66
68
 
67
- return decorator
69
+ return decorator
68
70
 
69
- # Actually decorate!
70
- func = func_or_name
71
71
 
72
+ def _wrap_deploy(func: Callable[P, Any]) -> PyinfraOperation[P]:
72
73
  @wraps(func)
73
- def decorated_func(*args, **kwargs):
74
+ def decorated_func(*args: P.args, **kwargs: P.kwargs) -> Any:
74
75
  deploy_kwargs, _ = pop_global_arguments(kwargs)
75
76
 
76
- # Name the deploy
77
- deploy_name = getattr(func, "deploy_name", func.__name__)
78
77
  deploy_data = getattr(func, "deploy_data", None)
79
78
 
80
79
  with context.host.deploy(
81
- name=deploy_name,
80
+ name=func.deploy_name, # type: ignore[attr-defined]
82
81
  kwargs=deploy_kwargs,
83
82
  data=deploy_data,
84
83
  ):
85
84
  return func(*args, **kwargs)
86
85
 
87
- return decorated_func
86
+ decorated_func._inner = func # type: ignore[attr-defined]
87
+ return cast(PyinfraOperation[P], decorated_func)
pyinfra/api/exceptions.py CHANGED
@@ -10,6 +10,25 @@ class ConnectError(PyinfraError):
10
10
  """
11
11
 
12
12
 
13
+ class FactError(PyinfraError):
14
+ """
15
+ Exception raised during fact gathering staging if a fact is unable to
16
+ generate output/change state.
17
+ """
18
+
19
+
20
+ class FactTypeError(FactError, TypeError):
21
+ """
22
+ Exception raised when a fact is passed invalid argument types.
23
+ """
24
+
25
+
26
+ class FactValueError(FactError, ValueError):
27
+ """
28
+ Exception raised when a fact is passed invalid argument values.
29
+ """
30
+
31
+
13
32
  class OperationError(PyinfraError):
14
33
  """
15
34
  Exception raised during fact gathering staging if an operation is unable to
@@ -41,19 +60,31 @@ class InventoryError(PyinfraError):
41
60
  """
42
61
 
43
62
 
44
- class NoConnectorError(PyinfraError, TypeError):
63
+ class NoConnectorError(PyinfraError, ValueError):
45
64
  """
46
65
  Raised when a requested connector is missing.
47
66
  """
48
67
 
49
68
 
50
- class NoHostError(PyinfraError, TypeError):
69
+ class NoHostError(PyinfraError, KeyError):
51
70
  """
52
71
  Raised when an inventory is missing a host.
53
72
  """
54
73
 
55
74
 
56
- class NoGroupError(PyinfraError, TypeError):
75
+ class NoGroupError(PyinfraError, KeyError):
76
+ """
77
+ Raised when an inventory is missing a group.
78
+ """
79
+
80
+
81
+ class ConnectorDataTypeError(PyinfraError, TypeError):
82
+ """
83
+ Raised when host connector data has invalid types.
84
+ """
85
+
86
+
87
+ class ArgumentTypeError(PyinfraError, TypeError):
57
88
  """
58
- Raise when an inventory is missing a group.
89
+ Raised when global arguments are passed with invalid types.
59
90
  """
pyinfra/api/facts.py CHANGED
@@ -10,10 +10,11 @@ other host B while I operate on this host A).
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import inspect
13
14
  import re
14
15
  from inspect import getcallargs
15
16
  from socket import error as socket_error, timeout as timeout_error
16
- from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional, Union
17
+ from typing import TYPE_CHECKING, Any, Callable, Generic, Iterable, Optional, Type, TypeVar, cast
17
18
 
18
19
  import click
19
20
  import gevent
@@ -21,68 +22,87 @@ from paramiko import SSHException
21
22
 
22
23
  from pyinfra import logger
23
24
  from pyinfra.api import StringCommand
24
- from pyinfra.api.arguments import pop_global_arguments
25
+ from pyinfra.api.arguments import all_global_arguments, pop_global_arguments
25
26
  from pyinfra.api.util import (
26
27
  get_kwargs_str,
27
28
  log_error_or_warning,
28
29
  log_host_command_error,
29
- make_hash,
30
30
  print_host_combined_output,
31
31
  )
32
32
  from pyinfra.connectors.util import CommandOutput
33
33
  from pyinfra.context import ctx_host, ctx_state
34
34
  from pyinfra.progress import progress_spinner
35
35
 
36
- from .arguments import get_connector_argument_keys
36
+ from .arguments import CONNECTOR_ARGUMENT_KEYS
37
37
 
38
38
  if TYPE_CHECKING:
39
39
  from pyinfra.api.host import Host
40
40
  from pyinfra.api.state import State
41
41
 
42
- SUDO_REGEX = r"^sudo: unknown user:"
42
+ SUDO_REGEX = r"^sudo: unknown user"
43
43
  SU_REGEXES = (
44
44
  r"^su: user .+ does not exist",
45
45
  r"^su: unknown login",
46
46
  )
47
47
 
48
48
 
49
- class FactNameMeta(type):
50
- def __init__(cls, name: str, bases, attrs, **kwargs):
51
- super().__init__(name, bases, attrs, **kwargs)
52
- module_name = cls.__module__.replace("pyinfra.facts.", "")
53
- cls.name = f"{module_name}.{cls.__name__}"
49
+ T = TypeVar("T")
54
50
 
55
51
 
56
- class FactBase(metaclass=FactNameMeta):
52
+ class FactBase(Generic[T]):
57
53
  name: str
58
54
 
59
55
  abstract: bool = True
60
56
 
61
- shell_executable: Optional[str] = None
57
+ shell_executable: str | None = None
58
+
59
+ command: Callable[..., str | StringCommand]
62
60
 
63
- requires_command: Optional[str] = None
61
+ def requires_command(self, *args, **kwargs) -> str | None:
62
+ return None
64
63
 
65
- command: Union[str, Callable]
64
+ def __init_subclass__(cls) -> None:
65
+ super().__init_subclass__()
66
+ module_name = cls.__module__.replace("pyinfra.facts.", "")
67
+ cls.name = f"{module_name}.{cls.__name__}"
68
+
69
+ # Check that fact's `command` method does not inadvertently take a global
70
+ # argument, most commonly `name`.
71
+ if hasattr(cls, "command") and callable(cls.command):
72
+ command_args = set(inspect.signature(cls.command).parameters.keys())
73
+ global_args = set([name for name, _ in all_global_arguments()])
74
+ command_global_args = command_args & global_args
75
+
76
+ if len(command_global_args) > 0:
77
+ names = ", ".join(command_global_args)
78
+ raise TypeError(f"{cls.name}'s arguments {names} are reserved for global arguments")
66
79
 
67
80
  @staticmethod
68
- def default():
81
+ def default() -> T:
69
82
  """
70
83
  Set the default attribute to be a type (eg list/dict).
71
84
  """
72
85
 
73
- @staticmethod
74
- def process(output):
75
- return "\n".join(output)
86
+ return cast(T, None)
87
+
88
+ def process(self, output: Iterable[str]) -> T:
89
+ # NOTE: TypeVar does not support a default, so we have to cast this str -> T
90
+ return cast(T, "\n".join(output))
76
91
 
77
92
  def process_pipeline(self, args, output):
78
93
  return {arg: self.process([output[i]]) for i, arg in enumerate(args)}
79
94
 
80
95
 
81
- class ShortFactBase(metaclass=FactNameMeta):
82
- fact: type[FactBase]
96
+ class ShortFactBase(Generic[T]):
97
+ name: str
98
+ fact: Type[FactBase]
83
99
 
84
- @staticmethod
85
- def process_data(data):
100
+ def __init_subclass__(cls) -> None:
101
+ super().__init_subclass__()
102
+ module_name = cls.__module__.replace("pyinfra.facts.", "")
103
+ cls.name = f"{module_name}.{cls.__name__}"
104
+
105
+ def process_data(self, data):
86
106
  return data
87
107
 
88
108
 
@@ -98,64 +118,29 @@ def _make_command(command_attribute, host_args):
98
118
  return command_attribute
99
119
 
100
120
 
101
- def _get_executor_kwargs(
102
- state: "State",
103
- host: "Host",
104
- override_kwargs: Optional[dict[str, Any]] = None,
105
- override_kwarg_keys: Optional[list[str]] = None,
106
- ):
107
- if override_kwargs is None:
108
- override_kwargs = {}
109
- if override_kwarg_keys is None:
110
- override_kwarg_keys = []
111
-
112
- # Use the current operation global kwargs, or generate defaults
113
- global_kwargs = host.current_op_global_arguments
114
- if not global_kwargs:
115
- global_kwargs, _ = pop_global_arguments({}, state, host)
116
-
117
- # Apply any current op kwargs that *weren't* found in the overrides
118
- override_kwargs.update(
119
- {key: value for key, value in global_kwargs.items() if key not in override_kwarg_keys},
120
- )
121
-
122
- return {
123
- key: value for key, value in override_kwargs.items() if key in get_connector_argument_keys()
124
- }
125
-
126
-
127
121
  def _handle_fact_kwargs(state, host, cls, args, kwargs):
128
122
  args = args or []
129
123
  kwargs = kwargs or {}
130
124
 
131
- # TODO: this is here to avoid popping stuff accidentally, this is horrible! Change the
132
- # pop function to return the clean kwargs to avoid the indirect mutation.
133
- kwargs = kwargs.copy()
125
+ # Start with a (shallow) copy of current operation kwargs if any
126
+ ctx_kwargs = (host.current_op_global_arguments or {}).copy()
127
+ # Update with the input kwargs (overrides)
128
+ ctx_kwargs.update(kwargs)
134
129
 
135
- # Get the defaults *and* overrides by popping from kwargs, executor kwargs passed
136
- # into get_fact override everything else (applied below).
137
- override_kwargs, override_kwarg_keys = pop_global_arguments(
138
- kwargs,
130
+ # Pop executor kwargs, pass remaining
131
+ global_kwargs, _ = pop_global_arguments(
132
+ ctx_kwargs,
139
133
  state=state,
140
134
  host=host,
141
- keys_to_check=get_connector_argument_keys(),
142
- )
143
-
144
- executor_kwargs = _get_executor_kwargs(
145
- state,
146
- host,
147
- override_kwargs=override_kwargs, # type: ignore[arg-type]
148
- override_kwarg_keys=override_kwarg_keys,
149
135
  )
150
136
 
151
- fact_kwargs = {}
137
+ fact_kwargs = {key: value for key, value in kwargs.items() if key not in global_kwargs}
152
138
 
153
- if args or kwargs:
154
- assert not isinstance(cls.command, str)
139
+ if args or fact_kwargs:
155
140
  # Merges args & kwargs into a single kwargs dictionary
156
- fact_kwargs = getcallargs(cls().command, *args, **kwargs)
141
+ fact_kwargs = getcallargs(cls().command, *args, **fact_kwargs)
157
142
 
158
- return fact_kwargs, executor_kwargs
143
+ return fact_kwargs, global_kwargs
159
144
 
160
145
 
161
146
  def get_facts(state: "State", *args, **kwargs):
@@ -223,7 +208,7 @@ def _get_fact(
223
208
  fact = cls()
224
209
  name = fact.name
225
210
 
226
- fact_kwargs, executor_kwargs = _handle_fact_kwargs(state, host, cls, args, kwargs)
211
+ fact_kwargs, global_kwargs = _handle_fact_kwargs(state, host, cls, args, kwargs)
227
212
 
228
213
  kwargs_str = get_kwargs_str(fact_kwargs)
229
214
  logger.debug(
@@ -239,15 +224,9 @@ def _get_fact(
239
224
  raise_exceptions=True,
240
225
  )
241
226
 
242
- ignore_errors = (
243
- host.current_op_global_arguments["_ignore_errors"]
244
- if host.in_op and host.current_op_global_arguments
245
- else state.config.IGNORE_ERRORS
246
- )
247
-
248
227
  # Facts can override the shell (winrm powershell vs cmd support)
249
228
  if fact.shell_executable:
250
- executor_kwargs["_shell_executable"] = fact.shell_executable
229
+ global_kwargs["_shell_executable"] = fact.shell_executable
251
230
 
252
231
  command = _make_command(fact.command, fact_kwargs)
253
232
  requires_command = _make_command(fact.requires_command, fact_kwargs)
@@ -266,6 +245,10 @@ def _get_fact(
266
245
  status = False
267
246
  output = CommandOutput([])
268
247
 
248
+ executor_kwargs = {
249
+ key: value for key, value in global_kwargs.items() if key in CONNECTOR_ARGUMENT_KEYS
250
+ }
251
+
269
252
  try:
270
253
  status, output = host.run_shell_command(
271
254
  command,
@@ -277,7 +260,7 @@ def _get_fact(
277
260
  log_host_command_error(
278
261
  host,
279
262
  e,
280
- timeout=executor_kwargs["_timeout"],
263
+ timeout=global_kwargs.get("_timeout"),
281
264
  )
282
265
 
283
266
  stdout_lines, stderr_lines = output.stdout_lines, output.stderr_lines
@@ -316,24 +299,17 @@ def _get_fact(
316
299
 
317
300
  log_error_or_warning(
318
301
  host,
319
- ignore_errors,
302
+ global_kwargs["_ignore_errors"],
320
303
  description=("could not load fact: {0} {1}").format(name, get_kwargs_str(fact_kwargs)),
321
304
  )
322
305
 
323
306
  # Check we've not failed
324
- if not status and not ignore_errors and apply_failed_hosts:
307
+ if apply_failed_hosts and not status and not global_kwargs["_ignore_errors"]:
325
308
  state.fail_hosts({host})
326
309
 
327
310
  return data
328
311
 
329
312
 
330
- def _get_fact_hash(state: "State", host: "Host", cls, args, kwargs):
331
- if issubclass(cls, ShortFactBase):
332
- cls = cls.fact
333
- fact_kwargs, executor_kwargs = _handle_fact_kwargs(state, host, cls, args, kwargs)
334
- return make_hash((cls, fact_kwargs, executor_kwargs))
335
-
336
-
337
313
  def get_host_fact(
338
314
  state: "State",
339
315
  host: "Host",