ansible-core 2.19.0b1__py3-none-any.whl → 2.19.0b3__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 (92) hide show
  1. ansible/_internal/_ansiballz.py +1 -4
  2. ansible/_internal/_collection_proxy.py +47 -0
  3. ansible/_internal/_errors/_handler.py +4 -4
  4. ansible/_internal/_json/__init__.py +47 -4
  5. ansible/_internal/_json/_profiles/_legacy.py +2 -3
  6. ansible/_internal/_templating/_datatag.py +3 -4
  7. ansible/_internal/_templating/_engine.py +6 -1
  8. ansible/_internal/_templating/_jinja_bits.py +4 -4
  9. ansible/_internal/_templating/_jinja_plugins.py +7 -17
  10. ansible/cli/__init__.py +12 -5
  11. ansible/cli/arguments/option_helpers.py +4 -1
  12. ansible/cli/doc.py +14 -8
  13. ansible/config/base.yml +17 -20
  14. ansible/config/manager.py +2 -2
  15. ansible/constants.py +0 -62
  16. ansible/errors/__init__.py +6 -2
  17. ansible/executor/module_common.py +11 -7
  18. ansible/executor/process/worker.py +31 -26
  19. ansible/executor/task_executor.py +38 -31
  20. ansible/executor/task_queue_manager.py +62 -52
  21. ansible/executor/task_result.py +168 -72
  22. ansible/galaxy/api.py +1 -1
  23. ansible/galaxy/collection/__init__.py +3 -3
  24. ansible/inventory/manager.py +2 -1
  25. ansible/module_utils/_internal/_ansiballz.py +4 -30
  26. ansible/module_utils/_internal/_datatag/_tags.py +3 -25
  27. ansible/module_utils/_internal/_deprecator.py +134 -0
  28. ansible/module_utils/_internal/_plugin_info.py +25 -0
  29. ansible/module_utils/_internal/_validation.py +14 -0
  30. ansible/module_utils/ansible_release.py +1 -1
  31. ansible/module_utils/basic.py +68 -23
  32. ansible/module_utils/common/arg_spec.py +8 -3
  33. ansible/module_utils/common/messages.py +40 -23
  34. ansible/module_utils/common/process.py +0 -1
  35. ansible/module_utils/common/respawn.py +0 -7
  36. ansible/module_utils/common/warnings.py +13 -13
  37. ansible/module_utils/datatag.py +13 -13
  38. ansible/modules/async_status.py +1 -1
  39. ansible/modules/dnf5.py +1 -1
  40. ansible/modules/get_url.py +1 -1
  41. ansible/parsing/utils/jsonify.py +40 -0
  42. ansible/parsing/yaml/objects.py +16 -5
  43. ansible/playbook/included_file.py +25 -12
  44. ansible/playbook/task.py +0 -2
  45. ansible/plugins/__init__.py +18 -8
  46. ansible/plugins/action/__init__.py +6 -14
  47. ansible/plugins/action/gather_facts.py +2 -4
  48. ansible/plugins/callback/__init__.py +173 -86
  49. ansible/plugins/callback/default.py +79 -79
  50. ansible/plugins/callback/junit.py +20 -19
  51. ansible/plugins/callback/minimal.py +17 -17
  52. ansible/plugins/callback/oneline.py +23 -16
  53. ansible/plugins/callback/tree.py +13 -6
  54. ansible/plugins/connection/local.py +1 -1
  55. ansible/plugins/connection/paramiko_ssh.py +9 -2
  56. ansible/plugins/doc_fragments/action_core.py +1 -1
  57. ansible/plugins/filter/core.py +12 -2
  58. ansible/plugins/inventory/__init__.py +2 -2
  59. ansible/plugins/loader.py +194 -130
  60. ansible/plugins/lookup/url.py +2 -2
  61. ansible/plugins/strategy/__init__.py +76 -82
  62. ansible/plugins/strategy/free.py +4 -4
  63. ansible/plugins/strategy/linear.py +11 -9
  64. ansible/plugins/test/core.py +1 -1
  65. ansible/release.py +1 -1
  66. ansible/template/__init__.py +8 -6
  67. ansible/utils/collection_loader/_collection_meta.py +5 -3
  68. ansible/utils/display.py +141 -79
  69. ansible/utils/py3compat.py +1 -7
  70. ansible/utils/ssh_functions.py +4 -1
  71. ansible/utils/vars.py +23 -0
  72. ansible/vars/clean.py +1 -1
  73. ansible/vars/manager.py +18 -27
  74. ansible/vars/plugins.py +4 -4
  75. {ansible_core-2.19.0b1.dist-info → ansible_core-2.19.0b3.dist-info}/METADATA +1 -1
  76. {ansible_core-2.19.0b1.dist-info → ansible_core-2.19.0b3.dist-info}/RECORD +89 -85
  77. ansible_test/_internal/commands/sanity/pylint.py +1 -0
  78. ansible_test/_internal/docker_util.py +4 -3
  79. ansible_test/_util/controller/sanity/pylint/plugins/deprecated_calls.py +475 -0
  80. ansible_test/_util/controller/sanity/pylint/plugins/deprecated_comment.py +137 -0
  81. ansible/module_utils/_internal/_dataclass_annotation_patch.py +0 -64
  82. ansible/module_utils/_internal/_plugin_exec_context.py +0 -49
  83. ansible_test/_util/controller/sanity/pylint/plugins/deprecated.py +0 -399
  84. {ansible_core-2.19.0b1.dist-info → ansible_core-2.19.0b3.dist-info}/Apache-License.txt +0 -0
  85. {ansible_core-2.19.0b1.dist-info → ansible_core-2.19.0b3.dist-info}/BSD-3-Clause.txt +0 -0
  86. {ansible_core-2.19.0b1.dist-info → ansible_core-2.19.0b3.dist-info}/COPYING +0 -0
  87. {ansible_core-2.19.0b1.dist-info → ansible_core-2.19.0b3.dist-info}/MIT-license.txt +0 -0
  88. {ansible_core-2.19.0b1.dist-info → ansible_core-2.19.0b3.dist-info}/PSF-license.txt +0 -0
  89. {ansible_core-2.19.0b1.dist-info → ansible_core-2.19.0b3.dist-info}/WHEEL +0 -0
  90. {ansible_core-2.19.0b1.dist-info → ansible_core-2.19.0b3.dist-info}/entry_points.txt +0 -0
  91. {ansible_core-2.19.0b1.dist-info → ansible_core-2.19.0b3.dist-info}/simplified_bsd.txt +0 -0
  92. {ansible_core-2.19.0b1.dist-info → ansible_core-2.19.0b3.dist-info}/top_level.txt +0 -0
