ansible-core 2.19.0b3__py3-none-any.whl → 2.19.0b5__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 (174) hide show
  1. ansible/_internal/__init__.py +2 -2
  2. ansible/_internal/_collection_proxy.py +1 -1
  3. ansible/_internal/_errors/_alarm_timeout.py +66 -0
  4. ansible/_internal/_errors/_captured.py +25 -30
  5. ansible/_internal/_errors/_error_factory.py +89 -0
  6. ansible/_internal/_errors/_error_utils.py +240 -0
  7. ansible/_internal/_errors/_task_timeout.py +28 -0
  8. ansible/_internal/_event_formatting.py +127 -0
  9. ansible/_internal/_json/__init__.py +6 -6
  10. ansible/_internal/_json/_profiles/_cache_persistence.py +2 -0
  11. ansible/_internal/_json/_profiles/_inventory_legacy.py +1 -1
  12. ansible/_internal/_json/_profiles/_legacy.py +3 -11
  13. ansible/_internal/_ssh/__init__.py +0 -0
  14. ansible/_internal/_ssh/_agent_launch.py +91 -0
  15. ansible/{utils → _internal/_ssh}/_ssh_agent.py +55 -93
  16. ansible/_internal/_templating/__init__.py +5 -3
  17. ansible/_internal/_templating/_datatag.py +2 -1
  18. ansible/_internal/_templating/_engine.py +3 -4
  19. ansible/_internal/_templating/_jinja_bits.py +21 -16
  20. ansible/_internal/_templating/_jinja_common.py +18 -27
  21. ansible/_internal/_templating/_jinja_plugins.py +31 -3
  22. ansible/_internal/_templating/_lazy_containers.py +5 -5
  23. ansible/_internal/_templating/_transform.py +20 -19
  24. ansible/_internal/_templating/_utils.py +1 -1
  25. ansible/_internal/_testing.py +26 -0
  26. ansible/_internal/_yaml/_dumper.py +1 -1
  27. ansible/_internal/_yaml/_errors.py +7 -7
  28. ansible/_internal/ansible_collections/ansible/_protomatter/plugins/filter/true_type.py +1 -1
  29. ansible/_internal/ansible_collections/ansible/_protomatter/plugins/filter/unmask.py +1 -1
  30. ansible/cli/__init__.py +5 -82
  31. ansible/cli/arguments/option_helpers.py +8 -5
  32. ansible/cli/doc.py +84 -28
  33. ansible/cli/inventory.py +1 -1
  34. ansible/compat/importlib_resources.py +9 -12
  35. ansible/config/base.yml +27 -23
  36. ansible/config/manager.py +142 -101
  37. ansible/constants.py +1 -1
  38. ansible/errors/__init__.py +96 -49
  39. ansible/executor/module_common.py +8 -10
  40. ansible/executor/powershell/async_watchdog.ps1 +2 -2
  41. ansible/executor/powershell/async_wrapper.ps1 +3 -3
  42. ansible/executor/powershell/become_wrapper.ps1 +20 -2
  43. ansible/executor/powershell/bootstrap_wrapper.ps1 +28 -6
  44. ansible/executor/powershell/coverage_wrapper.ps1 +15 -6
  45. ansible/executor/powershell/exec_wrapper.ps1 +219 -6
  46. ansible/executor/powershell/module_manifest.py +52 -0
  47. ansible/executor/powershell/module_wrapper.ps1 +47 -21
  48. ansible/executor/powershell/powershell_expand_user.ps1 +20 -0
  49. ansible/executor/powershell/powershell_mkdtemp.ps1 +17 -0
  50. ansible/executor/process/worker.py +38 -113
  51. ansible/executor/task_executor.py +26 -61
  52. ansible/executor/task_result.py +2 -4
  53. ansible/galaxy/collection/__init__.py +1 -4
  54. ansible/inventory/manager.py +1 -0
  55. ansible/module_utils/_internal/__init__.py +0 -3
  56. ansible/module_utils/_internal/_ambient_context.py +3 -3
  57. ansible/module_utils/_internal/_ansiballz.py +4 -2
  58. ansible/module_utils/_internal/_datatag/__init__.py +20 -14
  59. ansible/module_utils/_internal/_datatag/_tags.py +2 -2
  60. ansible/module_utils/_internal/_deprecator.py +66 -48
  61. ansible/module_utils/_internal/_errors.py +88 -17
  62. ansible/module_utils/_internal/_event_utils.py +61 -0
  63. ansible/module_utils/_internal/_json/_profiles/__init__.py +21 -4
  64. ansible/module_utils/_internal/_json/_profiles/_module_legacy_c2m.py +2 -0
  65. ansible/module_utils/_internal/_json/_profiles/_module_legacy_m2c.py +2 -0
  66. ansible/module_utils/_internal/_json/_profiles/_tagless.py +3 -1
  67. ansible/module_utils/{common/messages.py → _internal/_messages.py} +28 -47
  68. ansible/module_utils/_internal/_patches/_dataclass_annotation_patch.py +1 -3
  69. ansible/module_utils/_internal/_plugin_info.py +1 -1
  70. ansible/module_utils/_internal/_stack.py +22 -0
  71. ansible/module_utils/_internal/_text_utils.py +6 -0
  72. ansible/module_utils/_internal/_traceback.py +11 -8
  73. ansible/module_utils/ansible_release.py +1 -1
  74. ansible/module_utils/basic.py +49 -15
  75. ansible/module_utils/common/arg_spec.py +2 -2
  76. ansible/module_utils/common/collections.py +6 -0
  77. ansible/module_utils/common/json.py +2 -2
  78. ansible/module_utils/common/text/converters.py +3 -3
  79. ansible/module_utils/common/validation.py +1 -1
  80. ansible/module_utils/common/warnings.py +80 -23
  81. ansible/module_utils/common/yaml.py +1 -1
  82. ansible/module_utils/datatag.py +5 -2
  83. ansible/module_utils/facts/system/distribution.py +16 -3
  84. ansible/module_utils/facts/virtual/linux.py +2 -2
  85. ansible/module_utils/parsing/convert_bool.py +6 -0
  86. ansible/module_utils/service.py +2 -9
  87. ansible/modules/apt_repository.py +7 -29
  88. ansible/modules/assemble.py +4 -4
  89. ansible/modules/async_status.py +13 -11
  90. ansible/modules/async_wrapper.py +5 -5
  91. ansible/modules/cron.py +3 -5
  92. ansible/modules/dnf5.py +15 -22
  93. ansible/modules/git.py +1 -6
  94. ansible/modules/hostname.py +0 -1
  95. ansible/modules/pip.py +2 -4
  96. ansible/modules/service.py +3 -9
  97. ansible/modules/sysvinit.py +3 -3
  98. ansible/parsing/ajson.py +3 -5
  99. ansible/parsing/dataloader.py +4 -4
  100. ansible/parsing/mod_args.py +1 -1
  101. ansible/parsing/plugin_docs.py +2 -2
  102. ansible/parsing/utils/yaml.py +3 -3
  103. ansible/parsing/vault/__init__.py +4 -4
  104. ansible/playbook/playbook_include.py +1 -1
  105. ansible/playbook/taggable.py +0 -3
  106. ansible/plugins/__init__.py +0 -25
  107. ansible/plugins/action/__init__.py +9 -32
  108. ansible/plugins/action/add_host.py +1 -1
  109. ansible/plugins/action/assemble.py +8 -16
  110. ansible/plugins/action/async_status.py +7 -2
  111. ansible/plugins/action/copy.py +8 -7
  112. ansible/plugins/action/gather_facts.py +8 -8
  113. ansible/plugins/action/package.py +5 -8
  114. ansible/plugins/action/script.py +8 -15
  115. ansible/plugins/action/service.py +3 -7
  116. ansible/plugins/action/template.py +6 -8
  117. ansible/plugins/action/unarchive.py +5 -15
  118. ansible/plugins/action/uri.py +9 -20
  119. ansible/plugins/callback/__init__.py +4 -6
  120. ansible/plugins/callback/junit.py +4 -2
  121. ansible/plugins/connection/local.py +2 -2
  122. ansible/plugins/connection/ssh.py +17 -9
  123. ansible/plugins/connection/winrm.py +5 -2
  124. ansible/plugins/doc_fragments/constructed.py +2 -2
  125. ansible/plugins/filter/core.py +13 -6
  126. ansible/plugins/filter/encryption.py +4 -4
  127. ansible/plugins/inventory/__init__.py +11 -10
  128. ansible/plugins/inventory/script.py +1 -1
  129. ansible/plugins/list.py +69 -16
  130. ansible/plugins/loader.py +10 -9
  131. ansible/plugins/lookup/csvfile.py +16 -71
  132. ansible/plugins/lookup/first_found.py +2 -1
  133. ansible/plugins/shell/__init__.py +56 -2
  134. ansible/plugins/shell/powershell.py +66 -9
  135. ansible/plugins/shell/sh.py +9 -5
  136. ansible/plugins/test/core.py +21 -15
  137. ansible/plugins/test/finished.yml +1 -1
  138. ansible/plugins/test/uri.py +2 -5
  139. ansible/release.py +1 -1
  140. ansible/template/__init__.py +30 -2
  141. ansible/utils/collection_loader/__init__.py +2 -0
  142. ansible/utils/display.py +107 -128
  143. ansible/utils/hashing.py +0 -1
  144. ansible/utils/listify.py +6 -4
  145. ansible/utils/plugin_docs.py +2 -1
  146. ansible/utils/unsafe_proxy.py +1 -1
  147. ansible/vars/hostvars.py +1 -1
  148. {ansible_core-2.19.0b3.dist-info → ansible_core-2.19.0b5.dist-info}/METADATA +3 -2
  149. {ansible_core-2.19.0b3.dist-info → ansible_core-2.19.0b5.dist-info}/RECORD +173 -161
  150. {ansible_core-2.19.0b3.dist-info → ansible_core-2.19.0b5.dist-info}/WHEEL +1 -1
  151. ansible_test/_data/completion/docker.txt +3 -3
  152. ansible_test/_data/completion/remote.txt +1 -0
  153. ansible_test/_data/requirements/sanity.ansible-doc.txt +1 -1
  154. ansible_test/_data/requirements/sanity.changelog.txt +2 -2
  155. ansible_test/_data/requirements/sanity.pep8.txt +1 -1
  156. ansible_test/_data/requirements/sanity.pylint.txt +4 -4
  157. ansible_test/_data/requirements/sanity.yamllint.txt +1 -1
  158. ansible_test/_internal/util.py +20 -0
  159. ansible_test/_util/controller/sanity/pylint/config/ansible-test-target.cfg +1 -0
  160. ansible_test/_util/controller/sanity/pylint/config/ansible-test.cfg +1 -0
  161. ansible_test/_util/controller/sanity/pylint/config/code-smell.cfg +1 -0
  162. ansible_test/_util/controller/sanity/pylint/config/collection.cfg +1 -0
  163. ansible_test/_util/controller/sanity/pylint/config/default.cfg +1 -0
  164. ansible_test/_util/controller/sanity/pylint/plugins/deprecated_calls.py +73 -8
  165. ansible_test/_util/target/setup/bootstrap.sh +31 -0
  166. ansible/_internal/_errors/_utils.py +0 -310
  167. {ansible_core-2.19.0b3.dist-info → ansible_core-2.19.0b5.dist-info}/entry_points.txt +0 -0
  168. {ansible_core-2.19.0b3.dist-info → ansible_core-2.19.0b5.dist-info/licenses}/COPYING +0 -0
  169. {ansible_core-2.19.0b3.dist-info → ansible_core-2.19.0b5.dist-info/licenses/licenses}/Apache-License.txt +0 -0
  170. {ansible_core-2.19.0b3.dist-info → ansible_core-2.19.0b5.dist-info/licenses/licenses}/BSD-3-Clause.txt +0 -0
  171. {ansible_core-2.19.0b3.dist-info → ansible_core-2.19.0b5.dist-info/licenses/licenses}/MIT-license.txt +0 -0
  172. {ansible_core-2.19.0b3.dist-info → ansible_core-2.19.0b5.dist-info/licenses/licenses}/PSF-license.txt +0 -0
  173. {ansible_core-2.19.0b3.dist-info → ansible_core-2.19.0b5.dist-info/licenses/licenses}/simplified_bsd.txt +0 -0
  174. {ansible_core-2.19.0b3.dist-info → ansible_core-2.19.0b5.dist-info}/top_level.txt +0 -0
