pyinfra 3.0b0__py2.py3-none-any.whl → 3.0b2__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 (115) hide show
  1. pyinfra/api/__init__.py +3 -0
  2. pyinfra/api/arguments.py +12 -5
  3. pyinfra/api/arguments_typed.py +19 -6
  4. pyinfra/api/command.py +5 -3
  5. pyinfra/api/config.py +115 -13
  6. pyinfra/api/connectors.py +5 -2
  7. pyinfra/api/exceptions.py +19 -0
  8. pyinfra/api/facts.py +34 -33
  9. pyinfra/api/host.py +51 -12
  10. pyinfra/api/inventory.py +4 -0
  11. pyinfra/api/operation.py +88 -42
  12. pyinfra/api/operations.py +10 -11
  13. pyinfra/api/state.py +11 -2
  14. pyinfra/api/util.py +24 -16
  15. pyinfra/connectors/base.py +4 -7
  16. pyinfra/connectors/chroot.py +5 -6
  17. pyinfra/connectors/docker.py +13 -19
  18. pyinfra/connectors/dockerssh.py +5 -4
  19. pyinfra/connectors/local.py +7 -7
  20. pyinfra/connectors/ssh.py +46 -25
  21. pyinfra/connectors/terraform.py +9 -6
  22. pyinfra/connectors/util.py +7 -8
  23. pyinfra/connectors/vagrant.py +11 -10
  24. pyinfra/context.py +1 -0
  25. pyinfra/facts/apk.py +2 -0
  26. pyinfra/facts/apt.py +2 -0
  27. pyinfra/facts/brew.py +2 -0
  28. pyinfra/facts/bsdinit.py +2 -0
  29. pyinfra/facts/cargo.py +2 -0
  30. pyinfra/facts/choco.py +3 -1
  31. pyinfra/facts/deb.py +9 -4
  32. pyinfra/facts/dnf.py +2 -0
  33. pyinfra/facts/docker.py +2 -0
  34. pyinfra/facts/files.py +2 -0
  35. pyinfra/facts/gem.py +2 -0
  36. pyinfra/facts/gpg.py +2 -0
  37. pyinfra/facts/hardware.py +30 -22
  38. pyinfra/facts/launchd.py +2 -0
  39. pyinfra/facts/lxd.py +2 -0
  40. pyinfra/facts/mysql.py +12 -6
  41. pyinfra/facts/npm.py +1 -0
  42. pyinfra/facts/openrc.py +2 -0
  43. pyinfra/facts/pacman.py +6 -2
  44. pyinfra/facts/pip.py +2 -0
  45. pyinfra/facts/pkg.py +2 -0
  46. pyinfra/facts/pkgin.py +2 -0
  47. pyinfra/facts/postgres.py +168 -0
  48. pyinfra/facts/postgresql.py +5 -162
  49. pyinfra/facts/rpm.py +12 -9
  50. pyinfra/facts/server.py +10 -13
  51. pyinfra/facts/snap.py +2 -0
  52. pyinfra/facts/systemd.py +28 -10
  53. pyinfra/facts/upstart.py +2 -0
  54. pyinfra/facts/util/packaging.py +3 -2
  55. pyinfra/facts/vzctl.py +2 -0
  56. pyinfra/facts/xbps.py +2 -0
  57. pyinfra/facts/yum.py +2 -0
  58. pyinfra/facts/zypper.py +2 -0
  59. pyinfra/operations/apk.py +3 -1
  60. pyinfra/operations/apt.py +16 -18
  61. pyinfra/operations/brew.py +10 -8
  62. pyinfra/operations/bsdinit.py +5 -3
  63. pyinfra/operations/cargo.py +3 -1
  64. pyinfra/operations/choco.py +3 -1
  65. pyinfra/operations/dnf.py +15 -19
  66. pyinfra/operations/files.py +86 -69
  67. pyinfra/operations/gem.py +3 -1
  68. pyinfra/operations/git.py +18 -16
  69. pyinfra/operations/iptables.py +33 -25
  70. pyinfra/operations/launchd.py +5 -6
  71. pyinfra/operations/lxd.py +7 -4
  72. pyinfra/operations/mysql.py +57 -53
  73. pyinfra/operations/npm.py +8 -1
  74. pyinfra/operations/openrc.py +5 -3
  75. pyinfra/operations/pacman.py +4 -5
  76. pyinfra/operations/pip.py +16 -9
  77. pyinfra/operations/pkg.py +3 -1
  78. pyinfra/operations/pkgin.py +3 -1
  79. pyinfra/operations/postgres.py +349 -0
  80. pyinfra/operations/postgresql.py +18 -335
  81. pyinfra/operations/puppet.py +3 -1
  82. pyinfra/operations/python.py +7 -3
  83. pyinfra/operations/selinux.py +42 -16
  84. pyinfra/operations/server.py +48 -43
  85. pyinfra/operations/snap.py +3 -1
  86. pyinfra/operations/ssh.py +12 -10
  87. pyinfra/operations/systemd.py +13 -9
  88. pyinfra/operations/sysvinit.py +6 -4
  89. pyinfra/operations/upstart.py +5 -3
  90. pyinfra/operations/util/files.py +24 -16
  91. pyinfra/operations/util/packaging.py +53 -37
  92. pyinfra/operations/util/service.py +18 -13
  93. pyinfra/operations/vzctl.py +12 -10
  94. pyinfra/operations/xbps.py +3 -1
  95. pyinfra/operations/yum.py +14 -18
  96. pyinfra/operations/zypper.py +8 -9
  97. pyinfra/version.py +5 -2
  98. {pyinfra-3.0b0.dist-info → pyinfra-3.0b2.dist-info}/METADATA +31 -29
  99. pyinfra-3.0b2.dist-info/RECORD +163 -0
  100. {pyinfra-3.0b0.dist-info → pyinfra-3.0b2.dist-info}/WHEEL +1 -1
  101. pyinfra_cli/commands.py +3 -2
  102. pyinfra_cli/inventory.py +38 -19
  103. pyinfra_cli/main.py +2 -0
  104. pyinfra_cli/prints.py +27 -105
  105. pyinfra_cli/util.py +3 -1
  106. tests/test_api/test_api_deploys.py +5 -5
  107. tests/test_api/test_api_operations.py +5 -5
  108. tests/test_connectors/test_ssh.py +105 -0
  109. tests/test_connectors/test_terraform.py +11 -8
  110. tests/test_connectors/test_vagrant.py +6 -6
  111. pyinfra-3.0b0.dist-info/RECORD +0 -162
  112. pyinfra_cli/inventory_dsl.py +0 -23
  113. {pyinfra-3.0b0.dist-info → pyinfra-3.0b2.dist-info}/LICENSE.md +0 -0
  114. {pyinfra-3.0b0.dist-info → pyinfra-3.0b2.dist-info}/entry_points.txt +0 -0
  115. {pyinfra-3.0b0.dist-info → pyinfra-3.0b2.dist-info}/top_level.txt +0 -0
