pyinfra 3.0.dev0__py2.py3-none-any.whl → 3.0.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 (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 +188 -120
  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.2.dist-info}/METADATA +51 -58
  109. pyinfra-3.0.2.dist-info/RECORD +168 -0
  110. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/WHEEL +1 -1
  111. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.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 +17 -14
  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.2.dist-info}/LICENSE.md +0 -0
  148. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/top_level.txt +0 -0
pyinfra/api/host.py CHANGED
@@ -1,9 +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, Tuple, Union
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
+ )
16
+ from uuid import uuid4
5
17
 
6
18
  import click
19
+ from typing_extensions import Unpack
7
20
 
8
21
  from pyinfra import logger
9
22
  from pyinfra.connectors.base import BaseConnector
@@ -11,7 +24,8 @@ from pyinfra.connectors.util import CommandOutput, remove_any_sudo_askpass_file
11
24
 
12
25
  from .connectors import get_execution_connector
13
26
  from .exceptions import ConnectError
14
- from .facts import get_host_fact
27
+ from .facts import FactBase, ShortFactBase, get_host_fact
28
+ from .util import memoize, sha1_hash
15
29
 
16
30
  if TYPE_CHECKING:
17
31
  from pyinfra.api.arguments import AllArguments
@@ -19,7 +33,9 @@ if TYPE_CHECKING:
19
33
  from pyinfra.api.state import State
20
34
 
21
35
 
22
- 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]:
23
39
  for data in datas:
24
40
  # Support for dynamic data, ie @deploy wrapped data defaults where
25
41
  # the data is stored on the state temporarily.
@@ -94,17 +110,22 @@ class Host:
94
110
  in_op: bool = False
95
111
  in_callback_op: bool = False
96
112
  current_op_hash: Optional[str] = None
97
- current_op_global_arguments: Optional["AllArguments"]
113
+ current_op_global_arguments: Optional["AllArguments"] = None
98
114
 
99
- # Current context inside a @deploy function (op gen stage)
115
+ # Current context inside a @deploy function which become part of the op data
100
116
  in_deploy: bool = False
101
117
  current_deploy_name: Optional[str] = None
102
118
  current_deploy_kwargs = None
103
- 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
104
125
 
105
126
  # Current context during operation execution
106
- executing_op_hash = None
107
- nested_executing_op_hash = None
127
+ executing_op_hash: Optional[str] = None
128
+ nested_executing_op_hash: Optional[str] = None
108
129
 
109
130
  loop_position: list[int]
110
131
 
@@ -133,7 +154,6 @@ class Host:
133
154
  self.loop_position = []
134
155
 
135
156
  self.connector_data = {}
136
- self.current_op_global_arguments = {}
137
157
 
138
158
  # Append only list of operation hashes as called on this host, used to
139
159
  # generate a DAG to create the final operation order.
@@ -195,11 +215,15 @@ class Host:
195
215
  self.print_prefix_padding,
196
216
  )
197
217
 
198
- def get_deploy_data(self):
199
- if self.current_deploy_data:
200
- return self.current_deploy_data
218
+ def log(self, message, log_func=logger.info):
219
+ log_func(f"{self.print_prefix}{message}")
201
220
 
202
- return {}
221
+ def log_styled(self, message, log_func=logger.info, **kwargs):
222
+ message_styled = click.style(message, **kwargs)
223
+ self.log(message_styled, log_func=log_func)
224
+
225
+ def get_deploy_data(self):
226
+ return self.current_op_deploy_data or self.current_deploy_data or {}
203
227
 
204
228
  def noop(self, description):
205
229
  """
@@ -209,8 +233,25 @@ class Host:
209
233
  handler = logger.info if self.state.print_noop_info else logger.debug
210
234
  handler("{0}noop: {1}".format(self.print_prefix, description))
211
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
+
212
247
  @contextmanager
213
- 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
+ ):
214
255
  """
215
256
  Wraps a group of operations as a deploy, this should not be used
216
257
  directly, instead use ``pyinfra.api.deploy.deploy``.
@@ -227,6 +268,13 @@ class Host:
227
268
  old_deploy_data = self.current_deploy_data
228
269
  self.in_deploy = in_deploy
229
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
+
230
278
  # Set the new values
231
279
  self.current_deploy_name = name