@@ -18,7 +18,7 @@ def get_controller_serialize_map() -> dict[type, t.Callable]:
18
18
  return {
19
19
  _lazy_containers._AnsibleLazyTemplateDict: _profiles._JSONSerializationProfile.discard_tags,
20
20
  _lazy_containers._AnsibleLazyTemplateList: _profiles._JSONSerializationProfile.discard_tags,
21
- EncryptedString: str, # preserves tags since this is an intance of EncryptedString; if tags should be discarded from str, another entry will handle it
21
+ EncryptedString: str, # preserves tags since this is an instance of EncryptedString; if tags should be discarded from str, another entry will handle it
22
22
  }
23
23
 
24
24
 
@@ -45,7 +45,7 @@ def setup() -> None:
45
45
  """No-op function to ensure that side-effect only imports of this module are not flagged/removed as 'unused'."""
46
46
 
47
47
 
48
- # DTFIX-RELEASE: this is really fragile- disordered/incorrect imports (among other things) can mess it up. Consider a hosting-env-managed context
48
+ # DTFIX-FUTURE: this is really fragile- disordered/incorrect imports (among other things) can mess it up. Consider a hosting-env-managed context
49
49
  # with an enum with at least Controller/Target/Unknown values, and possibly using lazy-init module shims or some other mechanism to allow controller-side