@@ -4,15 +4,24 @@
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ import collections.abc as _c
8
+ import dataclasses
9
+ import functools
7
10
  import typing as t
8
11
 
9
- from ansible import constants as C
10
- from ansible.parsing.dataloader import DataLoader
12
+ from ansible import constants
13
+ from ansible.utils import vars as _vars
11
14
  from ansible.vars.clean import module_response_deepcopy, strip_internal_keys
15
+ from ansible.module_utils.common import messages as _messages
16
+ from ansible._internal import _collection_proxy
17
+
18
+ if t.TYPE_CHECKING:
19
+ from ansible.inventory.host import Host
20
+ from ansible.playbook.task import Task
12
21
 
13
22
  _IGNORE = ('failed', 'skipped')
14
- _PRESERVE = ('attempts', 'changed', 'retries', '_ansible_no_log')
15
- _SUB_PRESERVE = {'_ansible_delegated_vars': ('ansible_host', 'ansible_port', 'ansible_user', 'ansible_connection')}
23
+ _PRESERVE = {'attempts', 'changed', 'retries', '_ansible_no_log'}
24
+ _SUB_PRESERVE = {'_ansible_delegated_vars': {'ansible_host', 'ansible_port', 'ansible_user', 'ansible_connection'}}
16
25
 
17
26
  # stuff callbacks need
