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/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,115 @@ 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})"
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
+ )
69
+
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")
52
89
 
53
- def set_combined_output_lines(self, combined_output_lines):
54
- self.combined_output_lines = combined_output_lines
90
+ @property
91
+ def will_change(self) -> bool:
92
+ if self._maybe_is_change is not None:
93
+ return self._maybe_is_change
94
+
95
+ op_data = context.state.get_op_data_for_host(context.host, self._hash)
96
+ cmd_gen = op_data.command_generator
97
+ for _ in cmd_gen():
98
+ self._maybe_is_change = True
99
+ return True
100
+ self._maybe_is_change = False
101
+ return False
102
+
103
+ def _did_change(self) -> bool:
104
+ return bool(self._success and len(self._commands or []) > 0)
55
105
 
56
- def set_commands(self, commands) -> None:
57
- self.commands = commands
106
+ @property
107
+ def did_change(self):
108
+ return context.host.when(self._did_change)
58
109
 
59
- def set_result(self, success: bool) -> None:
60
- self.success = success
110
+ @property
111
+ def did_not_change(self):
112
+ return context.host.when(lambda: not self._did_change())
61
113
 
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")
114
+ def did_succeed(self, _raise_if_not_complete=True) -> bool:
115
+ if _raise_if_not_complete:
116
+ self._raise_if_not_complete()
117
+ return self._success is True
65
118
 
66
- return [line for type_, line in self.combined_output_lines if type_ in types]
119
+ def did_error(self) -> bool:
120
+ self._raise_if_not_complete()
121
+ return self._success is False
67
122
 
123
+ # TODO: deprecated, remove in v4
68
124
  @property
69
- def stdout_lines(self):
70
- return self._get_lines(types=("stdout",))
125
+ def changed(self) -> bool:
126
+ if self.is_complete():
127
+ return self._did_change()
128
+ return self.will_change
71
129
 
72
130
  @property
73
- def stderr_lines(self):
74
- return self._get_lines(types=("stderr",))
131
+ def stdout_lines(self) -> list[str]:
132
+ self._raise_if_not_complete()
133
+ assert self._combined_output is not None
134
+ return self._combined_output.stdout_lines
75
135
 
76
136
  @property
77
- def stdout(self):
137
+ def stderr_lines(self) -> list[str]:
138
+ self._raise_if_not_complete()
139
+ assert self._combined_output is not None
140
+ return self._combined_output.stderr_lines
141
+
142
+ @property
143
+ def stdout(self) -> str:
78
144
  return "\n".join(self.stdout_lines)
79
145
 
80
146
  @property
81
- def stderr(self):
147
+ def stderr(self) -> str:
82
148
  return "\n".join(self.stderr_lines)
83
149
 
84
150
 
@@ -113,57 +179,67 @@ def add_op(state: State, op_func, *args, **kwargs):
113
179
  return results
114
180
 
115
181
 
182
+ P = ParamSpec("P")
183
+
184
+
116
185
  def operation(
117
- pipeline_facts=None,
118
186
  is_idempotent: bool = True,
119
- idempotent_notice=None,
120
- ):
187
+ idempotent_notice: Optional[str] = None,
188
+ is_deprecated: bool = False,
189
+ deprecated_for: Optional[str] = None,
190
+ _set_in_op: bool = True,
191
+ ) -> Callable[[Callable[P, Generator]], PyinfraOperation[P]]:
121
192
  """
122
193
  Decorator that takes a simple module function and turn it into the internal
123
194
  operation representation that consists of a list of commands + options
124
195
  (sudo, (sudo|su)_user, env).
125
196
  """
126
197
 
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)
198
+ def decorator(f: Callable[P, Generator]) -> PyinfraOperation[P]:
199
+ f.is_idempotent = is_idempotent # type: ignore[attr-defined]
200
+ f.idempotent_notice = idempotent_notice # type: ignore[attr-defined]
201
+ f.is_deprecated = is_deprecated # type: ignore[attr-defined]
202
+ f.deprecated_for = deprecated_for # type: ignore[attr-defined]
203
+ return _wrap_operation(f, _set_in_op=_set_in_op)
132
204
 
133
205
  return decorator
134
206
 
135
207
 