232
280
  self.current_deploy_kwargs = kwargs
@@ -253,9 +301,48 @@ class Host:
253
301
  old_deploy_data,
254
302
  )
255
303
 
304
+ @memoize
305
+ def _get_temp_directory(self):
306
+ temp_directory = self.state.config.TEMP_DIR
307
+
308
+ if temp_directory is None:
309
+ # Unfortunate, but very hard to avoid, circular dependency, this method is memoized so
310
+ # performance isn't a concern.
311
+ from pyinfra.facts.server import TmpDir
312
+
313
+ temp_directory = self.get_fact(TmpDir)
314
+
315
+ if not temp_directory:
316
+ temp_directory = self.state.config.DEFAULT_TEMP_DIR
317
+
318
+ return temp_directory
319
+
320
+ def get_temp_filename(self, hash_key: Optional[str] = None, hash_filename: bool = True):
321
+ """
322
+ Generate a temporary filename for this deploy.
323
+ """
324
+
325
+ temp_directory = self._get_temp_directory()
326
+
327
+ if not hash_key:
328
+ hash_key = str(uuid4())
329
+
330
+ if hash_filename:
331
+ hash_key = sha1_hash(hash_key)
332
+
333
+ return "{0}/pyinfra-{1}".format(temp_directory, hash_key)
334
+
256
335
  # Host facts
257
336
  #
258
337
 
338
+ T = TypeVar("T")
339
+
340
+ @overload
341
+ def get_fact(self, name_or_cls: Type[FactBase[T]], *args, **kwargs) -> T: ...
342
+
343
+ @overload
344
+ def get_fact(self, name_or_cls: Type[ShortFactBase[T]], *args, **kwargs) -> T: ...
345
+
259
346
  def get_fact(self, name_or_cls, *args, **kwargs):
260
347
  """
261
348
  Get a fact for this host, reading from the cache if present.
@@ -323,7 +410,7 @@ class Host:
323
410
 
324
411
  self.state.trigger_callbacks("host_disconnect", self)
325
412
 
326
- def run_shell_command(self, *args, **kwargs) -> Tuple[bool, CommandOutput]:
413
+ def run_shell_command(self, *args, **kwargs) -> tuple[bool, CommandOutput]:
327
414
  """
328
415
  Low level method to execute a shell command on the host via it's configured connector.
329
416
  """
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
pyinfra/api/operation.py CHANGED
@@ -8,15 +8,19 @@ to the deploy state. This is then run later by pyinfra's ``__main__`` or the
8
8
  from __future__ import annotations
9
9
 
10
10
  from functools import wraps
11
+ from inspect import signature
11
12
  from io import StringIO
12
13
  from types import FunctionType
13
- from typing import Any, Iterator, Optional, Set, Tuple
14
+ from typing import TYPE_CHECKING, Any, Callable, Generator, Iterator, Optional, cast
15
+
16
+ from typing_extensions import ParamSpec
14
17
 
15
18
  import pyinfra
16
19
  from pyinfra import context, logger
17
20
  from pyinfra.context import ctx_host, ctx_state
18
21
 
19
- from .arguments import AllArguments, get_execution_kwarg_keys, pop_global_arguments
22
+ from .arguments import EXECUTION_KWARG_KEYS, AllArguments, pop_global_arguments
23
+ from .arguments_typed import PyinfraOperation
20
24
  from .command import PyinfraCommand, StringCommand
21
25
  from .exceptions import OperationValueError, PyinfraError
22
26
  from .host import Host
@@ -32,53 +36,117 @@ from .util import (
32
36
 
33
37
  op_meta_default = object()
34
38
 
39
+ if TYPE_CHECKING:
40
+ from pyinfra.connectors.util import CommandOutput
41
+
35
42
 
36
43
  class OperationMeta:
37
- combined_output_lines = None
38
- commands: Optional[list[Any]] = None
39
- changed: bool = False
40
- success: Optional[bool] = None
44
+ _hash: str
45
+
46
+ _combined_output: Optional["CommandOutput"] = None
47
+ _commands: Optional[list[Any]] = None
48
+ _maybe_is_change: Optional[bool] = None
49
+ _success: Optional[bool] = None
41
50
 
42
- def __init__(self, hash=None, is_change=False):
43
- self.hash = hash
44
- self.changed = is_change
51
+ def __init__(self, hash, is_change: Optional[bool]):
52
+ self._hash = hash
53
+ self._maybe_is_change = is_change
45
54
 
46
55
  def __repr__(self) -> str:
47
56
  """