18
27
  CLEAN_EXCEPTIONS = (
@@ -23,61 +32,120 @@ CLEAN_EXCEPTIONS = (
23
32
  )
24
33
 
25
34
 
26
- class TaskResult:
35
+ @t.final
36
+ @dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
37
+ class _WireTaskResult:
38
+ """A thin version of `_RawTaskResult` which can be sent over the worker queue."""
39
+
40
+ host_name: str
41
+ task_uuid: str
42
+ return_data: _c.MutableMapping[str, object]
43
+ task_fields: _c.Mapping[str, object]
44
+
45
+
46
+ class _BaseTaskResult:
27
47
  """
28
48
  This class is responsible for interpreting the resulting data
29
49
  from an executed task, and provides helper methods for determining
30
50
  the result of a given task.
31
51
  """
32
52
 
33
- def __init__(self, host, task, return_data, task_fields=None):
34
- self._host = host
35
- self._task = task
53
+ def __init__(self, host: Host, task: Task, return_data: _c.MutableMapping[str, t.Any], task_fields: _c.Mapping[str, t.Any]) -> None:
54
+ self.__host = host
55
+ self.__task = task
56
+ self._return_data = return_data # FIXME: this should be immutable, but strategy result processing mutates it in some corner cases
57
+ self.__task_fields = task_fields
36
58
 
37
- if isinstance(return_data, dict):
38
- self._result = return_data.copy()
39
- else:
40
- self._result = DataLoader().load(return_data)
59
+ @property
60
+ def host(self) -> Host:
61
+ """The host associated with this result."""
62
+ return self.__host
41
63
 
42
- if task_fields is None:
43
- self._task_fields = dict()
44
- else:
45
- self._task_fields = task_fields
64
+ @property
65
+ def _host(self) -> Host:
66
+ """Use the `host` property when supporting only ansible-core 2.19 or later."""
67
+ # deprecated: description='Deprecate `_host` in favor of `host`' core_version='2.23'
68
+ return self.__host
69
+
70
+ @property
71
+ def task(self) -> Task:
72
+ """The task associated with this result."""
73
+ return self.__task
74
+
75
+ @property
76
+ def _task(self) -> Task:
77
+ """Use the `task` property when supporting only ansible-core 2.19 or later."""
78
+ # deprecated: description='Deprecate `_task` in favor of `task`' core_version='2.23'
79
+ return self.__task
80
+
81
+ @property
82
+ def task_fields(self) -> _c.Mapping[str, t.Any]:
83
+ """The task fields associated with this result."""
84
+ return self.__task_fields
85
+
86
+ @property
87
+ def _task_fields(self) -> _c.Mapping[str, t.Any]:
88
+ """Use the `task_fields` property when supporting only ansible-core 2.19 or later."""
89
+ # deprecated: description='Deprecate `_task_fields` in favor of `task`' core_version='2.23'
90
+ return self.__task_fields
91
+
92
+ @property
93
+ def exception(self) -> _messages.ErrorSummary | None:
94
+ """The error from this task result, if any."""
95
+ return self._return_data.get('exception')
96
+
97
+ @property
98
+ def warnings(self) -> _c.Sequence[_messages.WarningSummary]:
99
+ """The warnings for this task, if any."""
100
+ return _collection_proxy.SequenceProxy(self._return_data.get('warnings') or [])
101
+
102
+ @property
103
+ def deprecations(self) -> _c.Sequence[_messages.DeprecationSummary]:
104
+ """The deprecation warnings for this task, if any."""
105
+ return _collection_proxy.SequenceProxy(self._return_data.get('deprecations') or [])
106
+
107
+ @property
108
+ def _loop_results(self) -> list[_c.MutableMapping[str, t.Any]]:
109
+ """Return a list of loop results. If no loop results are present, an empty list is returned."""
110
+ results = self._return_data.get('results')
111
+
112
+ if not isinstance(results, list):
113
+ return []
114
+
115
+ return results
46
116
 
47
117
  @property
48
- def task_name(self):
49
- return self._task_fields.get('name', None) or self._task.get_name()
118
+ def task_name(self) -> str:
119
+ return str(self.task_fields.get('name', '')) or self.task.get_name()
50
120
 
51
- def is_changed(self):
121
+ def is_changed(self) -> bool:
52
122
  return self._check_key('changed')
53
123
 
54
- def is_skipped(self):
55
- # loop results
56
- if 'results' in self._result:
57
- results = self._result['results']
124
+ def is_skipped(self) -> bool:
125
+ if self._loop_results:
58
126
  # Loop tasks are only considered skipped if all items were skipped.
59
127
  # some squashed results (eg, dnf) are not dicts and can't be skipped individually
60
- if results and all(isinstance(res, dict) and res.get('skipped', False) for res in results):
128
+ if all(isinstance(loop_res, dict) and loop_res.get('skipped', False) for loop_res in self._loop_results):
61
129
  return True
62
130
 
63
131
  # regular tasks and squashed non-dict results
64
- return self._result.get('skipped', False)
132
+ return bool(self._return_data.get('skipped', False))
65
133
 
66
- def is_failed(self):
67
- if 'failed_when_result' in self._result or \
68
- 'results' in self._result and True in [True for x in self._result['results'] if 'failed_when_result' in x]:
134
+ def is_failed(self) -> bool:
135
+ if 'failed_when_result' in self._return_data or any(isinstance(loop_res, dict) and 'failed_when_result' in loop_res for loop_res in self._loop_results):
69
136
  return self._check_key('failed_when_result')
70
- else:
71
- return self._check_key('failed')
72
137
 
73
- def is_unreachable(self):
138
+ return self._check_key('failed')
139
+
140
+ def is_unreachable(self) -> bool:
74
141
  return self._check_key('unreachable')
75
142
 
76
- def needs_debugger(self, globally_enabled=False):
77
- _debugger = self._task_fields.get('debugger')
78
- _ignore_errors = C.TASK_DEBUGGER_IGNORE_ERRORS and self._task_fields.get('ignore_errors')
143
+ def needs_debugger(self, globally_enabled: bool = False) -> bool:
144
+ _debugger = self.task_fields.get('debugger')
145
+ _ignore_errors = constants.TASK_DEBUGGER_IGNORE_ERRORS and self.task_fields.get('ignore_errors')
79
146
 
80
147
  ret = False
148
+
81
149
  if globally_enabled and ((self.is_failed() and not _ignore_errors) or self.is_unreachable()):
82
150
  ret = True
83
151
 
@@ -94,68 +162,96 @@ class TaskResult:
94
162
 
95
163
  return ret
96
164
 
97
- def _check_key(self, key):
98
- """get a specific key from the result or its items"""
165
+ def _check_key(self, key: str) -> bool:
166
+ """Fetch a specific named boolean value from the result; if missing, a logical OR of the value from nested loop results; False for non-loop results."""
167
+ if (value := self._return_data.get(key, ...)) is not ...:
168
+ return bool(value)
99
169
 
100
- if isinstance(self._result, dict) and key in self._result:
101
- return self._result.get(key, False)
102
- else:
103
- flag = False
104
- for res in self._result.get('results', []):
105
- if isinstance(res, dict):
106
- flag |= res.get(key, False)
107
- return flag
170
+ return any(isinstance(result, dict) and result.get(key) for result in self._loop_results)
108
171
 
109
- def clean_copy(self):
110
172
 
111
- """ returns 'clean' taskresult object """
173
+ @t.final
174
+ class _RawTaskResult(_BaseTaskResult):
175
+ def as_wire_task_result(self) -> _WireTaskResult:
176
+ """Return a `_WireTaskResult` from this instance."""
177
+ return _WireTaskResult(
178
+ host_name=self.host.name,
179
+ task_uuid=self.task._uuid,
180
+ return_data=self._return_data,
181
+ task_fields=self.task_fields,
182
+ )
112
183
 
113
- # FIXME: clean task_fields, _task and _host copies
114
- result = TaskResult(self._host, self._task, {}, self._task_fields)
184
+ def as_callback_task_result(self) -> CallbackTaskResult:
185
+ """Return a `CallbackTaskResult` from this instance."""
186
+ ignore: tuple[str, ...]
115
187
 
116
188
  # statuses are already reflected on the event type
117
- if result._task and result._task.action in C._ACTION_DEBUG:
189
+ if self.task and self.task.action in constants._ACTION_DEBUG:
118
190
  # debug is verbose by default to display vars, no need to add invocation
119
191
  ignore = _IGNORE + ('invocation',)
120
192
  else:
121
193
  ignore = _IGNORE
122
194
 
123
- subset = {}
195
+ subset: dict[str, dict[str, object]] = {}
196
+
124
197
  # preserve subset for later
125
- for sub in _SUB_PRESERVE:
126
- if sub in self._result:
127
- subset[sub] = {}
128
- for key in _SUB_PRESERVE[sub]:
129
- if key in self._result[sub]:
130
- subset[sub][key] = self._result[sub][key]
198
+ for sub, sub_keys in _SUB_PRESERVE.items():
199
+ sub_data = self._return_data.get(sub)
200
+
201
+ if isinstance(sub_data, dict):
202
+ subset[sub] = {key: value for key, value in sub_data.items() if key in sub_keys}
131
203
 
132
204
  # DTFIX-FUTURE: is checking no_log here redundant now that we use _ansible_no_log everywhere?
133
- if isinstance(self._task.no_log, bool) and self._task.no_log or self._result.get('_ansible_no_log'):
134
- censored_result = censor_result(self._result)
205
+ if isinstance(self.task.no_log, bool) and self.task.no_log or self._return_data.get('_ansible_no_log'):
206
+ censored_result = censor_result(self._return_data)
135
207
 
136
- if results := self._result.get('results'):
208
+ if self._loop_results:
137
209
  # maintain shape for loop results so callback behavior recognizes a loop was performed
138
- censored_result.update(results=[censor_result(item) if item.get('_ansible_no_log') else item for item in results])
210
+ censored_result.update(results=[
211
+ censor_result(loop_res) if isinstance(loop_res, dict) and loop_res.get('_ansible_no_log') else loop_res for loop_res in self._loop_results
212
+ ])
139
213
 
140
- result._result = censored_result
141
- elif self._result:
142
- result._result = module_response_deepcopy(self._result)
143
-
144
- # actually remove
145
- for remove_key in ignore:
146
- if remove_key in result._result:
147
- del result._result[remove_key]
214
+ return_data = censored_result
215
+ elif self._return_data:
216
+ return_data = {k: v for k, v in module_response_deepcopy(self._return_data).items() if k not in ignore}
148
217
 
149
218
  # remove almost ALL internal keys, keep ones relevant to callback
150
- strip_internal_keys(result._result, exceptions=CLEAN_EXCEPTIONS)
219
+ strip_internal_keys(return_data, exceptions=CLEAN_EXCEPTIONS)
220
+ else:
221
+ return_data = {}
151
222
 
152
223
  # keep subset
153
- result._result.update(subset)
224
+ return_data.update(subset)
225
+
226
+ return CallbackTaskResult(self.host, self.task, return_data, self.task_fields)
227
+
228
+
229
+ @t.final
230
+ class CallbackTaskResult(_BaseTaskResult):
231
+ """Public contract of TaskResult """
232
+
233
+ # DTFIX-RELEASE: find a better home for this since it's public API
234
+
235
+ @property
236
+ def _result(self) -> _c.MutableMapping[str, t.Any]:
237
+ """Use the `result` property when supporting only ansible-core 2.19 or later."""
238
+ # deprecated: description='Deprecate `_result` in favor of `result`' core_version='2.23'
239
+ return self.result
240
+
241
+ @functools.cached_property
242
+ def result(self) -> _c.MutableMapping[str, t.Any]:
243
+ """
244
+ Returns a cached copy of the task result dictionary for consumption by callbacks.
245
+ Internal custom types are transformed to native Python types to facilitate access and serialization.
246
+ """
247
+ return t.cast(_c.MutableMapping[str, t.Any], _vars.transform_to_native_types(self._return_data))
248
+
154
249
 
155
- return result
250
+ TaskResult = CallbackTaskResult
251
+ """Compatibility name for the pre-2.19 callback-shaped TaskResult passed to callbacks."""
156
252
 
157
253
 
158
- def censor_result(result: dict[str, t.Any]) -> dict[str, t.Any]:
254
+ def censor_result(result: _c.Mapping[str, t.Any]) -> dict[str, t.Any]:
159
255
  censored_result = {key: value for key in _PRESERVE if (value := result.get(key, ...)) is not ...}
160
256
  censored_result.update(censored="the output has been hidden due to the fact that 'no_log: true' was specified for this result")
161
257
 
ansible/galaxy/api.py CHANGED
@@ -138,7 +138,7 @@ def g_connect(versions):
138
138
  'The v2 Ansible Galaxy API is deprecated and no longer supported. '
139
139
  'Ensure that you have configured the ansible-galaxy CLI to utilize an '
140
140
  'updated and supported version of Ansible Galaxy.',
141
- version='2.20'
141
+ version='2.20',
142
142
  )