136
- def _wrap_operation(func):
208
+ def _wrap_operation(func: Callable[P, Generator], _set_in_op: bool = True) -> PyinfraOperation[P]:
137
209
  @wraps(func)
138
- def decorated_func(*args, **kwargs):
210
+ def decorated_func(*args: P.args, **kwargs: P.kwargs) -> OperationMeta:
139
211
  state = context.state
140
212
  host = context.host
141
213
 
214
+ if host.in_op:
215
+ raise Exception(
216
+ "Operation called within another operation, this is not allowed! Use the `_inner` "
217
+ + "function to call the underlying operation."
218
+ )
219
+
220
+ if func.is_deprecated: # type: ignore[attr-defined]
221
+ if func.deprecated_for: # type: ignore[attr-defined]
222
+ logger.warning(
223
+ f"The {get_operation_name_from_func(func)} operation is "
224
+ + f"deprecated, please use: {func.deprecated_for}", # type: ignore[attr-defined] # noqa
225
+ )
226
+ else:
227
+ logger.warning(f"The {get_operation_name_from_func(func)} operation is deprecated")
228
+
142
229
  # Configure operation
143
230
  #
144
231
  # Get the meta kwargs (globals that apply to all hosts)
145
232
  global_arguments, global_argument_keys = pop_global_arguments(kwargs)
146
233
 
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)
234
+ names, add_args = generate_operation_name(func, host, kwargs, global_arguments)
235
+ op_order, op_hash = solve_operation_consistency(names, state, host)
160
236
 
161
237
  # Ensure shared (between servers) operation meta, mutates state
162
- op_meta = _ensure_shared_op_meta(state, op_hash, op_order, global_arguments, names)
238
+ op_meta = ensure_shared_op_meta(state, op_hash, op_order, global_arguments, names)
163
239
 
164
240
  # Attach normal args, if we're auto-naming this operation
165
241
  if add_args:
166
- op_meta = _attach_args(op_meta, args, kwargs)
242
+ op_meta = attach_args(op_meta, args, kwargs)
167
243
 
168
244
  # Check if we're actually running the operation on this host
169
245
  # Run once and we've already added meta for this op? Stop here.
@@ -175,20 +251,30 @@ def _wrap_operation(func):
175
251
  break
176
252
 
177
253
  if has_run:
178
- return OperationMeta(op_hash)
254
+ return OperationMeta(op_hash, is_change=False)
255
+
256
+ # Grab a reference to any *current* deploy data as this may change when
257
+ # we later evaluate the operation at runtime.This means we put back the
258
+ # expected deploy data.
259
+ current_deploy_data = host.current_deploy_data
179
260
 
180
261
  # "Run" operation - here we make a generator that will yield out actual commands to execute
181
262
  # and, if we're diff-ing, we then iterate the generator now to determine if any changes
182
263
  # *would* be made based on the *current* remote state.
183
264
 
184
265
  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
- )
266
+ # Check global _if argument function and do nothing if returns False
267
+ if state.is_executing:
268
+ _ifs = global_arguments.get("_if")
269
+ if isinstance(_ifs, list) and not all(_if() for _if in _ifs):
270
+ return
271
+ elif callable(_ifs) and not _ifs():
272
+ return
273
+
274
+ host.in_op = _set_in_op
190
275
  host.current_op_hash = op_hash
191
276
  host.current_op_global_arguments = global_arguments
277
+ host.current_op_deploy_data = current_deploy_data
192
278
 
193
279
  try:
194
280
  for command in func(*args, **kwargs):
@@ -199,35 +285,54 @@ def _wrap_operation(func):
199
285
  host.in_op = False
200
286
  host.current_op_hash = None
201
287
  host.current_op_global_arguments = None
288
+ host.current_op_deploy_data = None
202
289
 
203
- op_is_change = False
290
+ op_is_change = None
204
291
  if state.should_check_for_changes():
205
- for command in command_generator():
292
+ op_is_change = False
293
+ for _ in command_generator():
206
294
  op_is_change = True
207
295
  break
296
+ else:
297
+ # If not calling the op function to check for change we still want to ensure the args
298
+ # are valid, so use Signature.bind to trigger any TypeError.
299
+ signature(func).bind(*args, **kwargs)
208
300
 
209
301
  # 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