48
57
  Return Operation object as a string.
49
58
  """
50
59
 
51
- return f"OperationMeta(changed={self.changed}, hash={self.hash})"
52
-
53
- def set_combined_output_lines(self, combined_output_lines):
54
- self.combined_output_lines = combined_output_lines
55
-
56
- def set_commands(self, commands) -> None:
57
- self.commands = commands
60
+ if self._commands is not None:
61
+ return (
62
+ "OperationMeta(executed=True, "
63
+ f"success={self.did_succeed()}, hash={self._hash}, commands={len(self._commands)})"
64
+ )
65
+ return (
66
+ "OperationMeta(executed=False, "
67
+ f"maybeChange={self._maybe_is_change}, hash={self._hash})"
68
+ )
58
69
 
59
- def set_result(self, success: bool) -> None:
60
- self.success = success
70
+ # Completion & status checks
71
+ def set_complete(
72
+ self,
73
+ success: bool,
74
+ commands: list[Any],
75
+ combined_output: "CommandOutput",
76
+ ) -> None:
77
+ if self.is_complete():
78
+ raise RuntimeError("Cannot complete an already complete operation")
79
+ self._success = success
80
+ self._commands = commands
81
+ self._combined_output = combined_output
82
+
83
+ def is_complete(self) -> bool:
84
+ return self._success is not None
85
+
86
+ def _raise_if_not_complete(self) -> None:
87
+ if not self.is_complete():
88
+ raise RuntimeError("Cannot evaluate operation result before execution")
61
89
 
62
- def _get_lines(self, types=("stdout", "stderr")):
63
- if self.combined_output_lines is None:
64
- raise AttributeError("Output is not available until operations have been executed")
90
+ @property
91
+ def executed(self) -> bool:
92
+ if self._commands is None:
93
+ return False
94
+ return len(self._commands) > 0
65
95
 
66
- return [line for type_, line in self.combined_output_lines if type_ in types]
96
+ @property
97
+ def will_change(self) -> bool:
98
+ if self._maybe_is_change is not None:
99
+ return self._maybe_is_change
100
+
101
+ op_data = context.state.get_op_data_for_host(context.host, self._hash)
102
+ cmd_gen = op_data.command_generator
103
+ for _ in cmd_gen():
104
+ self._maybe_is_change = True
105
+ return True
106
+ self._maybe_is_change = False
107
+ return False
108
+
109
+ def did_change(self) -> bool:
110
+ self._raise_if_not_complete()
111
+ return bool(self._success and len(self._commands or []) > 0)
112
+
113
+ def did_not_change(self) -> bool:
114
+ return not self.did_change()
115
+
116
+ def did_succeed(self, _raise_if_not_complete=True) -> bool:
117
+ if _raise_if_not_complete:
118
+ self._raise_if_not_complete()
119
+ return self._success is True
120
+
121
+ def did_error(self) -> bool:
122
+ self._raise_if_not_complete()
123
+ return self._success is False
124
+
125
+ # TODO: deprecated, remove in v4
126
+ @property
127
+ def changed(self) -> bool:
128
+ if self.is_complete():
129
+ return self.did_change()
130
+ return self.will_change
67
131
 
68
132
  @property
69
- def stdout_lines(self):
70
- return self._get_lines(types=("stdout",))
133
+ def stdout_lines(self) -> list[str]:
134
+ self._raise_if_not_complete()
135
+ assert self._combined_output is not None
136
+ return self._combined_output.stdout_lines
71
137
 
72
138
  @property
73
- def stderr_lines(self):
74
- return self._get_lines(types=("stderr",))
139
+ def stderr_lines(self) -> list[str]:
140
+ self._raise_if_not_complete()
141
+ assert self._combined_output is not None
142
+ return self._combined_output.stderr_lines
75
143
 
76
144
  @property
77
- def stdout(self):
145
+ def stdout(self) -> str:
78
146
  return "\n".join(self.stdout_lines)
79
147
 
80
148
  @property
81
- def stderr(self):
149
+ def stderr(self) -> str:
82
150
  return "\n".join(self.stderr_lines)
83
151
 
84
152
 
@@ -113,57 +181,67 @@ def add_op(state: State, op_func, *args, **kwargs):
113
181
  return results
114
182
 
115
183
 
184
+ P = ParamSpec("P")
185
+
186
+
116
187
  def operation(
117
- pipeline_facts=None,
118
188
  is_idempotent: bool = True,
119
- idempotent_notice=None,
120
- ):
189
+ idempotent_notice: Optional[str] = None,
190
+ is_deprecated: bool = False,
191
+ deprecated_for: Optional[str] = None,
192
+ _set_in_op: bool = True,
193
+ ) -> Callable[[Callable[P, Generator]], PyinfraOperation[P]]:
121
194
  """
