pyinfra 0.11.dev3__py3-none-any.whl → 3.5.1__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 (203) hide show
  1. pyinfra/__init__.py +9 -12
  2. pyinfra/__main__.py +4 -0
  3. pyinfra/api/__init__.py +18 -3
  4. pyinfra/api/arguments.py +406 -0
  5. pyinfra/api/arguments_typed.py +79 -0
  6. pyinfra/api/command.py +274 -0
  7. pyinfra/api/config.py +222 -28
  8. pyinfra/api/connect.py +33 -13
  9. pyinfra/api/connectors.py +27 -0
  10. pyinfra/api/deploy.py +65 -66
  11. pyinfra/api/exceptions.py +67 -18
  12. pyinfra/api/facts.py +253 -202
  13. pyinfra/api/host.py +413 -50
  14. pyinfra/api/inventory.py +121 -160
  15. pyinfra/api/operation.py +432 -262
  16. pyinfra/api/operations.py +273 -260
  17. pyinfra/api/state.py +302 -248
  18. pyinfra/api/util.py +291 -368
  19. pyinfra/connectors/base.py +173 -0
  20. pyinfra/connectors/chroot.py +212 -0
  21. pyinfra/connectors/docker.py +381 -0
  22. pyinfra/connectors/dockerssh.py +297 -0
  23. pyinfra/connectors/local.py +238 -0
  24. pyinfra/connectors/scp/__init__.py +1 -0
  25. pyinfra/connectors/scp/client.py +204 -0
  26. pyinfra/connectors/ssh.py +670 -0
  27. pyinfra/connectors/ssh_util.py +114 -0
  28. pyinfra/connectors/sshuserclient/client.py +309 -0
  29. pyinfra/connectors/sshuserclient/config.py +102 -0
  30. pyinfra/connectors/terraform.py +135 -0
  31. pyinfra/connectors/util.py +410 -0
  32. pyinfra/connectors/vagrant.py +183 -0
  33. pyinfra/context.py +145 -0
  34. pyinfra/facts/__init__.py +7 -6
  35. pyinfra/facts/apk.py +22 -7
  36. pyinfra/facts/apt.py +117 -60
  37. pyinfra/facts/brew.py +100 -15
  38. pyinfra/facts/bsdinit.py +23 -0
  39. pyinfra/facts/cargo.py +37 -0
  40. pyinfra/facts/choco.py +47 -0
  41. pyinfra/facts/crontab.py +195 -0
  42. pyinfra/facts/deb.py +94 -0
  43. pyinfra/facts/dnf.py +48 -0
  44. pyinfra/facts/docker.py +96 -23
  45. pyinfra/facts/efibootmgr.py +113 -0
  46. pyinfra/facts/files.py +630 -58
  47. pyinfra/facts/flatpak.py +77 -0
  48. pyinfra/facts/freebsd.py +70 -0
  49. pyinfra/facts/gem.py +19 -6
  50. pyinfra/facts/git.py +59 -14
  51. pyinfra/facts/gpg.py +150 -0
  52. pyinfra/facts/hardware.py +313 -167
  53. pyinfra/facts/iptables.py +72 -62
  54. pyinfra/facts/launchd.py +44 -0
  55. pyinfra/facts/lxd.py +17 -4
  56. pyinfra/facts/mysql.py +122 -86
  57. pyinfra/facts/npm.py +17 -9
  58. pyinfra/facts/openrc.py +71 -0
  59. pyinfra/facts/opkg.py +246 -0
  60. pyinfra/facts/pacman.py +50 -7
  61. pyinfra/facts/pip.py +24 -7
  62. pyinfra/facts/pipx.py +82 -0
  63. pyinfra/facts/pkg.py +15 -6
  64. pyinfra/facts/pkgin.py +35 -0
  65. pyinfra/facts/podman.py +54 -0
  66. pyinfra/facts/postgres.py +178 -0
  67. pyinfra/facts/postgresql.py +6 -147
  68. pyinfra/facts/rpm.py +105 -0
  69. pyinfra/facts/runit.py +77 -0
  70. pyinfra/facts/selinux.py +161 -0
  71. pyinfra/facts/server.py +746 -285
  72. pyinfra/facts/snap.py +88 -0
  73. pyinfra/facts/systemd.py +139 -0
  74. pyinfra/facts/sysvinit.py +59 -0
  75. pyinfra/facts/upstart.py +35 -0
  76. pyinfra/facts/util/__init__.py +17 -0
  77. pyinfra/facts/util/databases.py +4 -6
  78. pyinfra/facts/util/packaging.py +37 -6
  79. pyinfra/facts/util/units.py +30 -0
  80. pyinfra/facts/util/win_files.py +99 -0
  81. pyinfra/facts/vzctl.py +20 -13
  82. pyinfra/facts/xbps.py +35 -0
  83. pyinfra/facts/yum.py +34 -40
  84. pyinfra/facts/zfs.py +77 -0
  85. pyinfra/facts/zypper.py +42 -0
  86. pyinfra/local.py +45 -83
  87. pyinfra/operations/__init__.py +12 -0
  88. pyinfra/operations/apk.py +98 -0
  89. pyinfra/operations/apt.py +488 -0
  90. pyinfra/operations/brew.py +231 -0
  91. pyinfra/operations/bsdinit.py +59 -0
  92. pyinfra/operations/cargo.py +45 -0
  93. pyinfra/operations/choco.py +61 -0
  94. pyinfra/operations/crontab.py +191 -0
  95. pyinfra/operations/dnf.py +210 -0
  96. pyinfra/operations/docker.py +446 -0
  97. pyinfra/operations/files.py +1939 -0
  98. pyinfra/operations/flatpak.py +94 -0
  99. pyinfra/operations/freebsd/__init__.py +12 -0
  100. pyinfra/operations/freebsd/freebsd_update.py +70 -0
  101. pyinfra/operations/freebsd/pkg.py +219 -0
  102. pyinfra/operations/freebsd/service.py +116 -0
  103. pyinfra/operations/freebsd/sysrc.py +92 -0
  104. pyinfra/operations/gem.py +47 -0
  105. pyinfra/operations/git.py +419 -0
  106. pyinfra/operations/iptables.py +311 -0
  107. pyinfra/operations/launchd.py +45 -0
  108. pyinfra/operations/lxd.py +68 -0
  109. pyinfra/operations/mysql.py +609 -0
  110. pyinfra/operations/npm.py +57 -0
  111. pyinfra/operations/openrc.py +63 -0
  112. pyinfra/operations/opkg.py +88 -0
  113. pyinfra/operations/pacman.py +81 -0
  114. pyinfra/operations/pip.py +205 -0
  115. pyinfra/operations/pipx.py +102 -0
  116. pyinfra/operations/pkg.py +70 -0
  117. pyinfra/operations/pkgin.py +91 -0
  118. pyinfra/operations/postgres.py +436 -0
  119. pyinfra/operations/postgresql.py +30 -0
  120. pyinfra/operations/puppet.py +40 -0
  121. pyinfra/operations/python.py +72 -0
  122. pyinfra/operations/runit.py +184 -0
  123. pyinfra/operations/selinux.py +189 -0
  124. pyinfra/operations/server.py +1099 -0
  125. pyinfra/operations/snap.py +117 -0
  126. pyinfra/operations/ssh.py +216 -0
  127. pyinfra/operations/systemd.py +149 -0
  128. pyinfra/operations/sysvinit.py +141 -0
  129. pyinfra/operations/upstart.py +68 -0
  130. pyinfra/operations/util/__init__.py +12 -0
  131. pyinfra/operations/util/docker.py +251 -0
  132. pyinfra/operations/util/files.py +247 -0
  133. pyinfra/operations/util/packaging.py +336 -0
  134. pyinfra/operations/util/service.py +46 -0
  135. pyinfra/operations/vzctl.py +137 -0
  136. pyinfra/operations/xbps.py +77 -0
  137. pyinfra/operations/yum.py +210 -0
  138. pyinfra/operations/zfs.py +175 -0
  139. pyinfra/operations/zypper.py +192 -0
  140. pyinfra/progress.py +44 -32
  141. pyinfra/py.typed +0 -0
  142. pyinfra/version.py +9 -1
  143. pyinfra-3.5.1.dist-info/METADATA +141 -0
  144. pyinfra-3.5.1.dist-info/RECORD +159 -0
  145. {pyinfra-0.11.dev3.dist-info → pyinfra-3.5.1.dist-info}/WHEEL +1 -2
  146. pyinfra-3.5.1.dist-info/entry_points.txt +12 -0
  147. {pyinfra-0.11.dev3.dist-info → pyinfra-3.5.1.dist-info/licenses}/LICENSE.md +1 -1
  148. pyinfra_cli/__init__.py +1 -0
  149. pyinfra_cli/cli.py +780 -0
  150. pyinfra_cli/commands.py +66 -0
  151. pyinfra_cli/exceptions.py +155 -65
  152. pyinfra_cli/inventory.py +233 -89
  153. pyinfra_cli/log.py +39 -43
  154. pyinfra_cli/main.py +26 -495
  155. pyinfra_cli/prints.py +215 -156
  156. pyinfra_cli/util.py +172 -105
  157. pyinfra_cli/virtualenv.py +25 -20
  158. pyinfra/api/connectors/__init__.py +0 -21
  159. pyinfra/api/connectors/ansible.py +0 -99
  160. pyinfra/api/connectors/docker.py +0 -178
  161. pyinfra/api/connectors/local.py +0 -169
  162. pyinfra/api/connectors/ssh.py +0 -402
  163. pyinfra/api/connectors/sshuserclient/client.py +0 -105
  164. pyinfra/api/connectors/sshuserclient/config.py +0 -90
  165. pyinfra/api/connectors/util.py +0 -63
  166. pyinfra/api/connectors/vagrant.py +0 -155
  167. pyinfra/facts/init.py +0 -176
  168. pyinfra/facts/util/files.py +0 -102
  169. pyinfra/hook.py +0 -41
  170. pyinfra/modules/__init__.py +0 -11
  171. pyinfra/modules/apk.py +0 -64
  172. pyinfra/modules/apt.py +0 -272
  173. pyinfra/modules/brew.py +0 -122
  174. pyinfra/modules/files.py +0 -711
  175. pyinfra/modules/gem.py +0 -30
  176. pyinfra/modules/git.py +0 -115
  177. pyinfra/modules/init.py +0 -344
  178. pyinfra/modules/iptables.py +0 -271
  179. pyinfra/modules/lxd.py +0 -45
  180. pyinfra/modules/mysql.py +0 -347
  181. pyinfra/modules/npm.py +0 -47
  182. pyinfra/modules/pacman.py +0 -60
  183. pyinfra/modules/pip.py +0 -99
  184. pyinfra/modules/pkg.py +0 -43
  185. pyinfra/modules/postgresql.py +0 -245
  186. pyinfra/modules/puppet.py +0 -20
  187. pyinfra/modules/python.py +0 -37
  188. pyinfra/modules/server.py +0 -524
  189. pyinfra/modules/ssh.py +0 -150
  190. pyinfra/modules/util/files.py +0 -52
  191. pyinfra/modules/util/packaging.py +0 -118
  192. pyinfra/modules/vzctl.py +0 -133
  193. pyinfra/modules/yum.py +0 -171
  194. pyinfra/pseudo_modules.py +0 -64
  195. pyinfra-0.11.dev3.dist-info/.DS_Store +0 -0
  196. pyinfra-0.11.dev3.dist-info/METADATA +0 -135
  197. pyinfra-0.11.dev3.dist-info/RECORD +0 -95
  198. pyinfra-0.11.dev3.dist-info/entry_points.txt +0 -3
  199. pyinfra-0.11.dev3.dist-info/top_level.txt +0 -2
  200. pyinfra_cli/__main__.py +0 -40
  201. pyinfra_cli/config.py +0 -92
  202. /pyinfra/{modules/util → connectors}/__init__.py +0 -0
  203. /pyinfra/{api/connectors → connectors}/sshuserclient/__init__.py +0 -0