pyinfra/api/__init__.py CHANGED
@@ -11,6 +11,9 @@ from .config import Config # noqa: F401 # pragma: no cover
11
11
  from .deploy import deploy # noqa: F401 # pragma: no cover
12
12
  from .exceptions import DeployError # noqa: F401 # pragma: no cover
13
13
  from .exceptions import ( # noqa: F401
14
+ FactError,
15
+ FactTypeError,
16
+ FactValueError,
14
17
  InventoryError,
15
18
  OperationError,
16
19
  OperationTypeError,
pyinfra/api/arguments.py CHANGED
@@ -6,8 +6,10 @@ from typing import (
6
6
  Callable,
7
7
  Generic,
8
8
  Iterable,
9
+ List,
9
10
  Mapping,
10
11
  Optional,
12
+ Type,
11
13
  TypeVar,
12
14
  Union,
13
15
  cast,
@@ -169,7 +171,7 @@ class MetaArguments(TypedDict):
169
171
  name: str
170
172
  _ignore_errors: bool
171
173
  _continue_on_error: bool
172
- _if: Callable[[], bool]
174
+ _if: Union[List[Callable[[], bool]], Callable[[], bool], None]
173
175
 
174
176
 
175
177
  meta_argument_meta: dict[str, ArgumentMeta] = {
@@ -190,8 +192,8 @@ meta_argument_meta: dict[str, ArgumentMeta] = {
190
192
  default=lambda _: False,
191
193
  ),
192
194
  "_if": ArgumentMeta(
193
- "Only run this operation if this function returns True",
194
- default=lambda _: None,
195
+ "Only run this operation if these functions return True",
196
+ default=lambda _: [],
195
197
  ),
196
198
  }
197
199
 
@@ -227,6 +229,11 @@ class AllArguments(ConnectorArguments, MetaArguments, ExecutionArguments):
227
229
  pass
228
230
 
229
231
 
232
+ def all_global_arguments() -> List[tuple[str, Type]]:
233
+ """Return all global arguments and their types."""
234
+ return list(get_type_hints(AllArguments).items())
235
+
236
+
230
237
  all_argument_meta: dict[str, ArgumentMeta] = {
231
238
  **auth_argument_meta,
232
239
  **shell_argument_meta,
@@ -299,12 +306,12 @@ def pop_global_arguments(
299
306
  if context.ctx_config.isset():
300
307
  config = context.config
301
308
 
302
- meta_kwargs = host.current_deploy_kwargs or {}
309
+ meta_kwargs: dict[str, Any] = host.current_deploy_kwargs or {} # type: ignore[assignment]
303
310
 
304
311
  arguments: dict[str, Any] = {}
305
312
  found_keys: list[str] = []
306
313
 
307
- for key, type_ in get_type_hints(AllArguments).items():
314
+ for key, type_ in all_global_arguments():
308
315
  if keys_to_check and key not in keys_to_check:
309
316
  continue
310
317
 
@@ -1,6 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING, Callable, Generator, Generic, Iterable, Mapping, Optional, Union
3
+ from typing import (
4
+ TYPE_CHECKING,
5
+ Callable,
6
+ Generator,
7
+ Generic,
8
+ Iterable,
9
+ List,
10
+ Mapping,
11
+ Optional,
12
+ Union,
13
+ )
4
14
 
5
15
  from typing_extensions import ParamSpec, Protocol
6
16
 
@@ -22,6 +32,11 @@ class PyinfraOperation(Generic[P], Protocol):
22
32
  def __call__(
23
33
  self,
24
34
  #
35
+ # op args
36
+ # needs to be first
37
+ #
38
+ *args: P.args,
39
+ #
25
40
  # ConnectorArguments
26
41
  #
27
42
  # Auth
@@ -51,7 +66,7 @@ class PyinfraOperation(Generic[P], Protocol):
51
66
  name: Optional[str] = None,
52
67
  _ignore_errors: bool = False,
53
68
  _continue_on_error: bool = False,
54
- _if: Optional[Callable[[], bool]] = None,
69
+ _if: Union[List[Callable[[], bool]], Callable[[], bool], None] = None,
55
70
  #
56
71
  # ExecutionArguments
57
72
  #
@@ -59,9 +74,7 @@ class PyinfraOperation(Generic[P], Protocol):
59
74
  _run_once: bool = False,
60
75
  _serial: bool = False,
61
76
  #
62
- # The op itself
77
+ # op kwargs
63
78
  #
64
- *args: P.args,
65
79
  **kwargs: P.kwargs,
66
- ) -> "OperationMeta":
67
- ...
80
+ ) -> "OperationMeta": ...
pyinfra/api/command.py CHANGED
@@ -1,7 +1,9 @@
1
+ from __future__ import annotations
2
+
1
3
  import shlex
2
4
  from inspect import getfullargspec
3
5
  from string import Formatter
4
- from typing import TYPE_CHECKING, Callable, Union
6
+ from typing import IO, TYPE_CHECKING, Callable, Union
5
7
 
6
8
  import gevent
7
9
  from typing_extensions import Unpack
@@ -143,7 +145,7 @@ class StringCommand(PyinfraCommand):
143
145
  class FileUploadCommand(PyinfraCommand):
144
146
  def __init__(
145
147
  self,
146
- src: str,
148
+ src: str | IO,
147
149
  dest: str,
148
150
  remote_temp_filename=None,
149
151
  **kwargs: Unpack[ConnectorArguments],
@@ -173,7 +175,7 @@ class FileDownloadCommand(PyinfraCommand):
173
175
  def __init__(
174
176
  self,
175
177
  src: str,
176
- dest: str,
178
+ dest: str | IO,
177
179
  remote_temp_filename=None,
178
180
  **kwargs: Unpack[ConnectorArguments],
179
181
  ):
pyinfra/api/config.py CHANGED
@@ -1,8 +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
2
- from typing import Optional
6
+ from typing import Iterable, Optional, Set
3
7
 
4
- # TODO: move to importlib.resources
5
- 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
6
12
 
7
13
  from pyinfra import __version__, state
8
14
 
@@ -53,15 +59,113 @@ config_defaults = {key: value for key, value in ConfigDefaults.__dict__.items()
53
59
  def check_pyinfra_version(version: str):
54
60
  if not version:
55
61
  return
56
- running_version = parse_version(__version__)
57
- required_versions = Requirement.parse("pyinfra{0}".format(version))
62
+ running_version = Version(__version__)
63
+ required_versions = SpecifierSet(version)
58
64
 
59
- if running_version not in required_versions: # type: ignore[operator]
65
+ if running_version not in required_versions:
60
66
  raise PyinfraError(
61
67
  f"pyinfra version requirement not met (requires {version}, running {__version__})"
62
68
  )
63
69
 
64
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
+
65
169
  def check_require_packages(requirements_config):
66
170
  if not requirements_config:
67
171
  return
@@ -72,14 +176,12 @@ def check_require_packages(requirements_config):
72
176
  with open(path.join(state.cwd or "", requirements_config), encoding="utf-8") as f:
73
177
  requirements = [line.split("#egg=")[-1] for line in f.read().splitlines()]
74
178
 
75
- try:
76
- require(requirements)
77
- except ResolutionError as e:
179
+ requirements_not_met = _check_requirements(requirements)
180
+ if requirements_not_met:
78
181
  raise PyinfraError(
79
- "Deploy requirements ({0}) not met: {1}".format(
80
- requirements_config,
81
- e,
82
- ),
182
+ "Deploy requirements ({0}) not met: missing {1}".format(
183
+ requirements_config, ", ".join(str(r) for r in requirements_not_met)
184
+ )
83
185
  )
84
186
 
85
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/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
pyinfra/api/facts.py CHANGED
@@ -10,6 +10,7 @@ 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
@@ -32,7 +33,7 @@ from paramiko import SSHException
32
33
 
33
34
  from pyinfra import logger
34
35
  from pyinfra.api import StringCommand
35
- from pyinfra.api.arguments import pop_global_arguments
36
+ from pyinfra.api.arguments import all_global_arguments, pop_global_arguments
36
37
  from pyinfra.api.util import (
37
38
  get_kwargs_str,
38
39
  log_error_or_warning,
@@ -50,7 +51,7 @@ if TYPE_CHECKING:
50
51
  from pyinfra.api.host import Host
51
52
  from pyinfra.api.state import State
52
53
 
53
- SUDO_REGEX = r"^sudo: unknown user:"
54
+ SUDO_REGEX = r"^sudo: unknown user"
54
55
  SU_REGEXES = (
55
56
  r"^su: user .+ does not exist",
56
57
  r"^su: unknown login",
@@ -76,6 +77,17 @@ class FactBase(Generic[T]):
76
77
  module_name = cls.__module__.replace("pyinfra.facts.", "")
77
78
  cls.name = f"{module_name}.{cls.__name__}"
78
79
 
80
+ # Check that fact's `command` method does not inadvertently take a global
81
+ # argument, most commonly `name`.
82
+ if hasattr(cls, "command") and callable(cls.command):
83
+ command_args = set(inspect.signature(cls.command).parameters.keys())
84
+ global_args = set([name for name, _ in all_global_arguments()])
85
+ command_global_args = command_args & global_args
86
+
87
+ if len(command_global_args) > 0:
88
+ names = ", ".join(command_global_args)
89
+ raise TypeError(f"{cls.name}'s arguments {names} are reserved for global arguments")
90
+
79
91
  @staticmethod
80
92
  def default() -> T:
81
93
  """
@@ -146,34 +158,25 @@ def _handle_fact_kwargs(state, host, cls, args, kwargs):
146
158
  args = args or []
147
159
  kwargs = kwargs or {}
148
160
 
149
- # TODO: this is here to avoid popping stuff accidentally, this is horrible! Change the
150
- # pop function to return the clean kwargs to avoid the indirect mutation.
151
- kwargs = kwargs.copy()
161
+ # Start with a (shallow) copy of current operation kwargs if any
162
+ ctx_kwargs = (host.current_op_global_arguments or {}).copy()
163
+ # Update with the input kwargs (overrides)
164
+ ctx_kwargs.update(kwargs)
152
165
 
153
- # Get the defaults *and* overrides by popping from kwargs, executor kwargs passed
154
- # into get_fact override everything else (applied below).
155
- override_kwargs, override_kwarg_keys = pop_global_arguments(
156
- kwargs,
166
+ # Pop executor kwargs, pass remaining
167
+ global_kwargs, _ = pop_global_arguments(
168
+ ctx_kwargs,
157
169
  state=state,
158
170
  host=host,
159
- keys_to_check=CONNECTOR_ARGUMENT_KEYS,
160
- )
161
-
162
- executor_kwargs = _get_executor_kwargs(
163
- state,
164
- host,
165
- override_kwargs=override_kwargs, # type: ignore[arg-type]
166
- override_kwarg_keys=override_kwarg_keys,
167
171
  )
168
172
 
169
- fact_kwargs = {}
173
+ fact_kwargs = {key: value for key, value in kwargs.items() if key not in global_kwargs}
170
174
 
171
- if args or kwargs:
172
- assert not isinstance(cls.command, str)
175
+ if args or fact_kwargs:
173
176
  # Merges args & kwargs into a single kwargs dictionary
174
- fact_kwargs = getcallargs(cls().command, *args, **kwargs)
177
+ fact_kwargs = getcallargs(cls().command, *args, **fact_kwargs)
175
178
 
176
- return fact_kwargs, executor_kwargs
179
+ return fact_kwargs, global_kwargs
177
180
 
178
181
 
179
182
  def get_facts(state: "State", *args, **kwargs):
@@ -241,7 +244,7 @@ def _get_fact(
241
244
  fact = cls()
242
245
  name = fact.name
243
246
 
244
- fact_kwargs, executor_kwargs = _handle_fact_kwargs(state, host, cls, args, kwargs)
247
+ fact_kwargs, global_kwargs = _handle_fact_kwargs(state, host, cls, args, kwargs)
245
248
 
246
249
  kwargs_str = get_kwargs_str(fact_kwargs)
247
250
  logger.debug(
@@ -257,15 +260,9 @@ def _get_fact(
257
260
  raise_exceptions=True,
258
261
  )
259
262
 
260
- ignore_errors = (
261
- host.current_op_global_arguments["_ignore_errors"]
262
- if host.in_op and host.current_op_global_arguments
263
- else state.config.IGNORE_ERRORS
264
- )
265
-
266
263
  # Facts can override the shell (winrm powershell vs cmd support)
267
264
  if fact.shell_executable:
268
- executor_kwargs["_shell_executable"] = fact.shell_executable
265
+ global_kwargs["_shell_executable"] = fact.shell_executable
269
266
 
270
267
  command = _make_command(fact.command, fact_kwargs)
271
268
  requires_command = _make_command(fact.requires_command, fact_kwargs)
@@ -284,6 +281,10 @@ def _get_fact(
284
281
  status = False
285
282
  output = CommandOutput([])
286
283
 
284
+ executor_kwargs = {
285
+ key: value for key, value in global_kwargs.items() if key in CONNECTOR_ARGUMENT_KEYS
286
+ }
287
+
287
288
  try:
288
289
  status, output = host.run_shell_command(
289
290
  command,
@@ -295,7 +296,7 @@ def _get_fact(
295
296
  log_host_command_error(
296
297
  host,
297
298
  e,
298
- timeout=executor_kwargs["_timeout"],
299
+ timeout=global_kwargs["_timeout"],
299
300
  )
300
301
 
301
302
  stdout_lines, stderr_lines = output.stdout_lines, output.stderr_lines
@@ -334,12 +335,12 @@ def _get_fact(
334
335
 
335
336
  log_error_or_warning(
336
337
  host,
337
- ignore_errors,
338
+ global_kwargs["_ignore_errors"],
338
339
  description=("could not load fact: {0} {1}").format(name, get_kwargs_str(fact_kwargs)),
339
340
  )
340
341
 
341
342
  # Check we've not failed
342
- if not status and not ignore_errors and apply_failed_hosts:
343
+ if apply_failed_hosts and not status and not global_kwargs["_ignore_errors"]:
343
344
  state.fail_hosts({host})
344
345
 
345
346
  return data
pyinfra/api/host.py CHANGED
@@ -1,10 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from contextlib import contextmanager
4
- from typing import TYPE_CHECKING, Any, Callable, Generator, Optional, Type, TypeVar, Union, overload
4
+ from typing import (
5
+ TYPE_CHECKING,
6
+ Any,
7
+ Callable,
8
+ Generator,
9
+ Optional,
10
+ Type,
11
+ TypeVar,
12
+ Union,
13
+ cast,
14
+ overload,
15
+ )
5
16
  from uuid import uuid4
6
17
 
7
18
  import click
19
+ from typing_extensions import Unpack
8
20
 
9
21
  from pyinfra import logger
10
22
  from pyinfra.connectors.base import BaseConnector
@@ -21,7 +33,9 @@ if TYPE_CHECKING:
21
33
  from pyinfra.api.state import State
22
34
 
23
35
 
24
- def extract_callable_datas(datas: list[Union[Callable[..., Any], Any]]) -> Generator[Any, Any, Any]:
36
+ def extract_callable_datas(
37
+ datas: list[Union[Callable[..., Any], Any]],
38
+ ) -> Generator[Any, Any, Any]:
25
39
  for data in datas:
26
40
  # Support for dynamic data, ie @deploy wrapped data defaults where
27
41
  # the data is stored on the state temporarily.
@@ -98,11 +112,16 @@ class Host:
98
112
  current_op_hash: Optional[str] = None
99
113
  current_op_global_arguments: Optional["AllArguments"] = None
100
114
 
101
- # Current context inside a @deploy function (op gen stage)
115
+ # Current context inside a @deploy function which become part of the op data
102
116
  in_deploy: bool = False
103
117
  current_deploy_name: Optional[str] = None
104
118
  current_deploy_kwargs = None
105
- current_deploy_data = None
119
+
120
+ # @deploy decorator data is a bit different - we need to handle the case
121
+ # where we're evaluating an operation at runtime (current_op_) but also
122
+ # when ordering operations (current_) outside of an operation context.
123
+ current_op_deploy_data: Optional[dict[str, Any]] = None
124
+ current_deploy_data: Optional[dict[str, Any]] = None
106
125
 
107
126
  # Current context during operation execution
108
127
  executing_op_hash: Optional[str] = None
@@ -204,9 +223,7 @@ class Host:
204
223
  self.log(message_styled, log_func=log_func)
205
224
 
206
225
  def get_deploy_data(self):
207
- if self.current_deploy_data:
208
- return self.current_deploy_data
209
- return {}
226
+ return self.current_op_deploy_data or self.current_deploy_data or {}
210
227
 
211
228
  def noop(self, description):
212
229
  """
@@ -216,8 +233,25 @@ class Host:
216
233
  handler = logger.info if self.state.print_noop_info else logger.debug
217
234
  handler("{0}noop: {1}".format(self.print_prefix, description))
218
235
 
236
+ def when(self, condition: Callable[[], bool]):
237
+ return self.deploy(
238
+ "",
239
+ cast("AllArguments", {"_if": [condition]}),
240
+ {},
241
+ in_deploy=False,
242
+ )
243
+
244
+ def arguments(self, **arguments: Unpack["AllArguments"]):
245
+ return self.deploy("", arguments, {}, in_deploy=False)
246
+
219
247
  @contextmanager
220
- def deploy(self, name: str, kwargs, data, in_deploy: bool = True):
248
+ def deploy(
249
+ self,
250
+ name: str,
251
+ kwargs: Optional["AllArguments"],
252
+ data: Optional[dict],
253
+ in_deploy: bool = True,
254
+ ):
221
255
  """
222
256
  Wraps a group of operations as a deploy, this should not be used
223
257
  directly, instead use ``pyinfra.api.deploy.deploy``.
@@ -234,6 +268,13 @@ class Host:
234
268
  old_deploy_data = self.current_deploy_data
235
269
  self.in_deploy = in_deploy
236
270
 
271
+ # Combine any old _ifs with the new ones
272
+ if old_deploy_kwargs and kwargs:
273
+ old_ifs = old_deploy_kwargs["_if"]
274
+ new_ifs = kwargs["_if"]
275
+ if old_ifs and new_ifs:
276
+ kwargs["_if"] = old_ifs + new_ifs
277
+
237
278
  # Set the new values
238
279
  self.current_deploy_name = name
239
280
  self.current_deploy_kwargs = kwargs
@@ -297,12 +338,10 @@ class Host:
297
338
  T = TypeVar("T")
298
339
 
299
340
  @overload
300
- def get_fact(self, name_or_cls: Type[FactBase[T]], *args, **kwargs) -> T:
301
- ...
341
+ def get_fact(self, name_or_cls: Type[FactBase[T]], *args, **kwargs) -> T: ...
302
342
 
303
343
  @overload
304
- def get_fact(self, name_or_cls: Type[ShortFactBase[T]], *args, **kwargs) -> T:
305
- ...
344
+ def get_fact(self, name_or_cls: Type[ShortFactBase[T]], *args, **kwargs) -> T: ...
306
345
 
307
346
  def get_fact(self, name_or_cls, *args, **kwargs):
308
347
  """
pyinfra/api/inventory.py CHANGED
@@ -37,6 +37,10 @@ class Inventory:
37
37
 
38
38
  state: "State"
39
39
 
40
+ @staticmethod
41
+ def empty():
42
+ return Inventory(([], {}))
43
+
40
44
  def __init__(self, names_data, override_data=None, **groups):
41
45
  # Setup basics
42
46
  self.groups = defaultdict(list) # lists of Host objects