122
195
  Decorator that takes a simple module function and turn it into the internal
123
196
  operation representation that consists of a list of commands + options
124
197
  (sudo, (sudo|su)_user, env).
125
198
  """
126
199
 
127
- def decorator(f):
128
- f.pipeline_facts = pipeline_facts
129
- f.is_idempotent = is_idempotent
130
- f.idempotent_notice = idempotent_notice
131
- return _wrap_operation(f)
200
+ def decorator(f: Callable[P, Generator]) -> PyinfraOperation[P]:
201
+ f.is_idempotent = is_idempotent # type: ignore[attr-defined]
202
+ f.idempotent_notice = idempotent_notice # type: ignore[attr-defined]
203
+ f.is_deprecated = is_deprecated # type: ignore[attr-defined]
204
+ f.deprecated_for = deprecated_for # type: ignore[attr-defined]
205
+ return _wrap_operation(f, _set_in_op=_set_in_op)
132
206
 
133
207
  return decorator
134
208
 
135
209
 
136
- def _wrap_operation(func):
210
+ def _wrap_operation(func: Callable[P, Generator], _set_in_op: bool = True) -> PyinfraOperation[P]:
137
211
  @wraps(func)
138
- def decorated_func(*args, **kwargs):
212
+ def decorated_func(*args: P.args, **kwargs: P.kwargs) -> OperationMeta:
139
213
  state = context.state
140
214
  host = context.host
141
215
 
216
+ if host.in_op:
217
+ raise Exception(
218
+ "Operation called within another operation, this is not allowed! Use the `_inner` "
219
+ + "function to call the underlying operation."
220
+ )
221
+
222
+ if func.is_deprecated: # type: ignore[attr-defined]
223
+ if func.deprecated_for: # type: ignore[attr-defined]
224
+ logger.warning(
225
+ f"The {get_operation_name_from_func(func)} operation is "
226
+ + f"deprecated, please use: {func.deprecated_for}", # type: ignore[attr-defined] # noqa
227
+ )
228
+ else:
229
+ logger.warning(f"The {get_operation_name_from_func(func)} operation is deprecated")
230
+
142
231
  # Configure operation
143
232
  #
144
233
  # Get the meta kwargs (globals that apply to all hosts)
145
234
  global_arguments, global_argument_keys = pop_global_arguments(kwargs)
146
235
 
147
- # If this op is being called inside another, just return here
148
- # (any unwanted/op-related kwargs removed above).
149
- if host.in_op and not host.in_callback_op:
150
- if global_argument_keys:
151
- _error_msg = "Nested operation called with global arguments: {0} ({1})".format(
152
- global_argument_keys,
153
- get_call_location(),
154
- )
155
- raise PyinfraError(_error_msg)
156
- return func(*args, **kwargs) or []
157
-
158
- names, add_args = _generate_operation_name(func, host, kwargs, global_arguments)
159
- op_order, op_hash = _solve_operation_consistency(names, state, host)
236
+ names, add_args = generate_operation_name(func, host, kwargs, global_arguments)
237
+ op_order, op_hash = solve_operation_consistency(names, state, host)
160
238
 
161
239
  # Ensure shared (between servers) operation meta, mutates state
162
- op_meta = _ensure_shared_op_meta(state, op_hash, op_order, global_arguments, names)
240
+ op_meta = ensure_shared_op_meta(state, op_hash, op_order, global_arguments, names)
163
241
 
164
242
  # Attach normal args, if we're auto-naming this operation
165
243
  if add_args:
166
- op_meta = _attach_args(op_meta, args, kwargs)
244
+ op_meta = attach_args(op_meta, args, kwargs)
167
245
 
168
246
  # Check if we're actually running the operation on this host
169
247
  # Run once and we've already added meta for this op? Stop here.
@@ -175,20 +253,30 @@ def _wrap_operation(func):
175
253
  break
176
254
 
177
255
  if has_run:
178
- return OperationMeta(op_hash)
256
+ return OperationMeta(op_hash, is_change=False)
257
+
258
+ # Grab a reference to any *current* deploy data as this may change when
259
+ # we later evaluate the operation at runtime.This means we put back the
260
+ # expected deploy data.
261
+ current_deploy_data = host.current_deploy_data
179
262
 
180
263
  # "Run" operation - here we make a generator that will yield out actual commands to execute
181
264
  # and, if we're diff-ing, we then iterate the generator now to determine if any changes
182
265
  # *would* be made based on the *current* remote state.
183
266
 
184
267
  def command_generator() -> Iterator[PyinfraCommand]:
185
- host.in_op = True
186
- # MY EYES, this is evil
187
- host.in_callback_op = (
188
- func.__name__ == "call" and func.__module__ == "pyinfra.operations.python"
189
- )
268
+ # Check global _if argument function and do nothing if returns False
269
+ if state.is_executing:
270
+ _ifs = global_arguments.get("_if")
271
+ if isinstance(_ifs, list) and not all(_if() for _if in _ifs):
272
+ return
273
+ elif callable(_ifs) and not _ifs():
274
+ return
275
+
276
+ host.in_op = _set_in_op
190
277
  host.current_op_hash = op_hash
191
278
  host.current_op_global_arguments = global_arguments
279
+ host.current_op_deploy_data = current_deploy_data
192
280
 
193
281
  try:
194
282
  for command in func(*args, **kwargs):
@@ -199,35 +287,54 @@ def _wrap_operation(func):
199
287
  host.in_op = False
200
288
  host.current_op_hash = None
201
289
  host.current_op_global_arguments = None
290
+ host.current_op_deploy_data = None
202
291
 
203
- op_is_change = False
292
+ op_is_change = None
204
293
  if state.should_check_for_changes():
205
- for command in command_generator():
294
+ op_is_change = False
295
+ for _ in command_generator():
206
296
  op_is_change = True
207
297
  break
298
+ else:
299
+ # If not calling the op function to check for change we still want to ensure the args
300
+ # are valid, so use Signature.bind to trigger any TypeError.
301
+ signature(func).bind(*args, **kwargs)
208
302
 
209
303
  # Add host-specific operation data to state, this mutates state
210
- operation_meta = _add_host_op_to_state(
211
- state,
212
- host,
213
- op_hash,
214
- op_is_change,
215
- command_generator,
216
- global_arguments,
217
- )
304
+ host_meta = state.get_meta_for_host(host)
305
+ host_meta.ops += 1
306
+ if op_is_change:
307
+ host_meta.ops_change += 1
308
+ else:
309
+ host_meta.ops_no_change += 1
310
+
311
+ operation_meta = OperationMeta(op_hash, op_is_change)
312
+
313
+ # Add the server-relevant commands
314
+ op_data = StateOperationHostData(command_generator, global_arguments, operation_meta)
315
+ state.set_op_data_for_host(host, op_hash, op_data)
218
316
 
219
317
  # If we're already in the execution phase, execute this operation immediately
220
318
  if state.is_executing:
221
- _execute_immediately(state, host, op_hash)
319
+ execute_immediately(state, host, op_hash)
222
320
 
223
321
  # Return result meta for use in deploy scripts
224
322
  return operation_meta
225
323
 
226
- decorated_func._pyinfra_op = func # type: ignore
227
- return decorated_func
324
+ decorated_func._inner = func # type: ignore[attr-defined]
325
+ return cast(PyinfraOperation[P], decorated_func)
228
326
 
229
327
 
230
- def _generate_operation_name(func, host, kwargs, global_arguments):
328
+ def get_operation_name_from_func(func):
329
+ if func.__module__:
330
+ module_bits = func.__module__.split(".")
331
+ module_name = module_bits[-1]
332
+ return "{0}.{1}".format(module_name, func.__name__)
333
+ else:
334
+ return func.__name__
335
+
336
+
337
+ def generate_operation_name(func, host, kwargs, global_arguments):
231
338
  # Generate an operation name if needed (Module/Operation format)
232
339
  name = global_arguments.get("name")
233
340
  add_args = False
@@ -235,14 +342,7 @@ def _generate_operation_name(func, host, kwargs, global_arguments):
235
342
  names = {name}
236
343
  else:
237
344
  add_args = True
238
-
239
- if func.__module__:
240
- module_bits = func.__module__.split(".")
241
- module_name = module_bits[-1]
242
- name = "{0}/{1}".format(module_name.title(), func.__name__.title())
243
- else:
244
- name = func.__name__
245
-
345
+ name = get_operation_name_from_func(func)
246
346
  names = {name}
247
347
 
248
348
  if host.current_deploy_name:
@@ -251,7 +351,7 @@ def _generate_operation_name(func, host, kwargs, global_arguments):
251
351
  return names, add_args
252
352
 
253
353
 
254
- def _solve_operation_consistency(names, state, host):
354
+ def solve_operation_consistency(names, state, host):
255
355
  # Operation order is used to tie-break available nodes in the operation DAG, in CLI mode
256
356
  # we use stack call order so this matches as defined by the user deploy code.
257
357
  if pyinfra.is_cli:
@@ -286,16 +386,16 @@ def _solve_operation_consistency(names, state, host):
286
386
 
287
387
 
288
388
  # NOTE: this function mutates state.op_meta for this hash
289
- def _ensure_shared_op_meta(
389
+ def ensure_shared_op_meta(
290
390
  state: State,
291
391
  op_hash: str,
292
- op_order: Tuple[int],
392
+ op_order: tuple[int, ...],
293
393
  global_arguments: AllArguments,
294
- names: Set[str],
394
+ names: set[str],
295
395
  ):
296
396
  op_meta = state.op_meta.setdefault(op_hash, StateOperationMeta(op_order))
297
397
 
298
- for key in get_execution_kwarg_keys():
398
+ for key in EXECUTION_KWARG_KEYS:
299
399
  global_value = global_arguments.pop(key) # type: ignore[misc]
300
400
  op_meta_value = op_meta.global_arguments.get(key, op_meta_default)
301
401
 
@@ -310,12 +410,7 @@ def _ensure_shared_op_meta(
310
410
  return op_meta
311
411
 
312
412
 
313
- def _execute_immediately(state, host, op_hash):
314
- logger.warning(
315
- f"Note: nested operations are currently in beta ({get_call_location()})\n"
316
- " More information: "
317
- "https://docs.pyinfra.com/en/2.x/using-operations.html#nested-operations",
318
- )
413
+ def execute_immediately(state, host, op_hash):
319
414
  op_meta = state.get_op_meta(op_hash)
320
415
  op_data = state.get_op_data_for_host(host, op_hash)
321
416
  op_data.parent_op_hash = host.executing_op_hash
@@ -331,7 +426,7 @@ def _get_arg_value(arg):
331
426
  return arg
332
427
 
333
428
 
334
- def _attach_args(op_meta, args, kwargs):
429
+ def attach_args(op_meta, args, kwargs):
335
430
  for arg in args:
336
431
  if arg not in op_meta.args:
337
432
  op_meta.args.append(str(_get_arg_value(arg)))
@@ -343,30 +438,3 @@ def _attach_args(op_meta, args, kwargs):
343
438
  op_meta.args.append(arg)
344
439
 
345
440
  return op_meta
346
-
347
-
348
- # NOTE: this function mutates state.meta for this host
349
- def _add_host_op_to_state(
350
- state: State,
351
- host: Host,
352
- op_hash: str,
353
- is_change: bool,
354
- command_generator,
355
- global_arguments,
356
- ) -> OperationMeta:
357
- host_meta = state.get_meta_for_host(host)
358
-
359
- host_meta.ops += 1
360
-
361
- if is_change:
362
- host_meta.ops_change += 1
363
- else:
364
- host_meta.ops_no_change += 1
365
-
366
- operation_meta = OperationMeta(op_hash, is_change)
367
-
368
- # Add the server-relevant commands
369
- op_data = StateOperationHostData(command_generator, global_arguments, operation_meta)
370
- state.set_op_data_for_host(host, op_hash, op_data)
371
-
372
- return operation_meta