pyinfra/api/operation.py CHANGED
@@ -1,55 +1,210 @@
1
- '''
1
+ """
2
2
  Operations are the core of pyinfra. The ``@operation`` wrapper intercepts calls
3
3
  to the function and instead diff against the remote server, outputting commands
4
4
  to the deploy state. This is then run later by pyinfra's ``__main__`` or the
5
5
  :doc:`./pyinfra.api.operations` module.
6
- '''
6
+ """
7
+
8
+ from __future__ import annotations
7
9
 
8
10
  from functools import wraps
9
- from inspect import getframeinfo, stack
10
- from os import path
11
+ from inspect import signature
12
+ from io import StringIO
11
13
  from types import FunctionType
14
+ from typing import TYPE_CHECKING, Any, Callable, Generator, Iterator, Optional, cast
15
+
16
+ from typing_extensions import ParamSpec, override
12
17
 
13
- from pyinfra import logger, pseudo_host, pseudo_state
14
- from pyinfra.pseudo_modules import PseudoModule
18
+ import pyinfra
19
+ from pyinfra import context, logger
20
+ from pyinfra.context import ctx_host, ctx_state
15
21
 
16
- from .exceptions import PyinfraError
22
+ from .arguments import EXECUTION_KWARG_KEYS, AllArguments, pop_global_arguments
23
+ from .arguments_typed import PyinfraOperation
24
+ from .command import PyinfraCommand, StringCommand
25
+ from .exceptions import OperationValueError, PyinfraError
17
26
  from .host import Host
18
- from .state import State
27
+ from .operations import run_host_op
28
+ from .state import State, StateOperationHostData, StateOperationMeta, StateStage
19
29
  from .util import (
20
- get_arg_value,
21
- get_caller_frameinfo,
30
+ get_call_location,
31
+ get_file_sha1,
32
+ get_operation_order_from_stack,
33
+ log_operation_start,
22
34
  make_hash,
23
- pop_op_kwargs,
24
- unroll_generators,
25
35
  )
26
36
 
27
-
28
- # List of available operation names
29
- OPERATIONS = []
30
-
31
-
32
- def get_operation_names():
33
- '''
34
- Returns a list of available operations.
35
- '''
36
-
37
- return OPERATIONS
38
-
39
-
40
- class OperationMeta(object):
41
- def __init__(self, hash=None, commands=None):
42
- # Wrap all the attributes
43
- commands = commands or []
44
- self.commands = commands
45
- self.hash = hash
46
-
47
- # Changed flag = did we do anything?
48
- self.changed = len(self.commands) > 0
37
+ op_meta_default = object()
38
+
39
+ if TYPE_CHECKING:
40
+ from pyinfra.connectors.util import CommandOutput
41
+
42
+
43
+ class OperationMeta:
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
50
+ _retry_attempts: int = 0
51
+ _max_retries: int = 0
52
+ _retry_succeeded: Optional[bool] = None
53
+
54
+ def __init__(self, hash, is_change: Optional[bool]):
55
+ self._hash = hash
56
+ self._maybe_is_change = is_change
57
+
58
+ @override
59
+ def __repr__(self) -> str:
60
+ """
61
+ Return Operation object as a string.
62
+ """
63
+
64
+ if self._commands is not None:
65
+ retry_info = ""
66
+ if self._retry_attempts > 0:
67
+ retry_result = "succeeded" if self._retry_succeeded else "failed"
68
+ retry_info = (
69
+ f", retries={self._retry_attempts}/{self._max_retries} ({retry_result})"
70
+ )
71
+
72
+ return (
73
+ "OperationMeta(executed=True, "
74
+ f"success={self.did_succeed()}, hash={self._hash}, "
75
+ f"commands={len(self._commands)}{retry_info})"
76
+ )
77
+ return (
78
+ f"OperationMeta(executed=False, maybeChange={self._maybe_is_change}, hash={self._hash})"
79
+ )
80
+
81
+ # Completion & status checks
82
+ def set_complete(
83
+ self,
84
+ success: bool,
85
+ commands: list[Any],
86
+ combined_output: "CommandOutput",
87
+ retry_attempts: int = 0,
88
+ max_retries: int = 0,
89
+ ) -> None:
90
+ if self.is_complete():
91
+ raise RuntimeError("Cannot complete an already complete operation")
92
+ self._success = success
93
+ self._commands = commands
94
+ self._combined_output = combined_output
95
+ self._retry_attempts = retry_attempts
96
+ self._max_retries = max_retries
97
+
98
+ # Determine if operation succeeded after retries
99
+ if retry_attempts > 0:
100
+ self._retry_succeeded = success
101
+
102
+ def is_complete(self) -> bool:
103
+ return self._success is not None
104
+
105
+ def _raise_if_not_complete(self) -> None:
106
+ if not self.is_complete():
107
+ raise RuntimeError("Cannot evaluate operation result before execution")
108
+
109
+ @property
110
+ def executed(self) -> bool:
111
+ if self._commands is None:
112
+ return False
113
+ return len(self._commands) > 0
114
+
115
+ @property
116
+ def will_change(self) -> bool:
117
+ if self._maybe_is_change is not None:
118
+ return self._maybe_is_change
119
+
120
+ op_data = context.state.get_op_data_for_host(context.host, self._hash)
121
+ cmd_gen = op_data.command_generator
122
+ for _ in cmd_gen():
123
+ self._maybe_is_change = True
124
+ return True
125
+ self._maybe_is_change = False
126
+ return False
127
+
128
+ def did_change(self) -> bool:
129
+ self._raise_if_not_complete()
130
+ return bool(self._success and len(self._commands or []) > 0)
131
+
132
+ def did_not_change(self) -> bool:
133
+ return not self.did_change()
134
+
135
+ def did_succeed(self, _raise_if_not_complete=True) -> bool:
136
+ if _raise_if_not_complete:
137
+ self._raise_if_not_complete()
138
+ return self._success is True
139
+
140
+ def did_error(self) -> bool:
141
+ self._raise_if_not_complete()
142
+ return self._success is False
143
+
144
+ # TODO: deprecated, remove in v4
145
+ @property
146
+ def changed(self) -> bool:
147
+ if self.is_complete():
148
+ return self.did_change()
149
+ return self.will_change
150
+
151
+ @property
152
+ def stdout_lines(self) -> list[str]:
153
+ self._raise_if_not_complete()
154
+ assert self._combined_output is not None
155
+ return self._combined_output.stdout_lines
156
+
157
+ @property
158
+ def stderr_lines(self) -> list[str]:
159
+ self._raise_if_not_complete()
160
+ assert self._combined_output is not None
161
+ return self._combined_output.stderr_lines
162
+
163
+ @property
164
+ def stdout(self) -> str:
165
+ return "\n".join(self.stdout_lines)
166
+
167
+ @property
168
+ def stderr(self) -> str:
169
+ return "\n".join(self.stderr_lines)
170
+
171
+ @property
172
+ def retry_attempts(self) -> int:
173
+ return self._retry_attempts
174
+
175
+ @property
176
+ def max_retries(self) -> int:
177
+ return self._max_retries
178
+
179
+ @property
180
+ def was_retried(self) -> bool:
181
+ """
182
+ Returns whether this operation was retried at least once.
183
+ """
184
+ return self._retry_attempts > 0
185
+
186
+ @property
187
+ def retry_succeeded(self) -> Optional[bool]:
188
+ """
189
+ Returns whether this operation succeeded after retries.
190
+ Returns None if the operation was not retried.
191
+ """
192
+ return self._retry_succeeded
193
+
194
+ def get_retry_info(self) -> dict[str, Any]:
195
+ """
196
+ Returns a dictionary with all retry-related information.
197
+ """
198
+ return {
199
+ "retry_attempts": self._retry_attempts,
200
+ "max_retries": self._max_retries,
201
+ "was_retried": self.was_retried,
202
+ "retry_succeeded": self._retry_succeeded,
203
+ }
49
204
 
50
205
 
51
- def add_op(state, op_func, *args, **kwargs):
52
- '''
206
+ def add_op(state: State, op_func, *args, **kwargs):
207
+ """
53
208
  Prepare & add an operation to ``pyinfra.state`` by executing it on all hosts.