143
143
 
144
144
  return method(self, *args, **kwargs)
@@ -201,9 +201,9 @@ class CollectionSignatureError(Exception):
201
201
 
202
202
  # FUTURE: expose actual verify result details for a collection on this object, maybe reimplement as dataclass on py3.8+
203
203
  class CollectionVerifyResult:
204
- def __init__(self, collection_name): # type: (str) -> None
205
- self.collection_name = collection_name # type: str
206
- self.success = True # type: bool
204
+ def __init__(self, collection_name: str) -> None:
205
+ self.collection_name = collection_name
206
+ self.success = True
207
207
 
208
208
 
209
209
  def verify_local_collection(local_collection, remote_collection, artifacts_manager):
@@ -30,6 +30,7 @@ from random import shuffle
30
30
 
31
31
  from ansible import constants as C
32
32
  from ansible._internal import _json, _wrapt
33
+ from ansible._internal._json import EncryptedStringBehavior
33
34
  from ansible.errors import AnsibleError, AnsibleOptionsError
34
35
  from ansible.inventory.data import InventoryData
35
36
  from ansible.module_utils.six import string_types
@@ -787,7 +788,7 @@ class _InventoryDataWrapper(_wrapt.ObjectProxy):
787
788
  return _json.AnsibleVariableVisitor(
788
789
  trusted_as_template=self._target_plugin.trusted_by_default,
789
790
  origin=self._default_origin,
790
- allow_encrypted_string=True,
791
+ encrypted_string_behavior=EncryptedStringBehavior.PRESERVE,
791
792
  )