- )
302
+ host_meta = state.get_meta_for_host(host)
303
+ host_meta.ops += 1
304
+ if op_is_change:
305
+ host_meta.ops_change += 1
306
+ else:
307
+ host_meta.ops_no_change += 1
308
+
309
+ operation_meta = OperationMeta(op_hash, op_is_change)
310
+
311
+ # Add the server-relevant commands
312
+ op_data = StateOperationHostData(command_generator, global_arguments, operation_meta)
313
+ state.set_op_data_for_host(host, op_hash, op_data)
218
314
 
219
315
  # If we're already in the execution phase, execute this operation immediately
220
316
  if state.is_executing:
221
- _execute_immediately(state, host, op_hash)
317
+ execute_immediately(state, host, op_hash)
222
318
 
223
319
  # Return result meta for use in deploy scripts
224
320
  return operation_meta
225
321
 
226
- decorated_func._pyinfra_op = func # type: ignore
227
- return decorated_func
322
+ decorated_func._inner = func # type: ignore[attr-defined]
323
+ return cast(PyinfraOperation[P], decorated_func)
228
324
 
229
325
 
230
- def _generate_operation_name(func, host, kwargs, global_arguments):
326
+ def get_operation_name_from_func(func):
327
+ if func.__module__:
328
+ module_bits = func.__module__.split(".")
329
+ module_name = module_bits[-1]
330
+ return "{0}.{1}".format(module_name, func.__name__)
331
+ else:
332
+ return func.__name__
333
+
334
+
335
+ def generate_operation_name(func, host, kwargs, global_arguments):
231
336
  # Generate an operation name if needed (Module/Operation format)
232
337
  name = global_arguments.get("name")
233
338
  add_args = False
@@ -235,14 +340,7 @@ def _generate_operation_name(func, host, kwargs, global_arguments):
235
340
  names = {name}
236
341
  else:
237
342
  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
-
343
+ name = get_operation_name_from_func(func)
246
344
  names = {name}
247
345
 
248
346
  if host.current_deploy_name:
@@ -251,7 +349,7 @@ def _generate_operation_name(func, host, kwargs, global_arguments):
251
349
  return names, add_args
252
350
 
253
351
 
254
- def _solve_operation_consistency(names, state, host):
352
+ def solve_operation_consistency(names, state, host):
255
353
  # Operation order is used to tie-break available nodes in the operation DAG, in CLI mode
256
354
  # we use stack call order so this matches as defined by the user deploy code.
257
355
  if pyinfra.is_cli:
@@ -286,16 +384,16 @@ def _solve_operation_consistency(names, state, host):
286
384
 
287
385
 
288
386
  # NOTE: this function mutates state.op_meta for this hash
289
- def _ensure_shared_op_meta(
387
+ def ensure_shared_op_meta(
290
388
  state: State,
291
389
  op_hash: str,
292
- op_order: Tuple[int],
390
+ op_order: tuple[int, ...],
293
391
  global_arguments: AllArguments,
294
- names: Set[str],
392
+ names: set[str],
295
393
  ):
296
394
  op_meta = state.op_meta.setdefault(op_hash, StateOperationMeta(op_order))
297
395
 
298
- for key in get_execution_kwarg_keys():
396
+ for key in EXECUTION_KWARG_KEYS:
299
397
  global_value = global_arguments.pop(key) # type: ignore[misc]
300
398
  op_meta_value = op_meta.global_arguments.get(key, op_meta_default)
301
399
 
@@ -310,12 +408,7 @@ def _ensure_shared_op_meta(
310
408
  return op_meta
311
409
 
312
410
 
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
- )
411
+ def execute_immediately(state, host, op_hash):
319
412
  op_meta = state.get_op_meta(op_hash)
320
413
  op_data = state.get_op_data_for_host(host, op_hash)
321
414
  op_data.parent_op_hash = host.executing_op_hash
@@ -331,7 +424,7 @@ def _get_arg_value(arg):
331
424
  return arg
332
425
 
333
426
 
334
- def _attach_args(op_meta, args, kwargs):
427
+ def attach_args(op_meta, args, kwargs):
335
428
  for arg in args:
336
429
  if arg not in op_meta.args:
337
430
  op_meta.args.append(str(_get_arg_value(arg)))
@@ -343,30 +436,3 @@ def _attach_args(op_meta, args, kwargs):
343
436
  op_meta.args.append(arg)
344
437
 
345
438
  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