54
209
 
55
210
  Args:
@@ -57,198 +212,96 @@ def add_op(state, op_func, *args, **kwargs):
57
212
  to op_func (function): the operation function from one of the modules,
58
213
  ie ``server.user``
59
214
  args/kwargs: passed to the operation function
60
- '''
215
+ """
61
216
 
62
- frameinfo = get_caller_frameinfo()
63
- kwargs['frameinfo'] = frameinfo
217
+ if pyinfra.is_cli:
218
+ raise PyinfraError(
219
+ ("`add_op` should not be called when pyinfra is executing in CLI mode! ({0})").format(
220
+ get_call_location(),
221
+ ),
222
+ )
64
223
 
65
- for host in state.inventory:
66
- op_func(state, host, *args, **kwargs)
224
+ hosts = kwargs.pop("host", state.inventory.iter_active_hosts())
225
+ if isinstance(hosts, Host):
226
+ hosts = [hosts]
67
227
 
228
+ with ctx_state.use(state):
229
+ results = {}
230
+ for op_host in hosts:
231
+ with ctx_host.use(op_host):
232
+ results[op_host] = op_func(*args, **kwargs)
68
233
 
69
- def _get_call_location():
70
- frames = stack()
234
+ return results
71
235
 
72
- # Frist two frames are this and the caller below, so get the third item on
73
- # the frame list, which should be the call to the actual operation.
74
- frame = getframeinfo(frames[2][0])
75
236
 
76
- return 'line {0} in {1}'.format(
77
- frame.lineno,
78
- path.relpath(frame.filename),
79
- )
237
+ P = ParamSpec("P")
80
238
 
81
239
 
82
- def operation(func=None, pipeline_facts=None):
83
- '''
240
+ def operation(
241
+ is_idempotent: bool = True,
242
+ idempotent_notice: Optional[str] = None,
243
+ is_deprecated: bool = False,
244
+ deprecated_for: Optional[str] = None,
245
+ _set_in_op: bool = True,
246
+ ) -> Callable[[Callable[P, Generator]], PyinfraOperation[P]]:
247
+ """
84
248
  Decorator that takes a simple module function and turn it into the internal
85
249
  operation representation that consists of a list of commands + options
86
250
  (sudo, (sudo|su)_user, env).
87
- '''
251
+ """
88
252
 