50
50
  # notification/augmentation of this kind of metadata.
51
51
  _internal.get_controller_serialize_map = get_controller_serialize_map
@@ -9,7 +9,7 @@ _T_co = _t.TypeVar('_T_co', covariant=True)
9
9
  class SequenceProxy(_c.Sequence[_T_co]):
10
10
  """A read-only sequence proxy."""
11
11
 
12
- # DTFIX-RELEASE: needs unit test coverage
12
+ # DTFIX5: needs unit test coverage
13
13
 
14
14
  __slots__ = ('__value',)
15
15
 
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import signal
5
+ import types
6
+ import typing as _t
7
+
8
+ from ansible.module_utils import datatag
9
+
10
+
11
+ class AnsibleTimeoutError(BaseException):
12
+ """A general purpose timeout."""
13
+
14
+ _MAX_TIMEOUT = 100_000_000
15
+ """
16
+ The maximum supported timeout value.
17
+ This value comes from BSD's alarm limit, which is due to that function using setitimer.
18
+ """
19
+
20
+ def __init__(self, timeout: int) -> None:
21
+ self.timeout = timeout
22
+
23
+ super().__init__(f"Timed out after {timeout} second(s).")
24
+
25
+ @classmethod
26
+ @contextlib.contextmanager
27
+ def alarm_timeout(cls, timeout: int | None) -> _t.Iterator[None]:
28
+ """
29
+ Context for running code under an optional timeout.
30
+ Raises an instance of this class if the timeout occurs.
31
+
32
+ New usages of this timeout mechanism are discouraged.
33
+ """
34
+ if timeout is not None:
35
+ if not isinstance(timeout, int):
36
+ raise TypeError(f"Timeout requires 'int' argument, not {datatag.native_type_name(timeout)!r}.")
37
+
38
+ if timeout < 0 or timeout > cls._MAX_TIMEOUT:
39
+ # On BSD based systems, alarm is implemented using setitimer.
40
+ # If out-of-bounds values are passed to alarm, they will return -1, which would be interpreted as an existing timer being set.
41
+ # To avoid that, bounds checking is performed in advance.
42
+ raise ValueError(f'Timeout {timeout} is invalid, it must be between 0 and {cls._MAX_TIMEOUT}.')
43
+
44
+ if not timeout:
45
+ yield # execute the context manager's body
46
+ return # no timeout to deal with, exit immediately
47
+
48
+ def on_alarm(_signal: int, _frame: types.FrameType) -> None:
49
+ raise cls(timeout)
50
+
51
+ if signal.signal(signal.SIGALRM, on_alarm):
52
+ raise RuntimeError("An existing alarm handler was present.")
53
+
54
+ try:
55
+ try:
56
+ if signal.alarm(timeout):
57
+ raise RuntimeError("An existing alarm was set.")
58
+
59
+ yield # execute the context manager's body
60
+ finally:
61
+ # Disable the alarm.
62
+ # If the alarm fires inside this finally block, the alarm is still disabled.
63
+ # This guarantees the cleanup code in the outer finally block runs without risk of encountering the `TaskTimeoutError` from the alarm.
64
+ signal.alarm(0)
65
+ finally:
66
+ signal.signal(signal.SIGALRM, signal.SIG_DFL)
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import collections.abc as _c
3
4
  import dataclasses