792
793
 
793
794
  def set_variable(self, entity: str, varname: str, value: t.Any) -> None:
@@ -6,7 +6,6 @@
6
6
  from __future__ import annotations
7
7
 
8
8
  import atexit
9
- import dataclasses
10
9
  import importlib.util
11
10
  import json
12
11
  import os
@@ -15,17 +14,14 @@ import sys
15
14
  import typing as t
16
15
 
17
16
  from . import _errors
18
- from ._plugin_exec_context import PluginExecContext, HasPluginInfo
19
17
  from .. import basic
20
18
  from ..common.json import get_module_encoder, Direction
21
- from ..common.messages import PluginInfo
22
19
 
23
20
 
24
21
  def run_module(
25
22
  *,
26
23
  json_params: bytes,
27
24
  profile: str,
28
- plugin_info_dict: dict[str, object],
29
25
  module_fqn: str,
30
26
  modlib_path: str,
31
27
  init_globals: dict[str, t.Any] | None = None,
@@ -38,7 +34,6 @@ def run_module(
38
34
  _run_module(
39
35
  json_params=json_params,
40
36
  profile=profile,
41
- plugin_info_dict=plugin_info_dict,
42
37
  module_fqn=module_fqn,
43
38
  modlib_path=modlib_path,
44
39
  init_globals=init_globals,
@@ -80,7 +75,6 @@ def _run_module(
80
75
  *,
81
76
  json_params: bytes,
82
77
  profile: str,
83
- plugin_info_dict: dict[str, object],
84
78
  module_fqn: str,
85
79
  modlib_path: str,
86
80
  init_globals: dict[str, t.Any] | None = None,
@@ -92,12 +86,11 @@ def _run_module(
92
86
  init_globals = init_globals or {}
93
87
  init_globals.update(_module_fqn=module_fqn, _modlib_path=modlib_path)
94
88
 
95
- with PluginExecContext(_ModulePluginWrapper(PluginInfo._from_dict(plugin_info_dict))):
96
- # Run the module. By importing it as '__main__', it executes as a script.
97
- runpy.run_module(mod_name=module_fqn, init_globals=init_globals, run_name='__main__', alter_sys=True)
89
+ # Run the module. By importing it as '__main__', it executes as a script.
90
+ runpy.run_module(mod_name=module_fqn, init_globals=init_globals, run_name='__main__', alter_sys=True)
98
91
 
99
- # An Ansible module must print its own results and exit. If execution reaches this point, that did not happen.
100
- raise RuntimeError('New-style module did not handle its own exit.')
92
+ # An Ansible module must print its own results and exit. If execution reaches this point, that did not happen.
93
+ raise RuntimeError('New-style module did not handle its own exit.')
101
94
 
102
95
 
103
96
  def _handle_exception(exception: BaseException, profile: str) -> t.NoReturn:
@@ -112,22 +105,3 @@ def _handle_exception(exception: BaseException, profile: str) -> t.NoReturn:
112
105
  print(json.dumps(result, cls=encoder)) # pylint: disable=ansible-bad-function
113
106
 
114
107
  sys.exit(1) # pylint: disable=ansible-bad-function
115
-
116
-
117
- @dataclasses.dataclass(frozen=True)
118
- class _ModulePluginWrapper(HasPluginInfo):
119
- """Modules aren't plugin instances; this adapter implements the `HasPluginInfo` protocol to allow `PluginExecContext` infra to work with modules."""
120
-
121
- plugin: PluginInfo
122
-
123
- @property
124
- def _load_name(self) -> str:
125
- return self.plugin.requested_name
126
-
127
- @property
128
- def ansible_name(self) -> str:
129
- return self.plugin.resolved_name
130
-
131
- @property
132
- def plugin_type(self) -> str:
133
- return self.plugin.type
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import dataclasses
4
- import datetime
5
4
  import typing as t
6
5
 
7
6
  from ansible.module_utils.common import messages as _messages
@@ -12,27 +11,6 @@ from ansible.module_utils._internal import _datatag
12
11
  class Deprecated(_datatag.AnsibleDatatagBase):
13
12
  msg: str
14
13
  help_text: t.Optional[str] = None
15
- removal_date: t.Optional[datetime.date] = None
16
- removal_version: t.Optional[str] = None
17
- plugin: t.Optional[_messages.PluginInfo] = None
18
-
19
- @classmethod
20
- def _from_dict(cls, d: t.Dict[str, t.Any]) -> Deprecated:
21
- source = d
22
- removal_date = source.get('removal_date')
23
-
24
- if removal_date is not None:
25
- source = source.copy()
26
- source['removal_date'] = datetime.date.fromisoformat(removal_date)
27
-
28
- return cls(**source)
29
-
30
- def _as_dict(self) -> t.Dict[str, t.Any]:
31
- # deprecated: description='no-args super() with slotted dataclass requires 3.14+' python_version='3.13'
32
- # see: https://github.com/python/cpython/pull/124455
33
- value = super(Deprecated, self)._as_dict()
34
-
35
- if self.removal_date is not None:
36
- value['removal_date'] = self.removal_date.isoformat()
37
-
38
- return value
14
+ date: t.Optional[str] = None
15
+ version: t.Optional[str] = None
16
+ deprecator: t.Optional[_messages.PluginInfo] = None
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import re
5
+ import pathlib
6
+ import sys
7
+ import typing as t
8
+
9
+ from ansible.module_utils.common.messages import PluginInfo
10
+
11
+ _ansible_module_base_path: t.Final = pathlib.Path(sys.modules['ansible'].__file__).parent
12
+ """Runtime-detected base path of the `ansible` Python package to distinguish between Ansible-owned and external code."""
13
+
14
+ ANSIBLE_CORE_DEPRECATOR: t.Final = PluginInfo._from_collection_name('ansible.builtin')
15
+ """Singleton `PluginInfo` instance for ansible-core callers where the plugin can/should not be identified in messages."""
16
+
17
+ INDETERMINATE_DEPRECATOR: t.Final = PluginInfo(resolved_name='indeterminate', type='indeterminate')
18
+ """Singleton `PluginInfo` instance for indeterminate deprecator."""
19
+
20
+ _DEPRECATOR_PLUGIN_TYPES = frozenset(
21
+ {
22
+ 'action',
23
+ 'become',
24
+ 'cache',
25
+ 'callback',
26
+ 'cliconf',
27
+ 'connection',
28
+ # doc_fragments - no code execution
29
+ # filter - basename inadequate to identify plugin
30
+ 'httpapi',
31
+ 'inventory',
32
+ 'lookup',
33
+ 'module', # only for collections
34
+ 'netconf',
35
+ 'shell',
36
+ 'strategy',
37
+ 'terminal',
38
+ # test - basename inadequate to identify plugin
39
+ 'vars',
40
+ }
41
+ )
42
+ """Plugin types which are valid for identifying a deprecator for deprecation purposes."""
43
+
44
+ _AMBIGUOUS_DEPRECATOR_PLUGIN_TYPES = frozenset(
45
+ {
46
+ 'filter',
47
+ 'test',
48
+ }
49
+ )
50
+ """Plugin types for which basename cannot be used to identify the plugin name."""
51
+
52
+
53
+ def get_best_deprecator(*, deprecator: PluginInfo | None = None, collection_name: str | None = None) -> PluginInfo:
54
+ """Return the best-available `PluginInfo` for the caller of this method."""
55
+ _skip_stackwalk = True
56
+
57
+ if deprecator and collection_name:
58
+ raise ValueError('Specify only one of `deprecator` or `collection_name`.')
59
+
60
+ return deprecator or PluginInfo._from_collection_name(collection_name) or get_caller_plugin_info() or INDETERMINATE_DEPRECATOR
61
+
62
+
63
+ def get_caller_plugin_info() -> PluginInfo | None:
64
+ """Try to get `PluginInfo` for the caller of this method, ignoring marked infrastructure stack frames."""
65
+ _skip_stackwalk = True
66
+
67
+ if frame_info := next((frame_info for frame_info in inspect.stack() if '_skip_stackwalk' not in frame_info.frame.f_locals), None):
68
+ return _path_as_core_plugininfo(frame_info.filename) or _path_as_collection_plugininfo(frame_info.filename)
69
+
70
+ return None # pragma: nocover
71
+
72
+
73
+ def _path_as_core_plugininfo(path: str) -> PluginInfo | None:
74
+ """Return a `PluginInfo` instance if the provided `path` refers to a core plugin."""
75
+ try:
76
+ relpath = str(pathlib.Path(path).relative_to(_ansible_module_base_path))
77
+ except ValueError:
78
+ return None # not ansible-core
79
+
80
+ namespace = 'ansible.builtin'
81
+
82
+ if match := re.match(r'plugins/(?P<plugin_type>\w+)/(?P<plugin_name>\w+)', relpath):
83
+ plugin_name = match.group("plugin_name")
84
+ plugin_type = match.group("plugin_type")
85
+
86
+ if plugin_type not in _DEPRECATOR_PLUGIN_TYPES:
87
+ # The plugin type isn't a known deprecator type, so we have to assume the caller is intermediate code.
88
+ # We have no way of knowing if the intermediate code is deprecating its own feature, or acting on behalf of another plugin.
89
+ # Callers in this case need to identify the deprecating plugin name, otherwise only ansible-core will be reported.
90
+ # Reporting ansible-core is never wrong, it just may be missing an additional detail (plugin name) in the "on behalf of" case.
91
+ return ANSIBLE_CORE_DEPRECATOR
92
+ elif match := re.match(r'modules/(?P<module_name>\w+)', relpath):
93
+ # AnsiballZ Python package for core modules
94
+ plugin_name = match.group("module_name")
95
+ plugin_type = "module"
96
+ elif match := re.match(r'legacy/(?P<module_name>\w+)', relpath):
97
+ # AnsiballZ Python package for non-core library/role modules
98
+ namespace = 'ansible.legacy'
99
+
100
+ plugin_name = match.group("module_name")
101
+ plugin_type = "module"
102
+ else:
103
+ return ANSIBLE_CORE_DEPRECATOR # non-plugin core path, safe to use ansible-core for the same reason as the non-deprecator plugin type case above
104
+
105
+ name = f'{namespace}.{plugin_name}'
106
+
107
+ return PluginInfo(resolved_name=name, type=plugin_type)
108
+
109
+
110
+ def _path_as_collection_plugininfo(path: str) -> PluginInfo | None:
111
+ """Return a `PluginInfo` instance if the provided `path` refers to a collection plugin."""
112
+ if not (match := re.search(r'/ansible_collections/(?P<ns>\w+)/(?P<coll>\w+)/plugins/(?P<plugin_type>\w+)/(?P<plugin_name>\w+)', path)):
113
+ return None
114
+
115
+ plugin_type = match.group('plugin_type')
116
+
117
+ if plugin_type in _AMBIGUOUS_DEPRECATOR_PLUGIN_TYPES:
118
+ # We're able to detect the namespace, collection and plugin type -- but we have no way to identify the plugin name currently.
119
+ # To keep things simple we'll fall back to just identifying the namespace and collection.
120
+ # In the future we could improve the detection and/or make it easier for a caller to identify the plugin name.
121
+ return PluginInfo._from_collection_name('.'.join((match.group('ns'), match.group('coll'))))
122
+
123
+ if plugin_type == 'modules':
124
+ plugin_type = 'module'
125
+
126
+ if plugin_type not in _DEPRECATOR_PLUGIN_TYPES:
127
+ # The plugin type isn't a known deprecator type, so we have to assume the caller is intermediate code.
128
+ # We have no way of knowing if the intermediate code is deprecating its own feature, or acting on behalf of another plugin.
129
+ # Callers in this case need to identify the deprecator to avoid ambiguity, since it could be the same collection or another collection.
130
+ return INDETERMINATE_DEPRECATOR
131
+
132
+ name = '.'.join((match.group('ns'), match.group('coll'), match.group('plugin_name')))
133
+
134
+ return PluginInfo(resolved_name=name, type=plugin_type)
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+
5
+ from ..common import messages as _messages
6
+
7
+
8
+ class HasPluginInfo(t.Protocol):
9
+ """Protocol to type-annotate and expose PluginLoader-set values."""
10
+
11
+ @property
12
+ def ansible_name(self) -> str | None:
13
+ """Fully resolved plugin name."""
14
+
15
+ @property
16
+ def plugin_type(self) -> str:
17
+ """Plugin type name."""
18
+
19
+
20
+ def get_plugin_info(value: HasPluginInfo) -> _messages.PluginInfo:
21
+ """Utility method that returns a `PluginInfo` from an object implementing the `HasPluginInfo` protocol."""
22
+ return _messages.PluginInfo(
23
+ resolved_name=value.ansible_name,
24
+ type=value.plugin_type,
25
+ )
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ import keyword
4
+
5
+
6
+ def validate_collection_name(collection_name: object, name: str = 'collection_name') -> None:
7
+ """Validate a collection name."""
8
+ if not isinstance(collection_name, str):
9
+ raise TypeError(f"{name} must be {str} instead of {type(collection_name)}")
10
+
11
+ parts = collection_name.split('.')
12
+
13
+ if len(parts) != 2 or not all(part.isidentifier() and not keyword.iskeyword(part) for part in parts):
14
+ raise ValueError(f"{name} must consist of two non-keyword identifiers separated by '.'")
@@ -17,6 +17,6 @@
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- __version__ = '2.19.0b1'
20
+ __version__ = '2.19.0b3'
21
21
  __author__ = 'Ansible, Inc.'
22
22
  __codename__ = "What Is and What Should Never Be"