89
- # If not decorating, return function with config attached
90
- if func is None:
91
- def decorator(f):
92
- setattr(f, 'pipeline_facts', pipeline_facts)
93
- return operation(f)
253
+ def decorator(f: Callable[P, Generator]) -> PyinfraOperation[P]:
254
+ f.is_idempotent = is_idempotent # type: ignore[attr-defined]
255
+ f.idempotent_notice = idempotent_notice # type: ignore[attr-defined]
256
+ f.is_deprecated = is_deprecated # type: ignore[attr-defined]
257
+ f.deprecated_for = deprecated_for # type: ignore[attr-defined]
258
+ return _wrap_operation(f, _set_in_op=_set_in_op)
94
259
 
95
- return decorator
260
+ return decorator
96
261
 
97
- # Index the operation!
98
- module_bits = func.__module__.split('.')
99
- module_name = module_bits[-1]
100
- op_name = '.'.join((module_name, func.__name__))
101
- OPERATIONS.append(op_name)
102
262
 
103
- # Actually decorate!
263
+ def _wrap_operation(func: Callable[P, Generator], _set_in_op: bool = True) -> PyinfraOperation[P]:
104
264
  @wraps(func)
105
- def decorated_func(*args, **kwargs):
106
- # Prepare state/host
107
- #
108
-
109
- # If we're in CLI mode, there's no state/host passed down, we need to
110
- # use the global "pseudo" modules.
111
- if len(args) < 2 or not (
112
- isinstance(args[0], (State, PseudoModule))
113
- and isinstance(args[1], (Host, PseudoModule))
114
- ):
115
- state = pseudo_state._module
116
- host = pseudo_host._module
117
-
118
- if state.in_op:
119
- raise PyinfraError((
120
- 'Nested operation called without state/host: {0} ({1})'
121
- ).format(op_name, _get_call_location()))
122
-
123
- if state.in_deploy:
124
- raise PyinfraError((
125
- 'Nested deploy operation called without state/host: {0} ({1})'
126
- ).format(op_name, _get_call_location()))
127
-
128
- # Otherwise (API mode) we just trim off the commands
129
- else:
130
- args_copy = list(args)
131
- state, host = args[0], args[1]
132
- args = args_copy[2:]
133
-
134
- # In API mode we have the kwarg - if a nested operation call we have
135
- # current_frameinfo.
136
- frameinfo = kwargs.pop('frameinfo', get_caller_frameinfo())
265
+ def decorated_func(*args: P.args, **kwargs: P.kwargs) -> OperationMeta:
266
+ state = context.state
267
+ host = context.host
268
+
269
+ if state.current_stage < StateStage.Prepare or state.current_stage > StateStage.Execute:
270
+ raise Exception("Cannot call operations outside of Prepare/Execute stages")
271
+
272
+ if host.in_op:
273
+ raise Exception(
274
+ "Operation called within another operation, this is not allowed! Use the `_inner` "
275
+ + "function to call the underlying operation."
276
+ )
277
+
278
+ if func.is_deprecated: # type: ignore[attr-defined]
279
+ if func.deprecated_for: # type: ignore[attr-defined]
280
+ logger.warning(
281
+ f"The {get_operation_name_from_func(func)} operation is "
282
+ + f"deprecated, please use: {func.deprecated_for}", # type: ignore[attr-defined] # noqa
283
+ )
284
+ else:
285
+ logger.warning(f"The {get_operation_name_from_func(func)} operation is deprecated")
137
286
 