4
5
  import typing as t
5
6
 
7
+ from ansible._internal._errors import _error_utils
6
8
  from ansible.errors import AnsibleRuntimeError
7
- from ansible.module_utils.common.messages import ErrorSummary, Detail, _dataclass_kwargs
9
+ from ansible.module_utils._internal import _messages
8
10
 
9
11
 
10
12
  class AnsibleCapturedError(AnsibleRuntimeError):
@@ -16,43 +18,36 @@ class AnsibleCapturedError(AnsibleRuntimeError):
16
18
  self,
17
19
  *,
18
20
  obj: t.Any = None,
19
- error_summary: ErrorSummary,
21
+ event: _messages.Event,
20
22
  ) -> None:
21
23
  super().__init__(
22
24
  obj=obj,
23
25
  )
24
26
 
25
- self._error_summary = error_summary
27
+ self._event = event
26
28
 
27
- @property
28
- def error_summary(self) -> ErrorSummary:
29
- return self._error_summary
30
29
 
30
+ class AnsibleResultCapturedError(AnsibleCapturedError, _error_utils.ContributesToTaskResult):
31
+ """
32
+ An exception representing error detail captured in a foreign context where an action/module result dictionary is involved.
31
33
 
32
- class AnsibleResultCapturedError(AnsibleCapturedError):
33
- """An exception representing error detail captured in a foreign context where an action/module result dictionary is involved."""
34
+ This exception provides a result dictionary via the ContributesToTaskResult mixin.
35
+ """
34
36
 
35
- def __init__(self, error_summary: ErrorSummary, result: dict[str, t.Any]) -> None:
36
- super().__init__(error_summary=error_summary)
37
+ def __init__(self, event: _messages.Event, result: dict[str, t.Any]) -> None:
38
+ super().__init__(event=event)
37
39
 
38
40
  self._result = result
39
41
 
42
+ @property
43
+ def result_contribution(self) -> _c.Mapping[str, object]:
44
+ return self._result
45
+
40
46
  @classmethod
41
47
  def maybe_raise_on_result(cls, result: dict[str, t.Any]) -> None:
42
48
  """Normalize the result and raise an exception if the result indicated failure."""
43
49
  if error_summary := cls.normalize_result_exception(result):
44
- raise error_summary.error_type(error_summary, result)
45
-
46
- @classmethod
47
- def find_first_remoted_error(cls, exception: BaseException) -> t.Self | None:
48
- """Find the first captured module error in the cause chain, starting with the given exception, returning None if not found."""
49
- while exception:
50
- if isinstance(exception, cls):
51
- return exception
52
-
53
- exception = exception.__cause__
54
-
55
- return None
50
+ raise error_summary.error_type(error_summary.event, result)
56
51
 
57
52
  @classmethod
58
53
  def normalize_result_exception(cls, result: dict[str, t.Any]) -> CapturedErrorSummary | None:
@@ -76,17 +71,18 @@ class AnsibleResultCapturedError(AnsibleCapturedError):
76
71
 
77
72
  if isinstance(exception, CapturedErrorSummary):
78
73
  error_summary = exception