138
287
  # Configure operation
139
288
  #
140
-
141
- # Name the operation
142
- names = None
143
- autoname = False
144
-
145
- # Look for a set as the first argument
146
- if len(args) > 0 and isinstance(args[0], set):
147
- names = args[0]
148
- args_copy = list(args)
149
- args = args[1:]
150
-
151
- # Generate an operation name if needed (Module/Operation format)
152
- else:
153
- autoname = True
154
- module_bits = func.__module__.split('.')
155
- module_name = module_bits[-1]
156
- names = {
157
- '{0}/{1}'.format(module_name.title(), func.__name__.title()),
158
- }
159
-
160
- if state.deploy_name:
161
- names = {
162
- '{0} | {1}'.format(state.deploy_name, name)
163
- for name in names
164
- }
165
-
166
289
  # Get the meta kwargs (globals that apply to all hosts)
167
- op_meta_kwargs = pop_op_kwargs(state, kwargs)
168
-
169
- # If this op is being called inside another, just return here
170
- # (any unwanted/op-related kwargs removed above).
171
- if state.in_op:
172
- return func(state, host, *args, **kwargs) or []
173
-
174
- line_number = frameinfo.lineno
175
-
176
- # Inject the current op file number (only incremented in CLI mode)
177
- op_lines = [state.current_op_file]
178
-
179
- # Add any current @deploy line numbers
180
- if state.deploy_line_numbers:
181
- op_lines.extend(state.deploy_line_numbers)
182
-
183
- # Add any current loop count
184
- if state.loop_line:
185
- op_lines.extend([state.loop_line, state.loop_counter])
186
-
187
- # Add the line number that called this operation
188
- op_lines.append(line_number)
189
-
190
- # Make a hash from the call stack lines
191
- op_hash = make_hash(op_lines)
192
-
193
- # Avoid adding duplicates! This happens if an operation is called within
194
- # a loop - such that the filename/lineno/code _are_ the same, but the
195
- # arguments might be different. We just append an increasing number to
196
- # the op hash and also handle below with the op order.
197
- host_op_hashes = state.meta[host]['op_hashes']
198
- duplicate_op_count = 0
199
- while op_hash in host_op_hashes:
200
- logger.debug('Duplicate hash ({0}) detected!'.format(op_hash))
201
- op_hash = '{0}-{1}'.format(op_hash, duplicate_op_count)
202
- duplicate_op_count += 1
203
-
204
- host_op_hashes.add(op_hash)
205
-
206
- if duplicate_op_count:
207
- op_lines.append(duplicate_op_count)
208
-
209
- op_lines = tuple(op_lines)
210
- state.op_line_numbers_to_hash[op_lines] = op_hash
211
- logger.debug('Adding operation, {0}, called @ {1}:{2}, opLines={3}, opHash={4}'.format(
212
- names, frameinfo.filename, line_number, op_lines, op_hash,
213
- ))
214
-
215
- # Ensure shared (between servers) operation meta
216
- op_meta = state.op_meta.setdefault(op_hash, {
217
- 'names': set(),
218
- 'args': [],
219
- })
220
-
221
- # Add any meta kwargs (sudo, etc) to the meta - first parse any strings
222
- # as jinja templates.
223
- actual_op_meta_kwargs = {
224
- key: get_arg_value(state, host, a)
225
- for key, a in op_meta_kwargs.items()
226
- }
227
- op_meta.update(actual_op_meta_kwargs)
228
-
229
- # Add any new names to the set
230
- op_meta['names'].update(names)
290
+ global_arguments, global_argument_keys = pop_global_arguments(state, host, kwargs)
231
291
 
232
- # Attach normal args, if we're auto-naming this operation
233
- if autoname:
234
- for arg in args:
235
- if isinstance(arg, FunctionType):
236
- arg = arg.__name__
292
+ names, add_args = generate_operation_name(func, host, kwargs, global_arguments)
293
+ op_order, op_hash = solve_operation_consistency(names, state, host)
237
294
 
238
- if arg not in op_meta['args']:
239
- op_meta['args'].append(arg)
295
+ # Ensure shared (between servers) operation meta, mutates state
296
+ op_meta = ensure_shared_op_meta(state, op_hash, op_order, global_arguments, names)
240
297
 
241
- # Attach keyword args
242
- for key, value in kwargs.items():
243
- arg = '='.join((str(key), str(value)))
244
- if arg not in op_meta['args']:
245
- op_meta['args'].append(arg)
298
+ # Attach normal args, if we're auto-naming this operation
299
+ if add_args:
300
+ op_meta = attach_args(op_meta, args, kwargs)
246
301
 
247
302
  # Check if we're actually running the operation on this host
248
- #
249
-
250
303
  # Run once and we've already added meta for this op? Stop here.
251
- if op_meta_kwargs['run_once']:
304
+ if op_meta.global_arguments["_run_once"]:
252
305
  has_run = False