79
- elif isinstance(exception, ErrorSummary):
74
+ elif isinstance(exception, _messages.ErrorSummary):
80
75
  error_summary = CapturedErrorSummary(
81
- details=exception.details,
82
- formatted_traceback=cls._normalize_traceback(exception.formatted_traceback),
76
+ event=exception.event,
83
77
  error_type=cls,
84
78
  )
85
79
  else:
86
80
  # translate non-ErrorDetail errors
87
81
  error_summary = CapturedErrorSummary(
88
- details=(Detail(msg=str(result.get('msg', 'Unknown error.'))),),
89
- formatted_traceback=cls._normalize_traceback(exception),
82
+ event=_messages.Event(
83
+ msg=str(result.get('msg', 'Unknown error.')),
84
+ formatted_traceback=cls._normalize_traceback(exception),
85
+ ),
90
86
  error_type=cls,
91
87
  )
92
88
 
@@ -122,7 +118,6 @@ class AnsibleModuleCapturedError(AnsibleResultCapturedError):
122
118
  context = 'target'
123
119
 
124
120
 
125
- @dataclasses.dataclass(**_dataclass_kwargs)
126
- class CapturedErrorSummary(ErrorSummary):
127
- # DTFIX-RELEASE: where to put this, name, etc. since it shows up in results, it's not exactly private (and contains a type ref to an internal type)
121
+ @dataclasses.dataclass(**_messages._dataclass_kwargs)
122
+ class CapturedErrorSummary(_messages.ErrorSummary):
128
123
  error_type: type[AnsibleResultCapturedError] | None = None
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations as _annotations
2
+
3
+ from ansible.module_utils._internal import _errors, _messages
4
+
5
+
6
+ class ControllerEventFactory(_errors.EventFactory):
7
+ """Factory for creating `Event` instances from `BaseException` instances on the controller."""
8
+
9
+ def _get_msg(self, exception: BaseException) -> str | None:
10
+ from ansible.errors import AnsibleError
11
+
12
+ if not isinstance(exception, AnsibleError):
13
+ return super()._get_msg(exception)
14
+
15
+ return exception._original_message.strip()
16
+
17
+ def _get_formatted_source_context(self, exception: BaseException) -> str | None:
18
+ from ansible.errors import AnsibleError
19
+
20
+ if not isinstance(exception, AnsibleError):
21
+ return super()._get_formatted_source_context(exception)
22
+
23
+ return exception._formatted_source_context
24
+
25
+ def _get_help_text(self, exception: BaseException) -> str | None:
26
+ from ansible.errors import AnsibleError
27
+
28
+ if not isinstance(exception, AnsibleError):
29
+ return super()._get_help_text(exception)
30
+
31
+ return exception._help_text
32
+
33
+ def _get_chain(self, exception: BaseException) -> _messages.EventChain | None:
34
+ from ansible._internal._errors import _captured # avoid circular import due to AnsibleError import
35
+
36
+ if isinstance(exception, _captured.AnsibleCapturedError):
37
+ # a captured error provides its own cause event, it never has a normal __cause__
38
+ return _messages.EventChain(
39
+ msg_reason=_errors.MSG_REASON_DIRECT_CAUSE,
40
+ traceback_reason=f'The above {exception.context} exception was the direct cause of the following controller exception:',
41
+ event=exception._event,
42
+ )
43
+
44
+ return super()._get_chain(exception)
45
+
46
+ def _follow_cause(self, exception: BaseException) -> bool:
47
+ from ansible.errors import AnsibleError
48
+
49
+ return not isinstance(exception, AnsibleError) or exception._include_cause_message
50
+
51
+ def _get_cause(self, exception: BaseException) -> BaseException | None:
52
+ # deprecated: description='remove support for orig_exc (deprecated in 2.23)' core_version='2.27'
53
+
54
+ cause = super()._get_cause(exception)
55
+
56
+ from ansible.errors import AnsibleError
57
+
58
+ if not isinstance(exception, AnsibleError):
59
+ return cause
60
+
61
+ try:
62
+ from ansible.utils.display import _display
63
+ except Exception: # pylint: disable=broad-except # if config is broken, this can raise things other than ImportError
64
+ _display = None
65
+
66
+ if cause:
67
+ if exception.orig_exc and exception.orig_exc is not cause and _display:
68
+ _display.warning(
69
+ msg=f"The `orig_exc` argument to `{type(exception).__name__}` was given, but differed from the cause given by `raise ... from`.",
70
+ )
71
+
72
+ return cause
73
+
74
+ if exception.orig_exc:
75
+ if _display:
76
+ # encourage the use of `raise ... from` before deprecating `orig_exc`
77
+ _display.warning(
78
+ msg=f"The `orig_exc` argument to `{type(exception).__name__}` was given without using `raise ... from orig_exc`.",
79
+ )
80
+
81
+ return exception.orig_exc
82
+
83
+ return None
84
+
85
+ def _get_events(self, exception: BaseException) -> tuple[_messages.Event, ...] | None:
86
+ if isinstance(exception, BaseExceptionGroup):
87
+ return tuple(self._convert_exception(ex) for ex in exception.exceptions)
88
+
89
+ return None
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import collections.abc as _c
5
+ import dataclasses
6
+ import itertools
7
+ import pathlib
8
+ import textwrap
9
+ import typing as t
10
+
11
+ from ansible._internal._datatag._tags import Origin
12
+ from ansible._internal._errors import _error_factory
13
+ from ansible.module_utils._internal import _ambient_context, _event_utils, _messages, _traceback
14
+
15
+
16
+ class ContributesToTaskResult(metaclass=abc.ABCMeta):
17
+ """Exceptions may include this mixin to contribute task result dictionary data directly to the final result."""
18
+
19
+ @property
20
+ @abc.abstractmethod
21
+ def result_contribution(self) -> _c.Mapping[str, object]:
22
+ """Mapping of results to apply to the task result."""
23
+
24
+ @property
25
+ def omit_exception_key(self) -> bool:
26
+ """Non-error exceptions (e.g., `AnsibleActionSkip`) must return `True` to ensure omission of the `exception` key."""
27
+ return False
28
+
29
+ @property
30
+ def omit_failed_key(self) -> bool:
31
+ """Exceptions representing non-failure scenarios (e.g., `skipped`, `unreachable`) must return `True` to ensure omisson of the `failed` key."""
32
+ return False
33
+
34
+
35
+ class RedactAnnotatedSourceContext(_ambient_context.AmbientContextBase):
36
+ """When active, this context will redact annotated source lines, showing only the origin."""
37
+
38
+
39
+ @dataclasses.dataclass(kw_only=True, frozen=True)
40
+ class SourceContext:
41
+ origin: Origin
42
+ annotated_source_lines: list[str]
43
+ target_line: str | None
44
+
45
+ def __str__(self) -> str:
46
+ msg_lines = [f'Origin: {self.origin}']
47
+
48
+ if self.annotated_source_lines:
49
+ msg_lines.append('')
50
+ msg_lines.extend(self.annotated_source_lines)
51
+
52
+ return '\n'.join(msg_lines)
53
+
54
+ @classmethod
55
+ def from_value(cls, value: t.Any) -> SourceContext | None:
56
+ """Attempt to retrieve source and render a contextual indicator from the value's origin (if any)."""
57
+ if value is None:
58
+ return None
59
+
60
+ if isinstance(value, Origin):
61
+ origin = value
62
+ value = None
63
+ else:
64
+ origin = Origin.get_tag(value)
65
+
66
+ if RedactAnnotatedSourceContext.current(optional=True):
67
+ return cls.error('content redacted')
68
+
69
+ if origin and origin.path:
70
+ return cls.from_origin(origin)
71
+
72
+ if value is None:
73
+ truncated_value = None
74
+ annotated_source_lines = []
75
+ else:
76
+ # DTFIX-FUTURE: cleanup/share width
77
+ try:
78
+ value = str(value)
79
+ except Exception as ex:
80
+ value = f'<< context unavailable: {ex} >>'
81
+
82
+ truncated_value = textwrap.shorten(value, width=120)
83
+ annotated_source_lines = [truncated_value]
84
+
85
+ return SourceContext(
86
+ origin=origin or Origin.UNKNOWN,
87
+ annotated_source_lines=annotated_source_lines,
88
+ target_line=truncated_value,
89
+ )
90
+
91
+ @staticmethod
92
+ def error(message: str | None, origin: Origin | None = None) -> SourceContext:
93
+ return SourceContext(
94
+ origin=origin,
95
+ annotated_source_lines=[f'(source not shown: {message})'] if message else [],
96
+ target_line=None,
97
+ )
98
+
99
+ @classmethod
100
+ def from_origin(cls, origin: Origin) -> SourceContext:
101
+ """Attempt to retrieve source and render a contextual indicator of an error location."""
102
+ from ansible.parsing.vault import is_encrypted # avoid circular import
103
+
104
+ # DTFIX-FUTURE: support referencing the column after the end of the target line, so we can indicate where a missing character (quote) needs to be added
105
+ # this is also useful for cases like end-of-stream reported by the YAML parser
106
+
107
+ # DTFIX-FUTURE: Implement line wrapping and match annotated line width to the terminal display width.
108
+
109
+ context_line_count: t.Final = 2
110
+ max_annotated_line_width: t.Final = 120
111
+ truncation_marker: t.Final = '...'
112
+
113
+ target_line_num = origin.line_num
114
+
115
+ if RedactAnnotatedSourceContext.current(optional=True):
116
+ return cls.error('content redacted', origin)
117
+
118
+ if not target_line_num or target_line_num < 1:
119
+ return cls.error(None, origin) # message omitted since lack of line number is obvious from pos
120
+
121
+ start_line_idx = max(0, (target_line_num - 1) - context_line_count) # if near start of file
122
+ target_col_num = origin.col_num
123
+
124
+ try:
125
+ with pathlib.Path(origin.path).open() as src:
126
+ first_line = src.readline()
127
+ lines = list(itertools.islice(itertools.chain((first_line,), src), start_line_idx, target_line_num))
128
+ except Exception as ex:
129
+ return cls.error(type(ex).__name__, origin)
130
+
131
+ if is_encrypted(first_line):
132
+ return cls.error('content encrypted', origin)
133
+
134
+ if len(lines) != target_line_num - start_line_idx:
135
+ return cls.error('file truncated', origin)
136
+
137
+ annotated_source_lines = []
138
+
139
+ line_label_width = len(str(target_line_num))
140
+ max_src_line_len = max_annotated_line_width - line_label_width - 1
141
+
142
+ usable_line_len = max_src_line_len
143
+
144
+ for line_num, line in enumerate(lines, start_line_idx + 1):
145
+ line = line.rstrip('\n') # universal newline default mode on `open` ensures we'll never see anything but \n
146
+ line = line.replace('\t', ' ') # mixed tab/space handling is intentionally disabled since we're both format and display config agnostic
147
+
148
+ if len(line) > max_src_line_len:
149
+ line = line[: max_src_line_len - len(truncation_marker)] + truncation_marker
150
+ usable_line_len = max_src_line_len - len(truncation_marker)
151
+
152
+ annotated_source_lines.append(f'{str(line_num).rjust(line_label_width)}{" " if line else ""}{line}')
153
+
154
+ if target_col_num and usable_line_len >= target_col_num >= 1:
155
+ column_marker = f'column {target_col_num}'
156
+
157
+ target_col_idx = target_col_num - 1
158
+
159
+ if target_col_idx + 2 + len(column_marker) > max_src_line_len:
160
+ column_marker = f'{" " * (target_col_idx - len(column_marker) - 1)}{column_marker} ^'
161
+ else:
162
+ column_marker = f'{" " * target_col_idx}^ {column_marker}'
163
+
164
+ column_marker = f'{" " * line_label_width} {column_marker}'
165
+
166
+ annotated_source_lines.append(column_marker)
167
+ elif target_col_num is None:
168
+ underline_length = len(annotated_source_lines[-1]) - line_label_width - 1
169
+ annotated_source_lines.append(f'{" " * line_label_width} {"^" * underline_length}')
170
+
171
+ return SourceContext(
172
+ origin=origin,
173
+ annotated_source_lines=annotated_source_lines,
174
+ target_line=lines[-1].rstrip('\n'), # universal newline default mode on `open` ensures we'll never see anything but \n
175
+ )
176
+
177
+
178
+ def format_exception_message(exception: BaseException) -> str:
179
+ """Return the full chain of exception messages by concatenating the cause(s) until all are exhausted."""
180
+ return _event_utils.format_event_brief_message(_error_factory.ControllerEventFactory.from_exception(exception, False))
181
+
182
+
183
+ def result_dict_from_exception(exception: BaseException, accept_result_contribution: bool = False) -> dict[str, object]:
184
+ """Return a failed task result dict from the given exception."""
185
+ event = _error_factory.ControllerEventFactory.from_exception(exception, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR))
186
+
187
+ result: dict[str, object] = {}
188
+ omit_failed_key = False
189
+ omit_exception_key = False
190
+
191
+ if accept_result_contribution:
192
+ while exception:
193
+ if isinstance(exception, ContributesToTaskResult):
194
+ result = dict(exception.result_contribution)
195
+ omit_failed_key = exception.omit_failed_key
196
+ omit_exception_key = exception.omit_exception_key
197
+ break
198
+
199
+ exception = exception.__cause__
200
+
201
+ if omit_failed_key:
202
+ result.pop('failed', None)
203
+ else:
204
+ result.update(failed=True)
205
+
206
+ if omit_exception_key:
207
+ result.pop('exception', None)
208
+ else:
209
+ result.update(exception=_messages.ErrorSummary(event=event))
210
+
211
+ if 'msg' not in result:
212
+ # if nothing contributed `msg`, generate one from the exception messages
213
+ result.update(msg=_event_utils.format_event_brief_message(event))
214
+
215
+ return result
216
+
217
+
218
+ def result_dict_from_captured_errors(
219
+ msg: str,
220
+ *,
221
+ errors: list[_messages.ErrorSummary] | None = None,
222
+ ) -> dict[str, object]:
223
+ """Return a failed task result dict from the given error message and captured errors."""
224
+ _skip_stackwalk = True
225
+
226
+ event = _messages.Event(
227
+ msg=msg,
228
+ formatted_traceback=_traceback.maybe_capture_traceback(msg, _traceback.TracebackEvent.ERROR),
229
+ events=tuple(error.event for error in errors) if errors else None,
230
+ )
231
+
232
+ result = dict(
233
+ failed=True,
234
+ exception=_messages.ErrorSummary(
235
+ event=event,
236
+ ),
237
+ msg=_event_utils.format_event_brief_message(event),
238
+ )
239
+
240
+ return result
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import abc as _c
4
+
5
+ from ansible._internal._errors._alarm_timeout import AnsibleTimeoutError
6
+ from ansible._internal._errors._error_utils import ContributesToTaskResult
7
+ from ansible.module_utils.datatag import deprecate_value
8
+
9
+
10
+ class TaskTimeoutError(AnsibleTimeoutError, ContributesToTaskResult):
11
+ """
12
+ A task-specific timeout.
13
+
14
+ This exception provides a result dictionary via the ContributesToTaskResult mixin.
15
+ """
16
+
17
+ @property
18
+ def result_contribution(self) -> _c.Mapping[str, object]:
19
+ help_text = "Configure `DISPLAY_TRACEBACK` to see a traceback on timeout errors."
20
+
21
+ frame = deprecate_value(
22
+ value=help_text,
23
+ msg="The `timedout.frame` task result key is deprecated.",
24
+ help_text=help_text,
25
+ version="2.23",
26
+ )
27
+
28
+ return dict(timedout=dict(frame=frame, period=self.timeout))
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations as _annotations
2
+
3
+ import collections.abc as _c
4
+ import textwrap as _textwrap
5
+
6
+ from ansible.module_utils._internal import _event_utils, _messages
7
+
8
+
9
+ def format_event(event: _messages.Event, include_traceback: bool) -> str:
10
+ """Format an event into a verbose message and traceback."""
11
+ msg = format_event_verbose_message(event)
12
+
13
+ if include_traceback:
14
+ msg += '\n' + format_event_traceback(event)
15
+
16
+ msg = msg.strip()
17
+
18
+ if '\n' in msg:
19
+ msg += '\n\n'
20
+ else:
21
+ msg += '\n'
22
+
23
+ return msg
24
+
25
+
26
+ def format_event_traceback(event: _messages.Event) -> str:
27
+ """Format an event into a traceback."""
28
+ segments: list[str] = []
29
+
30
+ while event:
31
+ segment = event.formatted_traceback or '(traceback missing)\n'
32
+
33
+ if event.events:
34
+ child_tracebacks = [format_event_traceback(child) for child in event.events]
35
+ segment += _format_event_children("Sub-Traceback", child_tracebacks)
36
+
37
+ segments.append(segment)
38
+
39
+ if event.chain:
40
+ segments.append(f'\n{event.chain.traceback_reason}\n\n')
41
+
42
+ event = event.chain.event
43
+ else:
44
+ event = None
45
+
46
+ return ''.join(reversed(segments))
47
+
48
+
49
+ def format_event_verbose_message(event: _messages.Event) -> str:
50
+ """
51
+ Format an event into a verbose message.
52
+ Help text, contextual information and sub-events will be included.
53
+ """
54
+ segments: list[str] = []
55
+ original_event = event
56
+
57
+ while event:
58
+ messages = [event.msg]
59
+ chain: _messages.EventChain = event.chain
60
+
61
+ while chain and chain.follow:
62
+ if chain.event.events:
63
+ break # do not collapse a chained event with sub-events, since they would be lost
64
+
65
+ if chain.event.formatted_source_context or chain.event.help_text:
66
+ if chain.event.formatted_source_context != event.formatted_source_context or chain.event.help_text != event.help_text:
67
+ break # do not collapse a chained event with different details, since they would be lost
68
+
69
+ if chain.event.chain and chain.msg_reason != chain.event.chain.msg_reason:
70
+ break # do not collapse a chained event which has a chain with a different msg_reason
71
+
72
+ messages.append(chain.event.msg)
73
+
74
+ chain = chain.event.chain
75
+
76
+ msg = _event_utils.deduplicate_message_parts(messages)
77
+ segment = '\n'.join(_get_message_lines(msg, event.help_text, event.formatted_source_context)) + '\n'
78
+
79
+ if event.events:
80
+ child_msgs = [format_event_verbose_message(child) for child in event.events]
81
+ segment += _format_event_children("Sub-Event", child_msgs)
82
+
83
+ segments.append(segment)
84
+
85
+ if chain and chain.follow:
86
+ segments.append(f'\n{chain.msg_reason}\n\n')
87
+
88
+ event = chain.event
89
+ else:
90
+ event = None
91
+
92
+ if len(segments) > 1:
93
+ segments.insert(0, _event_utils.format_event_brief_message(original_event) + '\n\n')
94
+
95
+ return ''.join(segments)
96
+
97
+
98
+ def _format_event_children(label: str, children: _c.Iterable[str]) -> str:
99
+ """Format the given list of child messages into a single string."""
100
+ items = list(children)
101
+ count = len(items)
102
+ lines = ['\n']
103
+
104
+ for idx, item in enumerate(items):
105
+ lines.append(f'+--[ {label} {idx + 1} of {count} ]---\n')
106
+ lines.append(_textwrap.indent(f"\n{item}\n", "| ", lambda value: True))
107
+
108
+ lines.append(f'+--[ End {label} ]---\n')
109
+
110
+ return ''.join(lines)
111
+
112
+
113
+ def _get_message_lines(message: str, help_text: str | None, formatted_source_context: str | None) -> list[str]:
114
+ """Return a list of message lines constructed from the given message, help text and formatted source context."""
115
+ if help_text and not formatted_source_context and '\n' not in message and '\n' not in help_text:
116
+ return [f'{message} {help_text}'] # prefer a single-line message with help text when there is no source context
117
+
118
+ message_lines = [message]
119
+
120
+ if formatted_source_context:
121
+ message_lines.append(formatted_source_context)
122
+
123
+ if help_text:
124
+ message_lines.append('')
125
+ message_lines.append(help_text)
126
+
127
+ return message_lines