253
306
  for ops in state.ops.values():
254
307
  if op_hash in ops:
@@ -256,71 +309,188 @@ def operation(func=None, pipeline_facts=None):
256
309
  break
257
310
 
258
311
  if has_run:
259
- return OperationMeta(op_hash)
260
-
261
- # If we're limited, stop here - *after* we've created op_meta. This
262
- # ensures the meta object always exists, even if no hosts actually ever
263
- # execute the op (due to limit or otherwise).
264
- hosts = op_meta_kwargs['hosts']
265
- when = op_meta_kwargs['when']
266
-
267
- if (
268
- # Limited by the state's limit_hosts?
269
- (state.limit_hosts is not None and host not in state.limit_hosts)
270
- # Limited by the operation kwarg hosts?
271
- or (hosts is not None and host not in hosts)
272
- # Limited by the operation kwarg when? We check == because when is
273
- # normally attribute wrapped as a AttrDataBool, which is actually
274
- # an integer (Python doesn't allow subclassing bool!).
275
- or when == False # noqa
276
- ):
277
- return OperationMeta(op_hash)
278
-
279
- # "Run" operation
280
- #
312
+ return OperationMeta(op_hash, is_change=False)
313
+
314
+ # Grab a reference to any *current* deploy data as this may change when
315
+ # we later evaluate the operation at runtime.This means we put back the
316
+ # expected deploy data.
317
+ current_deploy_data = host.current_deploy_data
318
+
319
+ # "Run" operation - here we make a generator that will yield out actual commands to execute
320
+ # and, if we're diff-ing, we then iterate the generator now to determine if any changes
321
+ # *would* be made based on the *current* remote state.
322
+
323
+ def command_generator() -> Iterator[PyinfraCommand]:
324
+ # Check global _if argument function and do nothing if returns False
325
+ if state.is_executing:
326
+ _ifs = global_arguments.get("_if")
327
+ if isinstance(_ifs, list) and not all(_if() for _if in _ifs):
328
+ return
329
+ elif callable(_ifs) and not _ifs():
330
+ return
331
+
332
+ host.in_op = _set_in_op
333
+ host.current_op_hash = op_hash
334
+ host.current_op_global_arguments = global_arguments
335
+ host.current_op_deploy_data = current_deploy_data
336
+
337
+ try:
338
+ for command in func(*args, **kwargs):
339
+ if isinstance(command, str):
340
+ command = StringCommand(command.strip())
341
+ yield command
342
+ finally:
343
+ host.in_op = False
344
+ host.current_op_hash = None
345
+ host.current_op_global_arguments = None
346
+ host.current_op_deploy_data = None
347
+
348
+ op_is_change = None
349
+ if state.should_check_for_changes():
350
+ op_is_change = False
351
+ for _ in command_generator():
352
+ op_is_change = True
353
+ break
354
+ else:
355
+ # If not calling the op function to check for change we still want to ensure the args
356
+ # are valid, so use Signature.bind to trigger any TypeError.
357
+ signature(func).bind(*args, **kwargs)
358
+
359
+ # Add host-specific operation data to state, this mutates state
360
+ host_meta = state.get_meta_for_host(host)
361
+ host_meta.ops += 1
362
+ if op_is_change:
363
+ host_meta.ops_change += 1
364
+ else:
365
+ host_meta.ops_no_change += 1
281
366
 
282
- # Otherwise, flag as in-op and run it to get the commands
283
- state.in_op = True
284
- state.current_op_hash = op_hash
285
-
286
- # Generate actual arguments by parsing strings as jinja2 templates. This
287
- # means you can string format arguments w/o generating multiple
288
- # operations. Only affects top level operations, as must be run "in_op"
289
- # so facts are gathered correctly.
290
- actual_args = [
291
- get_arg_value(state, host, a)
292
- for a in args
293
- ]
294
-
295
- actual_kwargs = {
296
- key: get_arg_value(state, host, a)
297
- for key, a in kwargs.items()
298
- }
367
+ operation_meta = OperationMeta(op_hash, op_is_change)
299
368
 
300
- # Convert to list as the result may be a generator
301
- commands = unroll_generators(func(
302
- state, host,
303
- *actual_args,
304
- **actual_kwargs
305
- ))
369
+ # Add the server-relevant commands
370
+ op_data = StateOperationHostData(command_generator, global_arguments, operation_meta)
371
+ state.set_op_data_for_host(host, op_hash, op_data)
306
372
 
307
- state.in_op = False
308
- state.current_op_hash = None
373
+ # If we're already in the execution phase, execute this operation immediately
374
+ if state.is_executing:
375
+ execute_immediately(state, host, op_hash)
309
376
 
310
- # Add host-specific operation data to state
311
- #
377
+ # Return result meta for use in deploy scripts
378
+ return operation_meta
379
+
380
+ decorated_func._inner = func # type: ignore[attr-defined]
381
+ return cast(PyinfraOperation[P], decorated_func)
312
382
 
313
- # We're doing some commands, meta/ops++
314
- state.meta[host]['ops'] += 1
315
- state.meta[host]['commands'] += len(commands)
316
383
 
317
- # Add the server-relevant commands
318
- state.ops[host][op_hash] = {
319
- 'commands': commands,
320
- }
384
+ def get_operation_name_from_func(func):
385
+ if func.__module__:
386
+ module_bits = func.__module__.split(".")
387
+ module_name = module_bits[-1]
388
+ return "{0}.{1}".format(module_name, func.__name__)
389
+ else:
390
+ return func.__name__
391
+
392
+
393
+ def generate_operation_name(func, host, kwargs, global_arguments):
394
+ # Generate an operation name if needed (Module/Operation format)
395
+ name = global_arguments.get("name")
396
+ add_args = False
397
+ if name:
398
+ names = {name}
399
+ else:
400
+ add_args = True
401
+ name = get_operation_name_from_func(func)
402
+ names = {name}
403
+
404
+ if host.current_deploy_name:
405
+ names = {"{0} | {1}".format(host.current_deploy_name, name) for name in names}
406
+
407
+ return names, add_args
408
+
409
+
410
+ def solve_operation_consistency(names, state, host):
411
+ # Operation order is used to tie-break available nodes in the operation DAG, in CLI mode
412
+ # we use stack call order so this matches as defined by the user deploy code.
413
+ if pyinfra.is_cli:
414
+ op_order = get_operation_order_from_stack(state)
415
+ # In API mode we just increase the order for each host
416
+ else:
417
+ op_order = [len(host.op_hash_order)]
418
+
419
+ if host.loop_position:
420
+ op_order.extend(host.loop_position)
421
+
422
+ # Make a hash from the call stack lines
423
+ op_hash = make_hash(op_order)
424
+
425
+ # Avoid adding duplicates! This happens if an operation is called within
426
+ # a loop - such that the filename/lineno/code _are_ the same, but the
427
+ # arguments might be different. We just append an increasing number to
428
+ # the op hash and also handle below with the op order.
429
+ duplicate_op_count = 0
430
+ while op_hash in host.op_hash_order:
431
+ logger.debug("Duplicate hash ({0}) detected!".format(op_hash))
432
+ op_hash = "{0}-{1}".format(op_hash, duplicate_op_count)
433
+ duplicate_op_count += 1
434
+
435
+ host.op_hash_order.append(op_hash)
436
+ if duplicate_op_count:
437
+ op_order.append(duplicate_op_count)
438
+
439
+ op_order = tuple(op_order)
440
+ logger.debug(f"Adding operation, {names}, opOrder={op_order}, opHash={op_hash}")
441
+ return op_order, op_hash
442
+
443
+
444
+ # NOTE: this function mutates state.op_meta for this hash
445
+ def ensure_shared_op_meta(
446
+ state: State,
447
+ op_hash: str,
448
+ op_order: tuple[int, ...],
449
+ global_arguments: AllArguments,
450
+ names: set[str],
451
+ ):
452
+ op_meta = state.op_meta.setdefault(op_hash, StateOperationMeta(op_order))
453
+
454
+ for key in EXECUTION_KWARG_KEYS:
455
+ global_value = global_arguments.pop(key) # type: ignore[misc]
456
+ op_meta_value = op_meta.global_arguments.get(key, op_meta_default)
457
+
458
+ if op_meta_value is not op_meta_default and global_value != op_meta_value:
459
+ raise OperationValueError("Cannot have different values for `{0}`.".format(key))
460
+
461
+ op_meta.global_arguments[key] = global_value # type: ignore[literal-required]
462
+
463
+ # Add any new names to the set
464
+ op_meta.names.update(names)
465
+
466
+ return op_meta
467
+
468
+
469
+ def execute_immediately(state, host, op_hash):
470
+ op_meta = state.get_op_meta(op_hash)
471
+ op_data = state.get_op_data_for_host(host, op_hash)
472
+ op_data.parent_op_hash = host.executing_op_hash
473
+ log_operation_start(op_meta, op_types=["nested"], prefix="")
474
+ run_host_op(state, host, op_hash)
321
475
 
322
- # Return result meta for use in deploy scripts
323
- return OperationMeta(op_hash, commands)
324
476
 
325
- decorated_func._pyinfra_op = func
326
- return decorated_func
477
+ def _get_arg_value(arg):
478
+ if isinstance(arg, FunctionType):
479
+ return arg.__name__
480
+ if isinstance(arg, StringIO):
481
+ return f"StringIO(hash={get_file_sha1(arg)})"
482
+ return arg
483
+
484
+
485
+ def attach_args(op_meta, args, kwargs):
486
+ for arg in args:
487
+ if arg not in op_meta.args:
488
+ op_meta.args.append(str(_get_arg_value(arg)))
489
+
490
+ # Attach keyword args
491
+ for key, value in kwargs.items():
492
+ arg = "=".join((str(key), str(_get_arg_value(value))))
493
+ if arg not in op_meta.args:
494
+ op_meta.args.append(arg)
495
+
496
+ return op_meta