DTFIX recategorization and error/warning refactor (#85181)

Co-authored-by: Matt Davis <nitzmahone@redhat.com>
pull/85183/head
Matt Clay 7 months ago committed by GitHub
parent 40c919d7bd
commit 242bb9ebab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -21,10 +21,10 @@ minor_changes:
except when the undefined value is the default of ``undef()`` with no arguments. Previously, any existing undefined hint would be ignored.
- templating - Embedding ``range()`` values in containers such as lists will result in an error on use.
Previously the value would be converted to a string representing the range parameters, such as ``range(0, 3)``.
- Jinja plugins - Plugins can declare support for undefined values. # DTFIX-RELEASE: examples, porting guide entry
- Jinja plugins - Plugins can declare support for undefined values. # DTFIX5: examples, porting guide entry
- templating - Variables of type ``set`` and ``tuple`` are now converted to ``list`` when exiting the final pass of templating.
- templating - Access to an undefined variable from inside a lookup, filter, or test (which raises MarkerError) no longer ends processing of the current template.
The triggering undefined value is returned as the result of the offending plugin invocation, and the template continues to execute. # DTFIX-RELEASE: porting guide entry, samples needed
The triggering undefined value is returned as the result of the offending plugin invocation, and the template continues to execute. # DTFIX5: porting guide entry, samples needed
- plugin error handling - When raising exceptions in an exception handler, be sure to use ``raise ... from`` as appropriate.
This supersedes the use of the ``AnsibleError`` arg ``orig_exc`` to represent the cause.
Specifying ``orig_exc`` as the cause is still permitted.
@ -76,16 +76,16 @@ breaking_changes:
Lookup plugins are responsible for tagging strings containing templates to allow evaluation as a template.
- assert - The ``quiet`` argument must be a commonly-accepted boolean value.
Previously, unrecognized values were silently treated as False.
- plugins - Any plugin that sources or creates templates must properly tag them as trusted. # DTFIX-RELEASE: porting guide entry for "how?" Don't forget to mention inventory plugin ``trusted_by_default`` config.
- plugins - Any plugin that sources or creates templates must properly tag them as trusted. # DTFIX5: porting guide entry for "how?" Don't forget to mention inventory plugin ``trusted_by_default`` config.
- first_found lookup - When specifying ``files`` or ``paths`` as a templated list containing undefined values, the undefined list elements will be discarded with a warning.
Previously, the entire list would be discarded without any warning.
- templating - The result of the ``range()`` global function cannot be returned from a template- it should always be passed to a filter (e.g., ``random``).
Previously, range objects returned from an intermediate template were always converted to a list, which is inconsistent with inline consumption of range objects.
- plugins - Custom Jinja plugins that accept undefined top-level arguments must opt in to receiving them. # DTFIX-RELEASE: porting guide entry + backcompat behavior description
- plugins - Custom Jinja plugins that accept undefined top-level arguments must opt in to receiving them. # DTFIX5: porting guide entry + backcompat behavior description
- plugins - Custom Jinja plugins that use ``environment.getitem`` to retrieve undefined values will now trigger a ``MarkerError`` exception.
This exception must be handled to allow the plugin to return a ``Marker``, or the plugin must opt-in to accepting ``Marker`` values. # DTFIX-RELEASE: mention the decorator
This exception must be handled to allow the plugin to return a ``Marker``, or the plugin must opt-in to accepting ``Marker`` values. # DTFIX5: mention the decorator
- templating - Many Jinja plugins (filters, lookups, tests) and methods previously silently ignored undefined inputs, which often masked subtle errors.
Passing an undefined argument to a Jinja plugin or method that does not declare undefined support now results in an undefined value. # DTFIX-RELEASE: common examples, porting guide, `is defined`, `is undefined`, etc; porting guide should also mention that overly-broad exception handling may mask Undefined errors; also that lazy handling of Undefined can invoke a plugin and bomb out in the middle where it was previously never invoked (plugins with side effects, just don't)
Passing an undefined argument to a Jinja plugin or method that does not declare undefined support now results in an undefined value. # DTFIX5: common examples, porting guide, `is defined`, `is undefined`, etc; porting guide should also mention that overly-broad exception handling may mask Undefined errors; also that lazy handling of Undefined can invoke a plugin and bomb out in the middle where it was previously never invoked (plugins with side effects, just don't)
- lookup plugins - Lookup plugins called as `with_(lookup)` will no longer have the `_subdir` attribute set.
- lookup plugins - ``terms`` will always be passed to ``run`` as the first positional arg, where previously it was sometimes passed as a keyword arg when using ``with_`` syntax.
- modules - Ansible modules using ``sys.excepthook`` must use a standard ``try/except`` instead.

@ -9,7 +9,7 @@ _T_co = _t.TypeVar('_T_co', covariant=True)
class SequenceProxy(_c.Sequence[_T_co]):
"""A read-only sequence proxy."""
# DTFIX-RELEASE: needs unit test coverage
# DTFIX5: needs unit test coverage
__slots__ = ('__value',)

@ -4,7 +4,7 @@ import dataclasses
import typing as t
from ansible.errors import AnsibleRuntimeError
from ansible.module_utils.common.messages import ErrorSummary, Detail, _dataclass_kwargs
from ansible.module_utils._internal import _messages
class AnsibleCapturedError(AnsibleRuntimeError):
@ -16,24 +16,20 @@ class AnsibleCapturedError(AnsibleRuntimeError):
self,
*,
obj: t.Any = None,
error_summary: ErrorSummary,
event: _messages.Event,
) -> None:
super().__init__(
obj=obj,
)
self._error_summary = error_summary
@property
def error_summary(self) -> ErrorSummary:
return self._error_summary
self._event = event
class AnsibleResultCapturedError(AnsibleCapturedError):
"""An exception representing error detail captured in a foreign context where an action/module result dictionary is involved."""
def __init__(self, error_summary: ErrorSummary, result: dict[str, t.Any]) -> None:
super().__init__(error_summary=error_summary)
def __init__(self, event: _messages.Event, result: dict[str, t.Any]) -> None:
super().__init__(event=event)
self._result = result
@ -41,7 +37,7 @@ class AnsibleResultCapturedError(AnsibleCapturedError):
def maybe_raise_on_result(cls, result: dict[str, t.Any]) -> None:
"""Normalize the result and raise an exception if the result indicated failure."""
if error_summary := cls.normalize_result_exception(result):
raise error_summary.error_type(error_summary, result)
raise error_summary.error_type(error_summary.event, result)
@classmethod
def find_first_remoted_error(cls, exception: BaseException) -> t.Self | None:
@ -76,17 +72,18 @@ class AnsibleResultCapturedError(AnsibleCapturedError):
if isinstance(exception, CapturedErrorSummary):
error_summary = exception
elif isinstance(exception, ErrorSummary):
elif isinstance(exception, _messages.ErrorSummary):
error_summary = CapturedErrorSummary(
details=exception.details,
formatted_traceback=cls._normalize_traceback(exception.formatted_traceback),
event=exception.event,
error_type=cls,
)
else:
# translate non-ErrorDetail errors
error_summary = CapturedErrorSummary(
details=(Detail(msg=str(result.get('msg', 'Unknown error.'))),),
event=_messages.Event(
msg=str(result.get('msg', 'Unknown error.')),
formatted_traceback=cls._normalize_traceback(exception),
),
error_type=cls,
)
@ -122,6 +119,6 @@ class AnsibleModuleCapturedError(AnsibleResultCapturedError):
context = 'target'
@dataclasses.dataclass(**_dataclass_kwargs)
class CapturedErrorSummary(ErrorSummary):
@dataclasses.dataclass(**_messages._dataclass_kwargs)
class CapturedErrorSummary(_messages.ErrorSummary):
error_type: type[AnsibleResultCapturedError] | None = None

@ -0,0 +1,89 @@
from __future__ import annotations as _annotations
from ansible.module_utils._internal import _errors, _messages
class ControllerEventFactory(_errors.EventFactory):
"""Factory for creating `Event` instances from `BaseException` instances on the controller."""
def _get_msg(self, exception: BaseException) -> str | None:
from ansible.errors import AnsibleError
if not isinstance(exception, AnsibleError):
return super()._get_msg(exception)
return exception._original_message.strip()
def _get_formatted_source_context(self, exception: BaseException) -> str | None:
from ansible.errors import AnsibleError
if not isinstance(exception, AnsibleError):
return super()._get_formatted_source_context(exception)
return exception._formatted_source_context
def _get_help_text(self, exception: BaseException) -> str | None:
from ansible.errors import AnsibleError
if not isinstance(exception, AnsibleError):
return super()._get_help_text(exception)
return exception._help_text
def _get_chain(self, exception: BaseException) -> _messages.EventChain | None:
from ansible._internal._errors import _captured # avoid circular import due to AnsibleError import
if isinstance(exception, _captured.AnsibleCapturedError):
# a captured error provides its own cause event, it never has a normal __cause__
return _messages.EventChain(
msg_reason=_errors.MSG_REASON_DIRECT_CAUSE,
traceback_reason=f'The above {exception.context} exception was the direct cause of the following controller exception:',
event=exception._event,
)
return super()._get_chain(exception)
def _follow_cause(self, exception: BaseException) -> bool:
from ansible.errors import AnsibleError
return not isinstance(exception, AnsibleError) or exception._include_cause_message
def _get_cause(self, exception: BaseException) -> BaseException | None:
# deprecated: description='remove support for orig_exc (deprecated in 2.23)' core_version='2.27'
cause = super()._get_cause(exception)
from ansible.errors import AnsibleError
if not isinstance(exception, AnsibleError):
return cause
try:
from ansible.utils.display import _display
except Exception: # pylint: disable=broad-except # if config is broken, this can raise things other than ImportError
_display = None
if cause:
if exception.orig_exc and exception.orig_exc is not cause and _display:
_display.warning(
msg=f"The `orig_exc` argument to `{type(exception).__name__}` was given, but differed from the cause given by `raise ... from`.",
)
return cause
if exception.orig_exc:
if _display:
# encourage the use of `raise ... from` before deprecating `orig_exc`
_display.warning(
msg=f"The `orig_exc` argument to `{type(exception).__name__}` was given without using `raise ... from orig_exc`.",
)
return exception.orig_exc
return None
def _get_events(self, exception: BaseException) -> tuple[_messages.Event, ...] | None:
if isinstance(exception, BaseExceptionGroup):
return tuple(self._convert_exception(ex) for ex in exception.exceptions)
return None

@ -3,17 +3,12 @@ from __future__ import annotations
import dataclasses
import itertools
import pathlib
import sys
import textwrap
import typing as t
from ansible.module_utils.common.messages import Detail, ErrorSummary
from ansible._internal._datatag._tags import Origin
from ansible.module_utils._internal import _ambient_context, _traceback
from ansible import errors
if t.TYPE_CHECKING:
from ansible.utils.display import Display
from ansible._internal._errors import _error_factory
from ansible.module_utils._internal import _ambient_context, _event_utils
class RedactAnnotatedSourceContext(_ambient_context.AmbientContextBase):
@ -22,152 +17,9 @@ class RedactAnnotatedSourceContext(_ambient_context.AmbientContextBase):
"""
def _dedupe_and_concat_message_chain(message_parts: list[str]) -> str:
message_parts = list(reversed(message_parts))
message = message_parts.pop(0)
for message_part in message_parts:
# avoid duplicate messages where the cause was already concatenated to the exception message
if message_part.endswith(message):
message = message_part
else:
message = concat_message(message_part, message)
return message
def _collapse_error_details(error_details: t.Sequence[Detail]) -> list[Detail]:
"""
Return a potentially modified error chain, with redundant errors collapsed into previous error(s) in the chain.
This reduces the verbosity of messages by eliminating repetition when multiple errors in the chain share the same contextual information.
"""
previous_error = error_details[0]
previous_warnings: list[str] = []
collapsed_error_details: list[tuple[Detail, list[str]]] = [(previous_error, previous_warnings)]
for error in error_details[1:]:
details_present = error.formatted_source_context or error.help_text
details_changed = error.formatted_source_context != previous_error.formatted_source_context or error.help_text != previous_error.help_text
if details_present and details_changed:
previous_error = error
previous_warnings = []
collapsed_error_details.append((previous_error, previous_warnings))
else:
previous_warnings.append(error.msg)
final_error_details: list[Detail] = []
for error, messages in collapsed_error_details:
final_error_details.append(dataclasses.replace(error, msg=_dedupe_and_concat_message_chain([error.msg] + messages)))
return final_error_details
def _get_cause(exception: BaseException) -> BaseException | None:
# deprecated: description='remove support for orig_exc (deprecated in 2.23)' core_version='2.27'
if not isinstance(exception, errors.AnsibleError):
return exception.__cause__
if exception.__cause__:
if exception.orig_exc and exception.orig_exc is not exception.__cause__:
_get_display().warning(
msg=f"The `orig_exc` argument to `{type(exception).__name__}` was given, but differed from the cause given by `raise ... from`.",
)
return exception.__cause__
if exception.orig_exc:
# encourage the use of `raise ... from` before deprecating `orig_exc`
_get_display().warning(msg=f"The `orig_exc` argument to `{type(exception).__name__}` was given without using `raise ... from orig_exc`.")
return exception.orig_exc
return None
class _TemporaryDisplay:
# DTFIX-FUTURE: generalize this and hide it in the display module so all users of Display can benefit
@staticmethod
def warning(*args, **kwargs):
print(f'FALLBACK WARNING: {args} {kwargs}', file=sys.stderr)
@staticmethod
def deprecated(*args, **kwargs):
print(f'FALLBACK DEPRECATION: {args} {kwargs}', file=sys.stderr)
def _get_display() -> Display | _TemporaryDisplay:
try:
from ansible.utils.display import Display
except ImportError:
return _TemporaryDisplay()
return Display()
def _create_error_summary(exception: BaseException, event: _traceback.TracebackEvent | None = None) -> ErrorSummary:
from . import _captured # avoid circular import due to AnsibleError import
current_exception: BaseException | None = exception
error_details: list[Detail] = []
if event:
formatted_traceback = _traceback.maybe_extract_traceback(exception, event)
else:
formatted_traceback = None
while current_exception:
if isinstance(current_exception, errors.AnsibleError):
include_cause_message = current_exception._include_cause_message
edc = Detail(
msg=current_exception._original_message.strip(),
formatted_source_context=current_exception._formatted_source_context,
help_text=current_exception._help_text,
)
else:
include_cause_message = True
edc = Detail(
msg=str(current_exception).strip(),
)
error_details.append(edc)
if isinstance(current_exception, _captured.AnsibleCapturedError):
detail = current_exception.error_summary
error_details.extend(detail.details)
if formatted_traceback and detail.formatted_traceback:
formatted_traceback = (
f'{detail.formatted_traceback}\n'
f'The {current_exception.context} exception above was the direct cause of the following controller exception:\n\n'
f'{formatted_traceback}'
)
if not include_cause_message:
break
current_exception = _get_cause(current_exception)
return ErrorSummary(details=tuple(error_details), formatted_traceback=formatted_traceback)
def concat_message(left: str, right: str) -> str:
"""Normalize `left` by removing trailing punctuation and spaces before appending new punctuation and `right`."""
return f'{left.rstrip(". ")}: {right}'
def get_chained_message(exception: BaseException) -> str:
"""
Return the full chain of exception messages by concatenating the cause(s) until all are exhausted.
"""
error_summary = _create_error_summary(exception)
message_parts = [edc.msg for edc in error_summary.details]
return _dedupe_and_concat_message_chain(message_parts)
def format_exception_message(exception: BaseException) -> str:
"""Return the full chain of exception messages by concatenating the cause(s) until all are exhausted."""
return _event_utils.format_event_brief_message(_error_factory.ControllerEventFactory.from_exception(exception, False))
@dataclasses.dataclass(kw_only=True, frozen=True)

@ -0,0 +1,127 @@
from __future__ import annotations as _annotations
import collections.abc as _c
import textwrap as _textwrap
from ansible.module_utils._internal import _event_utils, _messages
def format_event(event: _messages.Event, include_traceback: bool) -> str:
"""Format an event into a verbose message and traceback."""
msg = format_event_verbose_message(event)
if include_traceback:
msg += '\n' + format_event_traceback(event)
msg = msg.strip()
if '\n' in msg:
msg += '\n\n'
else:
msg += '\n'
return msg
def format_event_traceback(event: _messages.Event) -> str:
"""Format an event into a traceback."""
segments: list[str] = []
while event:
segment = event.formatted_traceback or '(traceback missing)\n'
if event.events:
child_tracebacks = [format_event_traceback(child) for child in event.events]
segment += _format_event_children("Sub-Traceback", child_tracebacks)
segments.append(segment)
if event.chain:
segments.append(f'\n{event.chain.traceback_reason}\n\n')
event = event.chain.event
else:
event = None
return ''.join(reversed(segments))
def format_event_verbose_message(event: _messages.Event) -> str:
"""
Format an event into a verbose message.
Help text, contextual information and sub-events will be included.
"""
segments: list[str] = []
original_event = event
while event:
messages = [event.msg]
chain: _messages.EventChain = event.chain
while chain and chain.follow:
if chain.event.events:
break # do not collapse a chained event with sub-events, since they would be lost
if chain.event.formatted_source_context or chain.event.help_text:
if chain.event.formatted_source_context != event.formatted_source_context or chain.event.help_text != event.help_text:
break # do not collapse a chained event with different details, since they would be lost
if chain.event.chain and chain.msg_reason != chain.event.chain.msg_reason:
break # do not collapse a chained event which has a chain with a different msg_reason
messages.append(chain.event.msg)
chain = chain.event.chain
msg = _event_utils.deduplicate_message_parts(messages)
segment = '\n'.join(_get_message_lines(msg, event.help_text, event.formatted_source_context)) + '\n'
if event.events:
child_msgs = [format_event_verbose_message(child) for child in event.events]
segment += _format_event_children("Sub-Event", child_msgs)
segments.append(segment)
if chain and chain.follow:
segments.append(f'\n{chain.msg_reason}\n\n')
event = chain.event
else:
event = None
if len(segments) > 1:
segments.insert(0, _event_utils.format_event_brief_message(original_event) + '\n\n')
return ''.join(segments)
def _format_event_children(label: str, children: _c.Iterable[str]) -> str:
"""Format the given list of child messages into a single string."""
items = list(children)
count = len(items)
lines = ['\n']
for idx, item in enumerate(items):
lines.append(f'+--[ {label} {idx + 1} of {count} ]---\n')
lines.append(_textwrap.indent(f"\n{item}\n", "| ", lambda value: True))
lines.append(f'+--[ End {label} ]---\n')
return ''.join(lines)
def _get_message_lines(message: str, help_text: str | None, formatted_source_context: str | None) -> list[str]:
"""Return a list of message lines constructed from the given message, help text and formatted source context."""
if help_text and not formatted_source_context and '\n' not in message and '\n' not in help_text:
return [f'{message} {help_text}'] # prefer a single-line message with help text when there is no source context
message_lines = [message]
if formatted_source_context:
message_lines.append(formatted_source_context)
if help_text:
message_lines.append('')
message_lines.append(help_text)
return message_lines

@ -144,20 +144,20 @@ class AnsibleVariableVisitor:
value = self._template_engine.transform(value)
value_type = type(value)
# DTFIX-RELEASE: need to handle native copy for keys too
# DTFIX3: need to handle native copy for keys too
if self.convert_to_native_values and isinstance(value, _datatag.AnsibleTaggedObject):
value = value._native_copy()
value_type = type(value)
result: _T
# DTFIX-RELEASE: the visitor is ignoring dict/mapping keys except for debugging and schema-aware checking, it should be doing type checks on keys
# DTFIX3: the visitor is ignoring dict/mapping keys except for debugging and schema-aware checking, it should be doing type checks on keys
# keep in mind the allowed types for keys is a more restrictive set than for values (str and tagged str only, not EncryptedString)
# DTFIX-RELEASE: some type lists being consulted (the ones from datatag) are probably too permissive, and perhaps should not be dynamic
# DTFIX5: some type lists being consulted (the ones from datatag) are probably too permissive, and perhaps should not be dynamic
if (result := self._early_visit(value, value_type)) is not _sentinel:
pass
# DTFIX-RELEASE: de-duplicate and optimize; extract inline generator expressions and fallback function or mapping for native type calculation?
# DTFIX7: de-duplicate and optimize; extract inline generator expressions and fallback function or mapping for native type calculation?
elif value_type in _ANSIBLE_ALLOWED_MAPPING_VAR_TYPES: # check mappings first, because they're also collections
with self: # supports StateTrackingMixIn
result = AnsibleTagHelper.tag_copy(value, ((k, self._visit(k, v)) for k, v in value.items()), value_type=value_type)

@ -12,7 +12,7 @@ from . import _legacy
class _InventoryVariableVisitor(_legacy._LegacyVariableVisitor, _json.StateTrackingMixIn):
"""State-tracking visitor implementation that only applies trust to `_meta.hostvars` and `vars` inventory values."""
# DTFIX-RELEASE: does the variable visitor need to support conversion of sequence/mapping for inventory?
# DTFIX5: does the variable visitor need to support conversion of sequence/mapping for inventory?
@property
def _allow_trust(self) -> bool:

@ -154,7 +154,7 @@ class _Profile(_profiles._JSONSerializationProfile["Encoder", "Decoder"]):
@classmethod
def pre_serialize(cls, encoder: Encoder, o: _t.Any) -> _t.Any:
# DTFIX-RELEASE: these conversion args probably aren't needed
# DTFIX7: these conversion args probably aren't needed
avv = cls.visitor_type(invert_trust=True, convert_mapping_to_dict=True, convert_sequence_to_list=True, convert_custom_scalars=True)
return avv.visit(o)
@ -170,9 +170,9 @@ class _Profile(_profiles._JSONSerializationProfile["Encoder", "Decoder"]):
if isinstance(k, str):
return k
# DTFIX-RELEASE: decide if this is a deprecation warning, error, or what?
# DTFIX3: decide if this is a deprecation warning, error, or what?
# Non-string variable names have been disallowed by set_fact and other things since at least 2021.
# DTFIX-RELEASE: document why this behavior is here, also verify the legacy tagless use case doesn't need this same behavior
# DTFIX5: document why this behavior is here, also verify the legacy tagless use case doesn't need this same behavior
return str(k)

@ -60,6 +60,7 @@ class DeprecatedAccessAuditContext(NotifiableAccessContextBase):
date=item.deprecated.date,
obj=item.template,
deprecator=item.deprecated.deprecator,
formatted_traceback=item.deprecated.formatted_traceback,
)
return result

@ -186,7 +186,7 @@ class TemplateEngine:
@property
def available_variables(self) -> dict[str, t.Any] | ChainMap[str, t.Any]:
"""Available variables this instance will use when templating."""
# DTFIX-RELEASE: ensure that we're always accessing this as a shallow container-level snapshot, and eliminate uses of anything
# DTFIX3: ensure that we're always accessing this as a shallow container-level snapshot, and eliminate uses of anything
# that directly mutates this value. _new_context may resolve this for us?
if self._variables is None:
self._variables = self._variables_factory() if self._variables_factory else {}

@ -502,7 +502,7 @@ def create_template_error(ex: Exception, variable: t.Any, is_expression: bool) -
return exception_to_raise
# DTFIX-RELEASE: implement CapturedExceptionMarker deferral support on call (and lookup), filter/test plugins, etc.
# DTFIX3: implement CapturedExceptionMarker deferral support on call (and lookup), filter/test plugins, etc.
# also update the protomatter integration test once this is done (the test was written differently since this wasn't done yet)
_BUILTIN_FILTER_ALIASES: dict[str, str] = {}
@ -975,7 +975,7 @@ def _finalize_list(o: t.Any, mode: FinalizeMode) -> t.Iterator[t.Any]:
def _maybe_finalize_scalar(o: t.Any) -> t.Any:
# DTFIX-RELEASE: this should check all supported scalar subclasses, not just JSON ones (also, does the JSON serializer handle these cases?)
# DTFIX5: this should check all supported scalar subclasses, not just JSON ones (also, does the JSON serializer handle these cases?)
for target_type in _json_subclassable_scalar_types:
if not isinstance(o, target_type):
continue
@ -1025,7 +1025,7 @@ def _finalize_collection(
def _finalize_template_result(o: t.Any, mode: FinalizeMode) -> t.Any:
"""Recurse the template result, rendering any encountered templates, converting containers to non-lazy versions."""
# DTFIX-RELEASE: add tests to ensure this method doesn't drift from allowed types
# DTFIX5: add tests to ensure this method doesn't drift from allowed types
o_type = type(o)
# DTFIX-FUTURE: provide an optional way to check for trusted templates leaking out of templating (injected, but not passed through templar.template)

@ -9,7 +9,7 @@ import typing as t
from jinja2 import UndefinedError, StrictUndefined, TemplateRuntimeError
from jinja2.utils import missing
from ansible.module_utils.common.messages import ErrorSummary, Detail
from ...module_utils._internal import _messages
from ansible.constants import config
from ansible.errors import AnsibleUndefinedVariable, AnsibleTypeError
from ansible._internal._errors._handler import ErrorHandler
@ -250,28 +250,18 @@ class UndecryptableVaultError(_captured.AnsibleCapturedError):
class VaultExceptionMarker(ExceptionMarker):
"""A `Marker` value that represents an error accessing a vaulted value during templating."""
__slots__ = ('_marker_undecryptable_ciphertext', '_marker_undecryptable_reason', '_marker_undecryptable_traceback')
__slots__ = ('_marker_undecryptable_ciphertext', '_marker_event')
def __init__(self, ciphertext: str, reason: str, traceback: str | None) -> None:
# DTFIX-FUTURE: when does this show up, should it contain more details?
# see also CapturedExceptionMarker for a similar issue
def __init__(self, ciphertext: str, event: _messages.Event) -> None:
super().__init__(hint='A vault exception marker was tripped.')
self._marker_undecryptable_ciphertext = ciphertext
self._marker_undecryptable_reason = reason
self._marker_undecryptable_traceback = traceback
self._marker_event = event
def _as_exception(self) -> Exception:
return UndecryptableVaultError(
obj=self._marker_undecryptable_ciphertext,
error_summary=ErrorSummary(
details=(
Detail(
msg=self._marker_undecryptable_reason,
),
),
formatted_traceback=self._marker_undecryptable_traceback,
),
event=self._marker_event,
)
def _disarm(self) -> str:
@ -280,7 +270,7 @@ class VaultExceptionMarker(ExceptionMarker):
def get_first_marker_arg(args: c.Sequence, kwargs: dict[str, t.Any]) -> Marker | None:
"""Utility method to inspect plugin args and return the first `Marker` encountered, otherwise `None`."""
# DTFIX-RELEASE: this may or may not need to be public API, move back to utils or once usage is wrapped in a decorator?
# DTFIX0: this may or may not need to be public API, move back to utils or once usage is wrapped in a decorator?
for arg in iter_marker_args(args, kwargs):
return arg
@ -289,7 +279,7 @@ def get_first_marker_arg(args: c.Sequence, kwargs: dict[str, t.Any]) -> Marker |
def iter_marker_args(args: c.Sequence, kwargs: dict[str, t.Any]) -> t.Generator[Marker]:
"""Utility method to iterate plugin args and yield any `Marker` encountered."""
# DTFIX-RELEASE: this may or may not need to be public API, move back to utils or once usage is wrapped in a decorator?
# DTFIX0: this may or may not need to be public API, move back to utils or once usage is wrapped in a decorator?
for arg in itertools.chain(args, kwargs.values()):
if isinstance(arg, Marker):
yield arg

@ -549,7 +549,7 @@ class _AnsibleLazyAccessTuple(_AnsibleTaggedTuple, _AnsibleLazyTemplateMixin):
created as a results of managed access.
"""
# DTFIX-RELEASE: ensure we have tests that explicitly verify this behavior
# DTFIX5: ensure we have tests that explicitly verify this behavior
# nonempty __slots__ not supported for subtype of 'tuple'

@ -5,38 +5,41 @@ from __future__ import annotations
import dataclasses
import typing as t
from ansible.module_utils._internal import _traceback
from ansible.module_utils.common.messages import PluginInfo, ErrorSummary, WarningSummary, DeprecationSummary
from ansible.module_utils._internal import _traceback, _event_utils, _messages
from ansible.parsing.vault import EncryptedString, VaultHelper
from ansible.utils.display import Display
from ._jinja_common import VaultExceptionMarker
from .._errors import _captured, _utils
from .._errors import _captured, _error_factory
from .. import _event_formatting
display = Display()
def plugin_info(value: PluginInfo) -> dict[str, str]:
def plugin_info(value: _messages.PluginInfo) -> dict[str, str]:
"""Render PluginInfo as a dictionary."""
return dataclasses.asdict(value)
def error_summary(value: ErrorSummary) -> str:
def error_summary(value: _messages.ErrorSummary) -> str:
"""Render ErrorSummary as a formatted traceback for backward-compatibility with pre-2.19 TaskResult.exception."""
return value.formatted_traceback or '(traceback unavailable)'
if _traceback._is_traceback_enabled(_traceback.TracebackEvent.ERROR):
return _event_formatting.format_event_traceback(value.event)
return '(traceback unavailable)'
def warning_summary(value: WarningSummary) -> str:
def warning_summary(value: _messages.WarningSummary) -> str:
"""Render WarningSummary as a simple message string for backward-compatibility with pre-2.19 TaskResult.warnings."""
return value._format()
return _event_utils.format_event_brief_message(value.event)
def deprecation_summary(value: DeprecationSummary) -> dict[str, t.Any]:
def deprecation_summary(value: _messages.DeprecationSummary) -> dict[str, t.Any]:
"""Render DeprecationSummary as dict values for backward-compatibility with pre-2.19 TaskResult.deprecations."""
result = value._as_simple_dict()
result.pop('details')
transformed = _event_utils.deprecation_as_dict(value)
transformed.update(deprecator=value.deprecator)
return result
return transformed
def encrypted_string(value: EncryptedString) -> str | VaultExceptionMarker:
@ -46,17 +49,16 @@ def encrypted_string(value: EncryptedString) -> str | VaultExceptionMarker:
except Exception as ex:
return VaultExceptionMarker(
ciphertext=VaultHelper.get_ciphertext(value, with_tags=True),
reason=_utils.get_chained_message(ex),
traceback=_traceback.maybe_extract_traceback(ex, _traceback.TracebackEvent.ERROR),
event=_error_factory.ControllerEventFactory.from_exception(ex, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR)),
)
_type_transform_mapping: dict[type, t.Callable[[t.Any], t.Any]] = {
_captured.CapturedErrorSummary: error_summary,
PluginInfo: plugin_info,
ErrorSummary: error_summary,
WarningSummary: warning_summary,
DeprecationSummary: deprecation_summary,
_messages.PluginInfo: plugin_info,
_messages.ErrorSummary: error_summary,
_messages.WarningSummary: warning_summary,
_messages.DeprecationSummary: deprecation_summary,
EncryptedString: encrypted_string,
}
"""This mapping is consulted by `Templar.template` to provide custom views of some objects."""

@ -99,7 +99,7 @@ Omit = object.__new__(_OmitType)
_datatag._untaggable_types.add(_OmitType)
# DTFIX-RELEASE: review these type sets to ensure they're not overly permissive/dynamic
# DTFIX5: review these type sets to ensure they're not overly permissive/dynamic
IGNORE_SCALAR_VAR_TYPES = {value for value in _datatag._ANSIBLE_ALLOWED_SCALAR_VAR_TYPES if not issubclass(value, str)}
PASS_THROUGH_SCALAR_VAR_TYPES = _datatag._ANSIBLE_ALLOWED_SCALAR_VAR_TYPES | {

@ -32,7 +32,7 @@ class _BaseDumper(SafeDumper, metaclass=abc.ABCMeta):
class AnsibleDumper(_BaseDumper):
"""A simple stub class that allows us to add representers for our custom types."""
# DTFIX-RELEASE: need a better way to handle serialization controls during YAML dumping
# DTFIX0: need a better way to handle serialization controls during YAML dumping
def __init__(self, *args, dump_vault_tags: bool | None = None, **kwargs):
super().__init__(*args, **kwargs)

@ -96,7 +96,7 @@ try:
display = Display()
except Exception as ex:
if isinstance(ex, AnsibleError):
ex_msg = ' '.join((ex.message, ex._help_text)).strip()
ex_msg = ' '.join((ex.message, ex._help_text or '')).strip()
else:
ex_msg = str(ex)
@ -639,7 +639,7 @@ class CLI(ABC):
try:
_launch_ssh_agent()
except Exception as e:
raise AnsibleError('Failed to launch ssh agent', orig_exc=e)
raise AnsibleError('Failed to launch ssh agent.') from e
# create the inventory, and filter it based on the subset specified (if any)
inventory = InventoryManager(loader=loader, sources=options['inventory'], cache=(not options.get('flush_cache')))

@ -1368,7 +1368,7 @@ class DocCLI(CLI, RoleMixin):
try:
text.append(yaml_dump(doc.pop('examples'), indent=2, default_flow_style=False))
except Exception as e:
raise AnsibleParserError("Unable to parse examples section", orig_exc=e)
raise AnsibleParserError("Unable to parse examples section.") from e
return text
@ -1406,7 +1406,7 @@ class DocCLI(CLI, RoleMixin):
try:
text.append('\t' + C.config.get_deprecated_msg_from_config(doc['deprecated'], True, collection_name=collection_name))
except KeyError as e:
raise AnsibleError("Invalid deprecation documentation structure", orig_exc=e)
raise AnsibleError("Invalid deprecation documentation structure.") from e
else:
text.append("%s" % doc['deprecated'])
del doc['deprecated']

@ -164,7 +164,7 @@ class InventoryCLI(CLI):
import yaml
from ansible.parsing.yaml.dumper import AnsibleDumper
# DTFIX-RELEASE: need shared infra to smuggle custom kwargs to dumpers, since yaml.dump cannot (as of PyYAML 6.0.1)
# DTFIX0: need shared infra to smuggle custom kwargs to dumpers, since yaml.dump cannot (as of PyYAML 6.0.1)
dumper = functools.partial(AnsibleDumper, dump_vault_tags=True)
results = to_text(yaml.dump(stuff, Dumper=dumper, default_flow_style=False, allow_unicode=True))
elif context.CLIARGS['toml']:

@ -1335,6 +1335,7 @@ DISPLAY_TRACEBACK:
- error
- warning
- deprecated
- deprecated_value
- always
- never
version_added: "2.19"

@ -17,6 +17,7 @@ from ansible.module_utils.common.text.converters import to_text
from ..module_utils.datatag import native_type_name
from ansible._internal._datatag import _tags
from .._internal._errors import _utils
from ansible.module_utils._internal import _text_utils
if t.TYPE_CHECKING:
from ansible.plugins import loader as _t_loader
@ -73,7 +74,7 @@ class AnsibleError(Exception):
message = str(message)
if self._default_message and message:
message = _utils.concat_message(self._default_message, message)
message = _text_utils.concat_message(self._default_message, message)
elif self._default_message:
message = self._default_message
elif not message:
@ -108,12 +109,10 @@ class AnsibleError(Exception):
@property
def message(self) -> str:
"""
If `include_cause_message` is False, return the original message.
Otherwise, return the original message with cause message(s) appended, stopping on (and including) the first non-AnsibleError.
The recursion is due to `AnsibleError.__str__` calling this method, which uses `str` on child exceptions to create the cause message.
Recursion stops on the first non-AnsibleError since those exceptions do not implement the custom `__str__` behavior.
Return the original message with cause message(s) appended.
The cause will not be followed on any `AnsibleError` with `_include_cause_message=False`.
"""
return _utils.get_chained_message(self)
return _utils.format_exception_message(self)
@message.setter
def message(self, val) -> None:

@ -40,6 +40,7 @@ from ansible._internal import _locking
from ansible._internal._datatag import _utils
from ansible.module_utils._internal import _dataclass_validation
from ansible.module_utils.common.yaml import yaml_load
from ansible.module_utils.datatag import deprecator_from_collection_name
from ansible._internal._datatag._tags import Origin
from ansible.module_utils.common.json import Direction, get_module_encoder
from ansible.release import __version__, __author__
@ -55,7 +56,6 @@ from ansible.template import Templar
from ansible.utils.collection_loader._collection_finder import _get_collection_metadata, _nested_dict_get
from ansible.module_utils._internal import _json, _ansiballz
from ansible.module_utils import basic as _basic
from ansible.module_utils.common import messages as _messages
if t.TYPE_CHECKING:
from ansible import template as _template
@ -439,7 +439,7 @@ class ModuleUtilLocatorBase:
version=removal_version,
removed=removed,
date=removal_date,
deprecator=_messages.PluginInfo._from_collection_name(self._collection_name),
deprecator=deprecator_from_collection_name(self._collection_name),
)
if 'redirect' in routing_entry:
self.redirected = True
@ -658,7 +658,7 @@ metadata_versions: dict[t.Any, type[ModuleMetadata]] = {
def _get_module_metadata(module: ast.Module) -> ModuleMetadata:
# DTFIX-RELEASE: while module metadata works, this feature isn't fully baked and should be turned off before release
# DTFIX2: while module metadata works, this feature isn't fully baked and should be turned off before release
metadata_nodes: list[ast.Assign] = []
for node in module.body:
@ -928,7 +928,7 @@ class _BuiltModule:
class _CachedModule:
"""Cached Python module created by AnsiballZ."""
# DTFIX-RELEASE: secure this (locked down pickle, don't use pickle, etc.)
# DTFIX5: secure this (locked down pickle, don't use pickle, etc.)
zip_data: bytes
metadata: ModuleMetadata

@ -22,8 +22,8 @@ from ansible.errors import (
)
from ansible.executor.task_result import _RawTaskResult
from ansible._internal._datatag import _utils
from ansible.module_utils.common.messages import Detail, WarningSummary, DeprecationSummary, PluginInfo
from ansible.module_utils.datatag import native_type_name
from ansible.module_utils._internal import _messages
from ansible.module_utils.datatag import native_type_name, deprecator_from_collection_name
from ansible._internal._datatag._tags import TrustedAsTemplate
from ansible.module_utils.parsing.convert_bool import boolean
from ansible.module_utils.common.text.converters import to_text, to_native
@ -642,7 +642,7 @@ class TaskExecutor:
result = self._handler.run(task_vars=vars_copy)
# DTFIX-RELEASE: nuke this, it hides a lot of error detail- remove the active exception propagation hack from AnsibleActionFail at the same time
# DTFIX0: nuke this, it hides a lot of error detail- remove the active exception propagation hack from AnsibleActionFail at the same time
except (AnsibleActionFail, AnsibleActionSkip) as e:
return e.result
except AnsibleConnectionFailure as e:
@ -825,11 +825,11 @@ class TaskExecutor:
if warnings := result.get('warnings'):
if isinstance(warnings, list):
for warning in warnings:
if not isinstance(warning, WarningSummary):
if not isinstance(warning, _messages.WarningSummary):
# translate non-WarningMessageDetail messages
warning = WarningSummary(
details=(
Detail(msg=str(warning)),
warning = _messages.WarningSummary(
event=_messages.Event(
msg=str(warning),
),
)
@ -840,18 +840,18 @@ class TaskExecutor:
if deprecations := result.get('deprecations'):
if isinstance(deprecations, list):
for deprecation in deprecations:
if not isinstance(deprecation, DeprecationSummary):
if not isinstance(deprecation, _messages.DeprecationSummary):
# translate non-DeprecationMessageDetail message dicts
try:
if (collection_name := deprecation.pop('collection_name', ...)) is not ...:
# deprecated: description='enable the deprecation message for collection_name' core_version='2.23'
# CAUTION: This deprecation cannot be enabled until the replacement (deprecator) has been documented, and the schema finalized.
# self.deprecated('The `collection_name` key in the `deprecations` dictionary is deprecated.', version='2.27')
deprecation.update(deprecator=PluginInfo._from_collection_name(collection_name))
deprecation.update(deprecator=deprecator_from_collection_name(collection_name))
deprecation = DeprecationSummary(
details=(
Detail(msg=deprecation.pop('msg')),
deprecation = _messages.DeprecationSummary(
event=_messages.Event(
msg=deprecation.pop('msg'),
),
**deprecation,
)

@ -12,7 +12,7 @@ import typing as t
from ansible import constants
from ansible.utils import vars as _vars
from ansible.vars.clean import module_response_deepcopy, strip_internal_keys
from ansible.module_utils.common import messages as _messages
from ansible.module_utils._internal import _messages
from ansible._internal import _collection_proxy
if t.TYPE_CHECKING:
@ -20,7 +20,7 @@ if t.TYPE_CHECKING:
from ansible.playbook.task import Task
_IGNORE = ('failed', 'skipped')
_PRESERVE = {'attempts', 'changed', 'retries', '_ansible_no_log'}
_PRESERVE = {'attempts', 'changed', 'retries', '_ansible_no_log', 'exception', 'warnings', 'deprecations'}
_SUB_PRESERVE = {'_ansible_delegated_vars': {'ansible_host', 'ansible_port', 'ansible_user', 'ansible_connection'}}
# stuff callbacks need
@ -230,7 +230,7 @@ class _RawTaskResult(_BaseTaskResult):
class CallbackTaskResult(_BaseTaskResult):
"""Public contract of TaskResult """
# DTFIX-RELEASE: find a better home for this since it's public API
# DTFIX1: find a better home for this since it's public API
@property
def _result(self) -> _c.MutableMapping[str, t.Any]:

@ -13,7 +13,7 @@ import runpy
import sys
import typing as t
from . import _errors
from . import _errors, _traceback, _messages
from .. import basic
from ..common.json import get_module_encoder, Direction
@ -97,7 +97,9 @@ def _handle_exception(exception: BaseException, profile: str) -> t.NoReturn:
"""Handle the given exception."""
result = dict(
failed=True,
exception=_errors.create_error_summary(exception),
exception=_messages.ErrorSummary(
event=_errors.EventFactory.from_exception(exception, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR)),
),
)
encoder = get_module_encoder(profile, Direction.MODULE_TO_CONTROLLER)

@ -340,6 +340,7 @@ class AnsibleSerializableDateTime(AnsibleSerializableWrapper[datetime.datetime])
@dataclasses.dataclass(**_tag_dataclass_kwargs)
class AnsibleSerializableDataclass(AnsibleSerializable, metaclass=abc.ABCMeta):
_validation_allow_subclasses = True
_validation_auto_enabled = True
def _as_dict(self) -> t.Dict[str, t.Any]:
# omit None values when None is the field default
@ -369,7 +370,11 @@ class AnsibleSerializableDataclass(AnsibleSerializable, metaclass=abc.ABCMeta):
def __init_subclass__(cls, **kwargs) -> None:
super(AnsibleSerializableDataclass, cls).__init_subclass__(**kwargs) # cannot use super() without arguments when using slots
if cls._validation_auto_enabled:
try:
_dataclass_validation.inject_post_init_validation(cls, cls._validation_allow_subclasses) # code gen a real __post_init__ method
except Exception as ex:
raise Exception(f'Validation code generation failed on {cls}.') from ex
class Tripwire:

@ -3,8 +3,7 @@ from __future__ import annotations
import dataclasses
import typing as t
from ansible.module_utils.common import messages as _messages
from ansible.module_utils._internal import _datatag
from ansible.module_utils._internal import _datatag, _messages
@dataclasses.dataclass(**_datatag._tag_dataclass_kwargs)
@ -14,3 +13,4 @@ class Deprecated(_datatag.AnsibleDatatagBase):
date: t.Optional[str] = None
version: t.Optional[str] = None
deprecator: t.Optional[_messages.PluginInfo] = None
formatted_traceback: t.Optional[str] = None

@ -1,79 +1,52 @@
from __future__ import annotations
import inspect
import re
import pathlib
import sys
import typing as t
from ansible.module_utils.common.messages import PluginInfo
from ansible.module_utils._internal import _stack, _messages, _validation
_ansible_module_base_path: t.Final = pathlib.Path(sys.modules['ansible'].__file__).parent
"""Runtime-detected base path of the `ansible` Python package to distinguish between Ansible-owned and external code."""
ANSIBLE_CORE_DEPRECATOR: t.Final = PluginInfo._from_collection_name('ansible.builtin')
"""Singleton `PluginInfo` instance for ansible-core callers where the plugin can/should not be identified in messages."""
def deprecator_from_collection_name(collection_name: str | None) -> _messages.PluginInfo | None:
"""Returns an instance with the special `collection` type to refer to a non-plugin or ambiguous caller within a collection."""
# CAUTION: This function is exposed in public API as ansible.module_utils.datatag.deprecator_from_collection_name.
INDETERMINATE_DEPRECATOR: t.Final = PluginInfo(resolved_name='indeterminate', type='indeterminate')
"""Singleton `PluginInfo` instance for indeterminate deprecator."""
if not collection_name:
return None
_DEPRECATOR_PLUGIN_TYPES = frozenset(
{
'action',
'become',
'cache',
'callback',
'cliconf',
'connection',
# doc_fragments - no code execution
# filter - basename inadequate to identify plugin
'httpapi',
'inventory',
'lookup',
'module', # only for collections
'netconf',
'shell',
'strategy',
'terminal',
# test - basename inadequate to identify plugin
'vars',
}
)
"""Plugin types which are valid for identifying a deprecator for deprecation purposes."""
_validation.validate_collection_name(collection_name)
_AMBIGUOUS_DEPRECATOR_PLUGIN_TYPES = frozenset(
{
'filter',
'test',
}
return _messages.PluginInfo(
resolved_name=collection_name,
type=_COLLECTION_ONLY_TYPE,
)
"""Plugin types for which basename cannot be used to identify the plugin name."""
def get_best_deprecator(*, deprecator: PluginInfo | None = None, collection_name: str | None = None) -> PluginInfo:
def get_best_deprecator(*, deprecator: _messages.PluginInfo | None = None, collection_name: str | None = None) -> _messages.PluginInfo:
"""Return the best-available `PluginInfo` for the caller of this method."""
_skip_stackwalk = True
if deprecator and collection_name:
raise ValueError('Specify only one of `deprecator` or `collection_name`.')
return deprecator or PluginInfo._from_collection_name(collection_name) or get_caller_plugin_info() or INDETERMINATE_DEPRECATOR
return deprecator or deprecator_from_collection_name(collection_name) or get_caller_plugin_info() or INDETERMINATE_DEPRECATOR
def get_caller_plugin_info() -> PluginInfo | None:
def get_caller_plugin_info() -> _messages.PluginInfo | None:
"""Try to get `PluginInfo` for the caller of this method, ignoring marked infrastructure stack frames."""
_skip_stackwalk = True
if frame_info := next((frame_info for frame_info in inspect.stack() if '_skip_stackwalk' not in frame_info.frame.f_locals), None):
if frame_info := _stack.caller_frame():
return _path_as_core_plugininfo(frame_info.filename) or _path_as_collection_plugininfo(frame_info.filename)
return None # pragma: nocover
def _path_as_core_plugininfo(path: str) -> PluginInfo | None:
def _path_as_core_plugininfo(path: str) -> _messages.PluginInfo | None:
"""Return a `PluginInfo` instance if the provided `path` refers to a core plugin."""
try:
relpath = str(pathlib.Path(path).relative_to(_ansible_module_base_path))
relpath = str(pathlib.Path(path).relative_to(_ANSIBLE_MODULE_BASE_PATH))
except ValueError:
return None # not ansible-core
@ -104,10 +77,10 @@ def _path_as_core_plugininfo(path: str) -> PluginInfo | None:
name = f'{namespace}.{plugin_name}'
return PluginInfo(resolved_name=name, type=plugin_type)
return _messages.PluginInfo(resolved_name=name, type=plugin_type)
def _path_as_collection_plugininfo(path: str) -> PluginInfo | None:
def _path_as_collection_plugininfo(path: str) -> _messages.PluginInfo | None:
"""Return a `PluginInfo` instance if the provided `path` refers to a collection plugin."""
if not (match := re.search(r'/ansible_collections/(?P<ns>\w+)/(?P<coll>\w+)/plugins/(?P<plugin_type>\w+)/(?P<plugin_name>\w+)', path)):
return None
@ -118,7 +91,7 @@ def _path_as_collection_plugininfo(path: str) -> PluginInfo | None:
# We're able to detect the namespace, collection and plugin type -- but we have no way to identify the plugin name currently.
# To keep things simple we'll fall back to just identifying the namespace and collection.
# In the future we could improve the detection and/or make it easier for a caller to identify the plugin name.
return PluginInfo._from_collection_name('.'.join((match.group('ns'), match.group('coll'))))
return deprecator_from_collection_name('.'.join((match.group('ns'), match.group('coll'))))
if plugin_type == 'modules':
plugin_type = 'module'
@ -131,4 +104,49 @@ def _path_as_collection_plugininfo(path: str) -> PluginInfo | None:
name = '.'.join((match.group('ns'), match.group('coll'), match.group('plugin_name')))
return PluginInfo(resolved_name=name, type=plugin_type)
return _messages.PluginInfo(resolved_name=name, type=plugin_type)
_COLLECTION_ONLY_TYPE: t.Final = 'collection'
"""Ersatz placeholder plugin type for use by a `PluginInfo` instance that references only a collection."""
_ANSIBLE_MODULE_BASE_PATH: t.Final = pathlib.Path(sys.modules['ansible'].__file__).parent
"""Runtime-detected base path of the `ansible` Python package to distinguish between Ansible-owned and external code."""
ANSIBLE_CORE_DEPRECATOR: t.Final = deprecator_from_collection_name('ansible.builtin')
"""Singleton `PluginInfo` instance for ansible-core callers where the plugin can/should not be identified in messages."""
INDETERMINATE_DEPRECATOR: t.Final = _messages.PluginInfo(resolved_name='indeterminate', type='indeterminate')
"""Singleton `PluginInfo` instance for indeterminate deprecator."""
_DEPRECATOR_PLUGIN_TYPES: t.Final = frozenset(
{
'action',
'become',
'cache',
'callback',
'cliconf',
'connection',
# doc_fragments - no code execution
# filter - basename inadequate to identify plugin
'httpapi',
'inventory',
'lookup',
'module', # only for collections
'netconf',
'shell',
'strategy',
'terminal',
# test - basename inadequate to identify plugin
'vars',
}
)
"""Plugin types which are valid for identifying a deprecator for deprecation purposes."""
_AMBIGUOUS_DEPRECATOR_PLUGIN_TYPES: t.Final = frozenset(
{
'filter',
'test',
}
)
"""Plugin types for which basename cannot be used to identify the plugin name."""

@ -3,28 +3,97 @@
"""Internal error handling logic for targets. Not for use on the controller."""
from __future__ import annotations
from __future__ import annotations as _annotations
from . import _traceback
from ..common.messages import Detail, ErrorSummary
import traceback as _sys_traceback
import typing as _t
from . import _messages
def create_error_summary(exception: BaseException) -> ErrorSummary:
"""Return an `ErrorDetail` created from the given exception."""
return ErrorSummary(
details=_create_error_details(exception),
formatted_traceback=_traceback.maybe_extract_traceback(exception, _traceback.TracebackEvent.ERROR),
MSG_REASON_DIRECT_CAUSE: _t.Final[str] = '<<< caused by >>>'
MSG_REASON_HANDLING_CAUSE: _t.Final[str] = '<<< while handling >>>'
class EventFactory:
"""Factory for creating `Event` instances from `BaseException` instances on targets."""
_MAX_DEPTH = 10
"""Maximum exception chain depth. Exceptions beyond this depth will be omitted."""
@classmethod
def from_exception(cls, exception: BaseException, include_traceback: bool) -> _messages.Event:
return cls(include_traceback)._convert_exception(exception)
def __init__(self, include_traceback: bool) -> None:
self._include_traceback = include_traceback
self._depth = 0
def _convert_exception(self, exception: BaseException) -> _messages.Event:
if self._depth > self._MAX_DEPTH:
return _messages.Event(
msg="Maximum depth exceeded, omitting further events.",
)
self._depth += 1
try:
return _messages.Event(
msg=self._get_msg(exception),
formatted_traceback=self._get_formatted_traceback(exception),
formatted_source_context=self._get_formatted_source_context(exception),
help_text=self._get_help_text(exception),
chain=self._get_chain(exception),
events=self._get_events(exception),
)
finally:
self._depth -= 1
def _get_msg(self, exception: BaseException) -> str | None:
return str(exception).strip()
def _get_formatted_traceback(self, exception: BaseException) -> str | None:
if self._include_traceback:
return ''.join(_sys_traceback.format_exception(type(exception), exception, exception.__traceback__, chain=False))
return None
def _get_formatted_source_context(self, exception: BaseException) -> str | None:
return None
def _get_help_text(self, exception: BaseException) -> str | None:
return None
def _get_chain(self, exception: BaseException) -> _messages.EventChain | None:
if cause := self._get_cause(exception):
return _messages.EventChain(
msg_reason=MSG_REASON_DIRECT_CAUSE,
traceback_reason='The above exception was the direct cause of the following exception:',
event=self._convert_exception(cause),
follow=self._follow_cause(exception),
)
if context := self._get_context(exception):
return _messages.EventChain(
msg_reason=MSG_REASON_HANDLING_CAUSE,
traceback_reason='During handling of the above exception, another exception occurred:',
event=self._convert_exception(context),
follow=False,
)
return None
def _follow_cause(self, exception: BaseException) -> bool:
return True
def _create_error_details(exception: BaseException) -> tuple[Detail, ...]:
"""Return an `ErrorMessage` tuple created from the given exception."""
target_exception: BaseException | None = exception
error_details: list[Detail] = []
def _get_cause(self, exception: BaseException) -> BaseException | None:
return exception.__cause__
while target_exception:
error_details.append(Detail(msg=str(target_exception).strip()))
def _get_context(self, exception: BaseException) -> BaseException | None:
if exception.__suppress_context__:
return None
target_exception = target_exception.__cause__
return exception.__context__
return tuple(error_details)
def _get_events(self, exception: BaseException) -> tuple[_messages.Event, ...] | None:
# deprecated: description='move BaseExceptionGroup support here from ControllerEventFactory' python_version='3.10'
return None

@ -0,0 +1,61 @@
from __future__ import annotations as _annotations
import typing as _t
from ansible.module_utils._internal import _text_utils, _messages
def deduplicate_message_parts(message_parts: list[str]) -> str:
"""Format the given list of messages into a brief message, while deduplicating repeated elements."""
message_parts = list(reversed(message_parts))
message = message_parts.pop(0)
for message_part in message_parts:
# avoid duplicate messages where the cause was already concatenated to the exception message
if message_part.endswith(message):
message = message_part
else:
message = _text_utils.concat_message(message_part, message)
return message
def format_event_brief_message(event: _messages.Event) -> str:
"""
Format an event into a brief message.
Help text, contextual information and sub-events will be omitted.
"""
message_parts: list[str] = []
while True:
message_parts.append(event.msg)
if not event.chain or not event.chain.follow:
break
event = event.chain.event
return deduplicate_message_parts(message_parts)
def deprecation_as_dict(deprecation: _messages.DeprecationSummary) -> _t.Dict[str, _t.Any]:
"""Returns a dictionary representation of the deprecation object in the format exposed to playbooks."""
from ansible.module_utils._internal._deprecator import INDETERMINATE_DEPRECATOR # circular import from messages
if deprecation.deprecator and deprecation.deprecator != INDETERMINATE_DEPRECATOR:
collection_name = '.'.join(deprecation.deprecator.resolved_name.split('.')[:2])
else:
collection_name = None
result = dict(
msg=format_event_brief_message(deprecation.event),
collection_name=collection_name,
)
if deprecation.date:
result.update(date=deprecation.date)
else:
result.update(version=deprecation.version)
return result

@ -6,7 +6,7 @@ import json
import typing as t
from ansible.module_utils import _internal
from ansible.module_utils.common import messages as _messages
from ansible.module_utils._internal import _messages
from ansible.module_utils._internal._datatag import (
AnsibleSerializable,
AnsibleSerializableWrapper,
@ -87,7 +87,8 @@ For controller-to-module, type behavior is profile dependent.
_common_module_response_types: frozenset[type[AnsibleSerializable]] = frozenset(
{
_messages.PluginInfo,
_messages.Detail,
_messages.Event,
_messages.EventChain,
_messages.ErrorSummary,
_messages.WarningSummary,
_messages.DeprecationSummary,
@ -375,7 +376,7 @@ Future code changes should further restrict bytes to string conversions to elimi
Additional warnings at other boundaries may be needed to give users an opportunity to resolve the issues before they become errors.
"""
# DTFIX-FUTURE: add strict UTF8 string encoding checking to serialization profiles (to match the checks performed during deserialization)
# DTFIX-RELEASE: the surrogateescape note above isn't quite right, for encoding use surrogatepass, which does work
# DTFIX3: the surrogateescape note above isn't quite right, for encoding use surrogatepass, which does work
# DTFIX-FUTURE: this config setting should probably be deprecated

@ -17,7 +17,7 @@ class _Profile(_profiles._JSONSerializationProfile["Encoder", "Decoder"]):
@classmethod
def post_init(cls) -> None:
cls.serialize_map = {
# DTFIX-RELEASE: support serialization of every type that is supported in the Ansible variable type system
# DTFIX5: support serialization of every type that is supported in the Ansible variable type system
set: cls.serialize_as_list,
tuple: cls.serialize_as_list,
_datetime.date: cls.serialize_as_isoformat,

@ -7,13 +7,11 @@ A future release will remove the provisional status.
from __future__ import annotations as _annotations
import sys as _sys
import dataclasses as _dataclasses
import sys as _sys
import typing as _t
# deprecated: description='typing.Self exists in Python 3.11+' python_version='3.10'
from ..compat import typing as _t
from ansible.module_utils._internal import _datatag, _validation
from ansible.module_utils._internal import _datatag, _dataclass_validation
if _sys.version_info >= (3, 10):
# Using slots for reduced memory usage and improved performance.
@ -29,50 +27,50 @@ class PluginInfo(_datatag.AnsibleSerializableDataclass):
resolved_name: str
"""The resolved canonical plugin name; always fully-qualified for collection plugins."""
type: str
"""The plugin type."""
_COLLECTION_ONLY_TYPE: _t.ClassVar[str] = 'collection'
"""This is not a real plugin type. It's a placeholder for use by a `PluginInfo` instance which references a collection without a plugin."""
@classmethod
def _from_collection_name(cls, collection_name: str | None) -> _t.Self | None:
"""Returns an instance with the special `collection` type to refer to a non-plugin or ambiguous caller within a collection."""
if not collection_name:
return None
@_dataclasses.dataclass(**_dataclass_kwargs)
class EventChain(_datatag.AnsibleSerializableDataclass):
"""A chain used to link one event to another."""
_validation_auto_enabled = False
_validation.validate_collection_name(collection_name)
def __post_init__(self): ... # required for deferred dataclass validation
return cls(
resolved_name=collection_name,
type=cls._COLLECTION_ONLY_TYPE,
)
msg_reason: str
traceback_reason: str
event: Event
follow: bool = True
@_dataclasses.dataclass(**_dataclass_kwargs)
class Detail(_datatag.AnsibleSerializableDataclass):
"""Message detail with optional source context and help text."""
class Event(_datatag.AnsibleSerializableDataclass):
"""Base class for an error/warning/deprecation event with optional chain (from an exception __cause__ chain) and an optional traceback."""
_validation_auto_enabled = False
def __post_init__(self): ... # required for deferred dataclass validation
msg: str
formatted_source_context: _t.Optional[str] = None
formatted_traceback: _t.Optional[str] = None
help_text: _t.Optional[str] = None
chain: _t.Optional[EventChain] = None
events: _t.Optional[_t.Tuple[Event, ...]] = None
_dataclass_validation.inject_post_init_validation(EventChain, EventChain._validation_allow_subclasses)
_dataclass_validation.inject_post_init_validation(Event, Event._validation_allow_subclasses)
@_dataclasses.dataclass(**_dataclass_kwargs)
class SummaryBase(_datatag.AnsibleSerializableDataclass):
"""Base class for an error/warning/deprecation summary with details (possibly derived from an exception __cause__ chain) and an optional traceback."""
details: _t.Tuple[Detail, ...]
formatted_traceback: _t.Optional[str] = None
def _format(self) -> str:
"""Returns a string representation of the details."""
# DTFIX-FUTURE: eliminate this function and use a common message squashing utility such as get_chained_message on instances of this type
return ': '.join(detail.msg for detail in self.details)
def _post_validate(self) -> None:
if not self.details:
raise ValueError(f'{type(self).__name__}.details cannot be empty')
event: Event
@_dataclasses.dataclass(**_dataclass_kwargs)
@ -106,20 +104,3 @@ class DeprecationSummary(WarningSummary):
Ignored if `deprecator` is not provided.
Ignored if `date` is provided.
"""
def _as_simple_dict(self) -> _t.Dict[str, _t.Any]:
"""Returns a dictionary representation of the deprecation object in the format exposed to playbooks."""
from ansible.module_utils._internal._deprecator import INDETERMINATE_DEPRECATOR # circular import from messages
if self.deprecator and self.deprecator != INDETERMINATE_DEPRECATOR:
collection_name = '.'.join(self.deprecator.resolved_name.split('.')[:2])
else:
collection_name = None
result = self._as_dict()
result.update(
msg=self._format(),
collection_name=collection_name,
)
return result

@ -2,7 +2,7 @@ from __future__ import annotations
import typing as t
from ..common import messages as _messages
from . import _messages
class HasPluginInfo(t.Protocol):

@ -0,0 +1,22 @@
from __future__ import annotations as _annotations
import inspect as _inspect
import typing as _t
def caller_frame() -> _inspect.FrameInfo | None:
"""Return the caller stack frame, skipping any marked with the `_skip_stackwalk` local."""
_skip_stackwalk = True
return next(iter_stack(), None)
def iter_stack() -> _t.Generator[_inspect.FrameInfo]:
"""Iterate over stack frames, skipping any marked with the `_skip_stackwalk` local."""
_skip_stackwalk = True
for frame_info in _inspect.stack():
if '_skip_stackwalk' in frame_info.frame.f_locals:
continue
yield frame_info

@ -0,0 +1,6 @@
from __future__ import annotations as _annotations
def concat_message(left: str, right: str) -> str:
"""Normalize `left` by removing trailing punctuation and spaces before appending new punctuation and `right`."""
return f'{left.rstrip(". ")}: {right}'

@ -6,9 +6,10 @@
from __future__ import annotations
import enum
import inspect
import traceback
from . import _stack
class TracebackEvent(enum.Enum):
"""The events for which tracebacks can be enabled."""
@ -16,6 +17,7 @@ class TracebackEvent(enum.Enum):
ERROR = enum.auto()
WARNING = enum.auto()
DEPRECATED = enum.auto()
DEPRECATED_VALUE = enum.auto() # implies DEPRECATED
def traceback_for() -> list[str]:
@ -31,21 +33,21 @@ def is_traceback_enabled(event: TracebackEvent) -> bool:
def maybe_capture_traceback(event: TracebackEvent) -> str | None:
"""
Optionally capture a traceback for the current call stack, formatted as a string, if the specified traceback event is enabled.
The current and previous frames are omitted to mask the expected call pattern from error/warning handlers.
Frames marked with the `_skip_stackwalk` local are omitted.
"""
_skip_stackwalk = True
if not is_traceback_enabled(event):
return None
tb_lines = []
if current_frame := inspect.currentframe():
if frame_info := _stack.caller_frame():
# DTFIX-FUTURE: rewrite target-side tracebacks to point at controller-side paths?
frames = inspect.getouterframes(current_frame)
ignore_frame_count = 2 # ignore this function and its caller
tb_lines.append('Traceback (most recent call last):\n')
tb_lines.extend(traceback.format_stack(frames[ignore_frame_count].frame))
tb_lines.extend(traceback.format_stack(frame_info.frame))
else:
tb_lines.append('Traceback unavailable.\n')
tb_lines.append('(frame not found)\n') # pragma: nocover
return ''.join(tb_lines)

@ -75,7 +75,7 @@ except ImportError:
# Python2 & 3 way to get NoneType
NoneType = type(None)
from ._internal import _traceback, _errors, _debugging, _deprecator
from ._internal import _traceback, _errors, _debugging, _deprecator, _messages
from .common.text.converters import (
to_native,
@ -161,7 +161,6 @@ from ansible.module_utils.common.validation import (
safe_eval,
)
from ansible.module_utils.common._utils import get_all_subclasses as _get_all_subclasses
from ansible.module_utils.common import messages as _messages
from ansible.module_utils.parsing.convert_bool import BOOLEANS, BOOLEANS_FALSE, BOOLEANS_TRUE, boolean
from ansible.module_utils.common.warnings import (
deprecate,
@ -1528,16 +1527,25 @@ class AnsibleModule(object):
)
if isinstance(exception, BaseException):
# Include a `_messages.ErrorDetail` in the result.
# The `msg` is included in the list of errors to ensure it is not lost when looking only at `exception` from the result.
# Include a `_messages.Event` in the result.
# The `msg` is included in the chain to ensure it is not lost when looking only at `exception` from the result.
error_summary = _errors.create_error_summary(exception)
error_summary = _dataclasses.replace(error_summary, details=(_messages.Detail(msg=msg),) + error_summary.details)
kwargs.update(exception=error_summary)
kwargs.update(
exception=_messages.ErrorSummary(
event=_messages.Event(
msg=msg,
formatted_traceback=_traceback.maybe_capture_traceback(_traceback.TracebackEvent.ERROR),
chain=_messages.EventChain(
msg_reason=_errors.MSG_REASON_DIRECT_CAUSE,
traceback_reason="The above exception was the direct cause of the following error:",
event=_errors.EventFactory.from_exception(exception, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR)),
),
),
),
)
elif _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR):
# Include only a formatted traceback string in the result.
# The controller will combine this with `msg` to create an `_messages.ErrorDetail`.
# The controller will combine this with `msg` to create an `_messages.ErrorSummary`.
formatted_traceback: str | None

@ -6,6 +6,7 @@ from __future__ import annotations
from copy import deepcopy
from ansible.module_utils.datatag import deprecator_from_collection_name
from ansible.module_utils.common.parameters import (
_ADDITIONAL_CHECKS,
_get_legal_inputs,
@ -22,7 +23,6 @@ from ansible.module_utils.common.parameters import (
from ansible.module_utils.common.text.converters import to_native
from ansible.module_utils.common.warnings import deprecate, warn
from ansible.module_utils.common import messages as _messages
from ansible.module_utils.common.validation import (
check_mutually_exclusive,
@ -306,7 +306,7 @@ class ModuleArgumentSpecValidator(ArgumentSpecValidator):
msg=d['msg'],
version=d.get('version'),
date=d.get('date'),
deprecator=_messages.PluginInfo._from_collection_name(d.get('collection_name')),
deprecator=deprecator_from_collection_name(d.get('collection_name')),
)
for w in result._warnings:

@ -6,20 +6,38 @@ from __future__ import annotations as _annotations
import typing as _t
from ansible.module_utils._internal import _traceback, _deprecator
from ansible.module_utils.common import messages as _messages
from ansible.module_utils._internal import _traceback, _deprecator, _event_utils, _messages
from ansible.module_utils import _internal
def warn(warning: str) -> None:
def warn(
warning: str,
*,
help_text: str | None = None,
obj: object | None = None,
) -> None:
"""Record a warning to be returned with the module result."""
# DTFIX-RELEASE: shim to controller display warning like `deprecate`
_global_warnings[_messages.WarningSummary(
details=(
_messages.Detail(msg=warning),
),
_skip_stackwalk = True
if _internal.is_controller:
_display = _internal.import_controller_module('ansible.utils.display').Display()
_display.warning(
msg=warning,
help_text=help_text,
obj=obj,
)
return
warning = _messages.WarningSummary(
event=_messages.Event(
msg=warning,
help_text=help_text,
formatted_traceback=_traceback.maybe_capture_traceback(_traceback.TracebackEvent.WARNING),
)] = None
),
)
_global_warnings[warning] = None
def deprecate(
@ -57,30 +75,30 @@ def deprecate(
return
_global_deprecations[_messages.DeprecationSummary(
details=(
_messages.Detail(msg=msg, help_text=help_text),
),
warning = _messages.DeprecationSummary(
event=_messages.Event(
msg=msg,
help_text=help_text,
formatted_traceback=_traceback.maybe_capture_traceback(_traceback.TracebackEvent.DEPRECATED),
),
version=version,
date=date,
deprecator=deprecator,
)] = None
)
_global_deprecations[warning] = None
def get_warning_messages() -> tuple[str, ...]:
"""Return a tuple of warning messages accumulated over this run."""
# DTFIX-RELEASE: add future deprecation comment
return tuple(item._format() for item in _global_warnings)
_DEPRECATION_MESSAGE_KEYS = frozenset({'msg', 'date', 'version', 'collection_name'})
# DTFIX7: add future deprecation comment
return tuple(_event_utils.format_event_brief_message(item.event) for item in _global_warnings)
def get_deprecation_messages() -> tuple[dict[str, _t.Any], ...]:
"""Return a tuple of deprecation warning messages accumulated over this run."""
# DTFIX-RELEASE: add future deprecation comment
return tuple({key: value for key, value in item._as_simple_dict().items() if key in _DEPRECATION_MESSAGE_KEYS} for item in _global_deprecations)
# DTFIX7: add future deprecation comment
return tuple(_event_utils.deprecation_as_dict(item) for item in _global_deprecations)
def get_warnings() -> list[_messages.WarningSummary]:
@ -94,7 +112,7 @@ def get_deprecations() -> list[_messages.DeprecationSummary]:
_global_warnings: dict[_messages.WarningSummary, object] = {}
"""Global, ordered, de-deplicated storage of acculumated warnings for the current module run."""
"""Global, ordered, de-duplicated storage of accumulated warnings for the current module run."""
_global_deprecations: dict[_messages.DeprecationSummary, object] = {}
"""Global, ordered, de-deplicated storage of acculumated deprecations for the current module run."""
"""Global, ordered, de-duplicated storage of accumulated deprecations for the current module run."""

@ -3,13 +3,15 @@ from __future__ import annotations as _annotations
import typing as _t
from ._internal import _datatag, _deprecator
from ._internal import _datatag, _deprecator, _traceback, _messages
from ._internal._datatag import _tags
from .common import messages as _messages
_T = _t.TypeVar('_T')
deprecator_from_collection_name = _deprecator.deprecator_from_collection_name
def deprecate_value(
value: _T,
msg: str,
@ -36,6 +38,7 @@ def deprecate_value(
date=date,
version=version,
deprecator=_deprecator.get_best_deprecator(deprecator=deprecator, collection_name=collection_name),
formatted_traceback=_traceback.maybe_capture_traceback(_traceback.TracebackEvent.DEPRECATED_VALUE),
)
return deprecated.tag(value)

@ -6,7 +6,7 @@ from __future__ import annotations as _annotations
# from ansible.utils.display import Display as _Display
# DTFIX-RELEASE: The pylint deprecated checker does not detect `Display().deprecated` calls, of which we have many.
# DTFIX1: The pylint deprecated checker does not detect `Display().deprecated` calls, of which we have many.
# deprecated: description='deprecate ajson' core_version='2.23'
# _Display().deprecated(

@ -160,7 +160,7 @@ class ModuleArgsParser:
final_args = dict()
if additional_args:
if isinstance(additional_args, (str, EncryptedString)):
# DTFIX-RELEASE: should this be is_possibly_template?
# DTFIX5: should this be is_possibly_template?
if TemplateEngine().is_template(additional_args):
final_args['_variable_params'] = additional_args
else:

@ -149,7 +149,7 @@ def _parse_vaulttext_envelope(b_vaulttext_envelope, default_vault_id=None):
vault_id = to_text(b_tmpheader[3].strip())
b_ciphertext = b''.join(b_tmpdata[1:])
# DTFIX-RELEASE: possible candidate for propagate_origin
# DTFIX7: possible candidate for propagate_origin
b_ciphertext = AnsibleTagHelper.tag_copy(b_vaulttext_envelope, b_ciphertext)
return b_ciphertext, b_version, cipher_name, vault_id
@ -222,7 +222,7 @@ def format_vaulttext_envelope(b_ciphertext, cipher_name, version=None, vault_id=
def _unhexlify(b_data):
try:
# DTFIX-RELEASE: possible candidate for propagate_origin
# DTFIX7: possible candidate for propagate_origin
return AnsibleTagHelper.tag_copy(b_data, unhexlify(b_data))
except (BinasciiError, TypeError) as ex:
raise AnsibleVaultFormatError('Vault format unhexlify error.', obj=b_data) from ex
@ -712,7 +712,7 @@ class VaultLib:
# secret = self.secrets[vault_secret_id]
display.vvvv(u'Trying secret %s for vault_id=%s' % (to_text(vault_secret), to_text(vault_secret_id)))
b_plaintext = this_cipher.decrypt(b_vaulttext, vault_secret)
# DTFIX-RELEASE: possible candidate for propagate_origin
# DTFIX7: possible candidate for propagate_origin
b_plaintext = AnsibleTagHelper.tag_copy(vaulttext, b_plaintext)
if b_plaintext is not None:
vault_id_used = vault_secret_id

@ -164,5 +164,5 @@ class PlaybookInclude(Base, Conditional, Taggable):
if len(items) == 0:
raise AnsibleParserError("import_playbook statements must specify the file name to import", obj=ds)
# DTFIX-RELEASE: investigate this as a possible "problematic strip"
# DTFIX3: investigate this as a possible "problematic strip"
new_ds['import_playbook'] = AnsibleTagHelper.tag_copy(v, items[0].strip())

@ -45,9 +45,9 @@ class Taggable:
return ds
if isinstance(ds, str):
# DTFIX-RELEASE: this allows each individual tag to be templated, but prevents the use of commas in templates, is that what we want?
# DTFIX-RELEASE: this can return empty tags (including a list of nothing but empty tags), is that correct?
# DTFIX-RELEASE: the original code seemed to attempt to preserve `ds` if there were no commas, but it never ran, what should it actually do?
# DTFIX0: this allows each individual tag to be templated, but prevents the use of commas in templates, is that what we want?
# DTFIX0: this can return empty tags (including a list of nothing but empty tags), is that correct?
# DTFIX0: the original code seemed to attempt to preserve `ds` if there were no commas, but it never ran, what should it actually do?
return [AnsibleTagHelper.tag_copy(ds, item.strip()) for item in ds.split(',')]
raise AnsibleError('tags must be specified as a list', obj=ds)

@ -20,12 +20,11 @@ from abc import ABC, abstractmethod
from collections.abc import Sequence
from ansible import constants as C
from ansible._internal._errors import _captured
from ansible._internal._errors import _captured, _error_factory
from ansible.errors import AnsibleError, AnsibleConnectionFailure, AnsibleActionSkip, AnsibleActionFail, AnsibleAuthenticationFailure
from ansible._internal._errors import _utils
from ansible.executor.module_common import modify_module, _BuiltModule
from ansible.executor.interpreter_discovery import discover_interpreter, InterpreterDiscoveryRequiredError
from ansible.module_utils._internal import _traceback
from ansible.module_utils._internal import _traceback, _event_utils, _messages
from ansible.module_utils.common.arg_spec import ArgumentSpecValidator
from ansible.module_utils.errors import UnsupportedError
from ansible.module_utils.json_utils import _filter_non_json_lines
@ -41,7 +40,6 @@ from ansible import _internal
from ansible._internal._templating import _engine
from .. import _AnsiblePluginInfoMixin
from ...module_utils.common.messages import PluginInfo
display = Display()
@ -1445,14 +1443,41 @@ class ActionBase(ABC, _AnsiblePluginInfoMixin):
else:
result = {}
error_summary = _utils._create_error_summary(exception, _traceback.TracebackEvent.ERROR)
event = _error_factory.ControllerEventFactory.from_exception(exception, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR))
result.update(
failed=True,
exception=error_summary,
exception=_messages.ErrorSummary(
event=event,
),
)
if 'msg' not in result:
result.update(msg=_utils._dedupe_and_concat_message_chain([md.msg for md in error_summary.details]))
result.update(msg=_event_utils.format_event_brief_message(event))
return result
def _result_dict_from_captured_errors(
self,
msg: str,
*,
errors: list[_messages.ErrorSummary] | None = None,
) -> dict[str, t.Any]:
"""Return a failed task result dict from the given error message and captured errors."""
_skip_stackwalk = True
event = _messages.Event(
msg=msg,
formatted_traceback=_traceback.maybe_capture_traceback(_traceback.TracebackEvent.ERROR),
events=tuple(error.event for error in errors) if errors else None,
)
result = dict(
failed=True,
exception=_messages.ErrorSummary(
event=event,
),
msg=_event_utils.format_event_brief_message(event),
)
return result

@ -127,8 +127,6 @@ class ActionModule(ActionBase):
# TODO: use gather_timeout to cut module execution if module itself does not support gather_timeout
res = self._execute_module(module_name=fact_module, module_args=mod_args, task_vars=task_vars, wrap_async=False)
if res.get('failed', False):
# DTFIX-RELEASE: this trashes the individual failure details and does not work with the new error handling; need to do something to
# invoke per-item error handling- perhaps returning this as a synthetic loop result?
failed[fact_module] = res
elif res.get('skipped', False):
skipped[fact_module] = res
@ -161,8 +159,6 @@ class ActionModule(ActionBase):
res = self._execute_module(module_name='ansible.legacy.async_status', module_args=poll_args, task_vars=task_vars, wrap_async=False)
if res.get('finished', 0) == 1:
if res.get('failed', False):
# DTFIX-RELEASE: this trashes the individual failure details and does not work with the new error handling; need to do something to
# invoke per-item error handling- perhaps returning this as a synthetic loop result?
failed[module] = res
elif res.get('skipped', False):
skipped[module] = res
@ -180,16 +176,19 @@ class ActionModule(ActionBase):
self._task.async_val = async_val
if skipped:
result['msg'] = "The following modules were skipped: %s\n" % (', '.join(skipped.keys()))
result['msg'] = f"The following modules were skipped: {', '.join(skipped.keys())}."
result['skipped_modules'] = skipped
if len(skipped) == len(modules):
result['skipped'] = True
if failed:
result['failed'] = True
result['msg'] = "The following modules failed to execute: %s\n" % (', '.join(failed.keys()))
result['failed_modules'] = failed
result.update(self._result_dict_from_captured_errors(
msg=f"The following modules failed to execute: {', '.join(failed.keys())}.",
errors=[r['exception'] for r in failed.values()],
))
# tell executor facts were gathered
result['ansible_facts']['_ansible_facts_gathered'] = True

@ -280,7 +280,7 @@ class CallbackBase(AnsiblePlugin):
# that want to further modify the result, or use custom serialization
return abridged_result
# DTFIX-RELEASE: Switch to stock json/yaml serializers here? We should always have a transformed plain-types result.
# DTFIX0: Switch to stock json/yaml serializers here? We should always have a transformed plain-types result.
if result_format == 'json':
return json.dumps(abridged_result, cls=_fallback_to_str.Encoder, indent=indent, ensure_ascii=False, sort_keys=sort_keys)
@ -310,7 +310,7 @@ class CallbackBase(AnsiblePlugin):
' ' * (indent or 4)
)
# DTFIX-RELEASE: add test to exercise this case
# DTFIX5: add test to exercise this case
raise ValueError(f'Unsupported result_format {result_format!r}.')
def _handle_warnings(self, res: _c.MutableMapping[str, t.Any]) -> None:
@ -318,7 +318,7 @@ class CallbackBase(AnsiblePlugin):
if res.pop('warnings', None) and self._current_task_result and (warnings := self._current_task_result.warnings):
# display warnings from the current task result if `warnings` was not removed from `result` (or made falsey)
for warning in warnings:
# DTFIX-RELEASE: what to do about propagating wrap_text from the original display.warning call?
# DTFIX3: what to do about propagating wrap_text from the original display.warning call?
self._display._warning(warning, wrap_text=False)
if res.pop('deprecations', None) and self._current_task_result and (deprecations := self._current_task_result.deprecations):
@ -333,7 +333,7 @@ class CallbackBase(AnsiblePlugin):
def _handle_warnings_and_exception(self, result: CallbackTaskResult) -> None:
"""Standardized handling of warnings/deprecations and exceptions from a task/item result."""
# DTFIX-RELEASE: make/doc/porting-guide a public version of this method?
# DTFIX5: make/doc/porting-guide a public version of this method?
try:
use_stderr = self.get_option('display_failed_stderr')
except KeyError:
@ -374,7 +374,7 @@ class CallbackBase(AnsiblePlugin):
' '
)
# DTFIX-RELEASE: add test to exercise this case
# DTFIX5: add test to exercise this case
raise ValueError(f'Unsupported result_format {result_format!r}.')
def _get_diff(self, difflist):

@ -90,6 +90,8 @@ import typing as t
from ansible import constants
from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible.module_utils._internal import _event_utils
from ansible._internal import _event_formatting
from ansible.playbook.task import Task
from ansible.plugins.callback import CallbackBase
from ansible.executor.task_result import CallbackTaskResult
@ -248,8 +250,8 @@ class CallbackModule(CallbackBase):
if host_data.status == 'failed':
if error_summary := task_result.exception:
message = error_summary._format()
output = error_summary.formatted_traceback
message = _event_utils.format_event_brief_message(error_summary.event)
output = _event_formatting.format_event_traceback(error_summary.event)
test_case.errors.append(TestError(message=message, output=output))
elif 'msg' in res:
message = res['msg']

@ -879,10 +879,7 @@ class Connection(ConnectionBase):
try:
key = self._populate_agent()
except Exception as e:
raise AnsibleAuthenticationFailure(
'Failed to add configured private key into ssh-agent',
orig_exc=e,
)
raise AnsibleAuthenticationFailure('Failed to add configured private key into ssh-agent.') from e
b_args = (b'-o', b'IdentitiesOnly=yes', b'-o', to_bytes(f'IdentityFile="{key}"', errors='surrogate_or_strict'))
self._add_args(b_command, b_args, "ANSIBLE_PRIVATE_KEY/private_key set")
elif key := self.get_option('private_key_file'):

@ -806,7 +806,7 @@ class FilterModule(object):
'groupby': _cleansed_groupby,
# Jinja builtins that need special arg handling
# DTFIX-RELEASE: document these now that they're overridden, or hide them so they don't show up as undocumented
# DTFIX1: document these now that they're overridden, or hide them so they don't show up as undocumented
'd': ansible_default, # replaces the implementation instead of wrapping it
'default': ansible_default, # replaces the implementation instead of wrapping it
'map': wrapped_map,
@ -816,4 +816,4 @@ class FilterModule(object):
'rejectattr': wrapped_rejectattr,
}
# DTFIX-RELEASE: document protomatter plugins, or hide them from ansible-doc/galaxy (not related to this code, but needed some place to put this comment)
# DTFIX1: document protomatter plugins, or hide them from ansible-doc/galaxy (not related to this code, but needed some place to put this comment)

@ -26,6 +26,7 @@ from ansible import __version__ as ansible_version
from ansible import _internal, constants as C
from ansible.errors import AnsibleError, AnsiblePluginCircularRedirect, AnsiblePluginRemovedError, AnsibleCollectionUnsupportedVersionError
from ansible.module_utils.common.text.converters import to_bytes, to_text, to_native
from ansible.module_utils.datatag import deprecator_from_collection_name
from ansible.module_utils.six import string_types
from ansible.parsing.yaml.loader import AnsibleLoader
from ansible._internal._yaml._loader import AnsibleInstrumentedLoader
@ -40,7 +41,6 @@ from . import _AnsiblePluginInfoMixin
from .filter import AnsibleJinja2Filter
from .test import AnsibleJinja2Test
from .._internal._plugins import _cache
from ..module_utils.common.messages import PluginInfo
# TODO: take the packaging dep, or vendor SpecifierSet?
@ -202,7 +202,7 @@ class PluginLoadContext(object):
msg=warning_text,
date=removal_date,
version=removal_version,
deprecator=PluginInfo._from_collection_name(collection_name),
deprecator=deprecator_from_collection_name(collection_name),
)
self.deprecated = True
@ -611,7 +611,7 @@ class PluginLoader:
version=removal_version,
date=removal_date,
removed=True,
deprecator=PluginInfo._from_collection_name(acr.collection),
deprecator=deprecator_from_collection_name(acr.collection),
)
plugin_load_context.date = removal_date
plugin_load_context.version = removal_version
@ -1396,7 +1396,7 @@ class Jinja2Loader(PluginLoader):
msg=warning_text,
version=removal_version,
date=removal_date,
deprecator=PluginInfo._from_collection_name(acr.collection),
deprecator=deprecator_from_collection_name(acr.collection),
)
# check removal
@ -1412,7 +1412,7 @@ class Jinja2Loader(PluginLoader):
version=removal_version,
date=removal_date,
removed=True,
deprecator=PluginInfo._from_collection_name(acr.collection),
deprecator=deprecator_from_collection_name(acr.collection),
)
raise AnsiblePluginRemovedError(exc_msg)

@ -178,7 +178,7 @@ def vaulted_file(value):
with open(to_bytes(value), 'rb') as f:
return is_encrypted_file(f)
except (OSError, IOError) as e:
raise errors.AnsibleFilterError(f"Cannot test if the file {value} is a vault", orig_exc=e)
raise errors.AnsibleFilterError(f"Cannot test if the file {value} is a vault.") from e
def match(value, pattern='', ignorecase=False, multiline=False):

@ -381,7 +381,7 @@ def generate_ansible_template_vars(path: str, fullpath: str | None = None, dest_
)
ansible_managed = _time.strftime(managed_str, _time.localtime(template_stat.st_mtime))
# DTFIX-RELEASE: this should not be tag_copy, it should either be an origin copy or some kind of derived origin
# DTFIX7: this should not be tag_copy, it should either be an origin copy or some kind of derived origin
ansible_managed = _datatag.AnsibleTagHelper.tag_copy(managed_default, ansible_managed)
ansible_managed = trust_as_template(ansible_managed)
ansible_managed = _module_utils_datatag.deprecate_value(

@ -51,13 +51,14 @@ from struct import unpack, pack
from ansible import constants as C
from ansible.constants import config
from ansible.errors import AnsibleAssertionError, AnsiblePromptInterrupt, AnsiblePromptNoninteractive, AnsibleError
from ansible._internal._errors import _utils
from ansible.module_utils._internal import _ambient_context, _deprecator
from ansible._internal._errors import _utils, _error_factory
from ansible._internal import _event_formatting
from ansible.module_utils._internal import _ambient_context, _deprecator, _messages
from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible.module_utils.datatag import deprecator_from_collection_name
from ansible._internal._datatag._tags import TrustedAsTemplate
from ansible.module_utils.common.messages import ErrorSummary, WarningSummary, DeprecationSummary, Detail, SummaryBase, PluginInfo
from ansible.module_utils.six import text_type
from ansible.module_utils._internal import _traceback
from ansible.module_utils._internal import _traceback, _errors
from ansible.utils.color import stringc
from ansible.utils.multiprocessing import context as multiprocessing_context
from ansible.utils.singleton import Singleton
@ -90,6 +91,9 @@ def _is_controller_traceback_enabled(event: _traceback.TracebackEvent) -> bool:
if 'never' in flag_values:
return False
if _traceback.TracebackEvent.DEPRECATED_VALUE.name.lower() in flag_values:
flag_values.add(_traceback.TracebackEvent.DEPRECATED.name.lower()) # DEPRECATED_VALUE implies DEPRECATED
return event.name.lower() in flag_values
@ -568,7 +572,7 @@ class Display(metaclass=Singleton):
version=version,
removed=removed,
date=date,
deprecator=PluginInfo._from_collection_name(collection_name),
deprecator=deprecator_from_collection_name(collection_name),
)
if removed:
@ -585,7 +589,7 @@ class Display(metaclass=Singleton):
version: str | None,
removed: bool = False,
date: str | None,
deprecator: PluginInfo | None,
deprecator: _messages.PluginInfo | None,
) -> str:
"""Internal use only. Return a deprecation message and help text for display."""
# DTFIX-FUTURE: the logic for omitting date/version doesn't apply to the payload, so it shows up in vars in some cases when it should not
@ -598,13 +602,13 @@ class Display(metaclass=Singleton):
if not deprecator or deprecator.type == _deprecator.INDETERMINATE_DEPRECATOR.type:
collection = None
plugin_fragment = ''
elif deprecator.type == _deprecator.PluginInfo._COLLECTION_ONLY_TYPE:
elif deprecator.type == _deprecator._COLLECTION_ONLY_TYPE:
collection = deprecator.resolved_name
plugin_fragment = ''
else:
parts = deprecator.resolved_name.split('.')
plugin_name = parts[-1]
# DTFIX-RELEASE: normalize 'modules' -> 'module' before storing it so we can eliminate the normalization here
# DTFIX1: normalize 'modules' -> 'module' before storing it so we can eliminate the normalization here
plugin_type = "module" if deprecator.type in ("module", "modules") else f'{deprecator.type} plugin'
collection = '.'.join(parts[:2]) if len(parts) > 2 else None
@ -668,7 +672,7 @@ class Display(metaclass=Singleton):
date: str | None = None,
collection_name: str | None = None,
*,
deprecator: PluginInfo | None = None,
deprecator: _messages.PluginInfo | None = None,
help_text: str | None = None,
obj: t.Any = None,
) -> None:
@ -678,8 +682,8 @@ class Display(metaclass=Singleton):
Specify `version` or `date`, but not both.
If `date` is a string, it must be in the form `YYYY-MM-DD`.
"""
# DTFIX-RELEASE: are there any deprecation calls where the feature is switching from enabled to disabled, rather than being removed entirely?
# DTFIX-RELEASE: are there deprecated features which should going through deferred deprecation instead?
# DTFIX3: are there any deprecation calls where the feature is switching from enabled to disabled, rather than being removed entirely?
# DTFIX3: are there deprecated features which should going through deferred deprecation instead?
_skip_stackwalk = True
@ -691,6 +695,7 @@ class Display(metaclass=Singleton):
help_text=help_text,
obj=obj,
deprecator=_deprecator.get_best_deprecator(deprecator=deprecator, collection_name=collection_name),
formatted_traceback=_traceback.maybe_capture_traceback(_traceback.TracebackEvent.DEPRECATED),
)
def _deprecated_with_plugin_info(
@ -702,7 +707,8 @@ class Display(metaclass=Singleton):
date: str | None,
help_text: str | None,
obj: t.Any,
deprecator: PluginInfo | None,
deprecator: _messages.PluginInfo | None,
formatted_traceback: str | None = None,
) -> None:
"""
This is the internal pre-proxy half of the `deprecated` implementation.
@ -726,18 +732,16 @@ class Display(metaclass=Singleton):
else:
formatted_source_context = None
deprecation = DeprecationSummary(
details=(
Detail(
deprecation = _messages.DeprecationSummary(
event=_messages.Event(
msg=msg,
formatted_source_context=formatted_source_context,
help_text=help_text,
),
formatted_traceback=formatted_traceback,
),
version=version,
date=date,
deprecator=deprecator,
formatted_traceback=_traceback.maybe_capture_traceback(_traceback.TracebackEvent.DEPRECATED),
)
if warning_ctx := _DeferredWarningContext.current(optional=True):
@ -747,7 +751,7 @@ class Display(metaclass=Singleton):
self._deprecated(deprecation)
@_proxy
def _deprecated(self, warning: DeprecationSummary) -> None:
def _deprecated(self, warning: _messages.DeprecationSummary) -> None:
"""Internal implementation detail, use `deprecated` instead."""
# This is the post-proxy half of the `deprecated` implementation.
@ -758,10 +762,10 @@ class Display(metaclass=Singleton):
self.warning('Deprecation warnings can be disabled by setting `deprecation_warnings=False` in ansible.cfg.')
msg = format_message(warning)
msg = _format_message(warning, _traceback.is_traceback_enabled(_traceback.TracebackEvent.DEPRECATED))
msg = f'[DEPRECATION WARNING]: {msg}'
# DTFIX-RELEASE: what should we do with wrap_message?
# DTFIX3: what should we do with wrap_message?
msg = self._wrap_message(msg=msg, wrap_text=True)
if self._deduplicate(msg, self._deprecations):
@ -778,6 +782,7 @@ class Display(metaclass=Singleton):
obj: t.Any = None
) -> None:
"""Display a warning message."""
_skip_stackwalk = True
# This is the pre-proxy half of the `warning` implementation.
# Any logic that must occur on workers needs to be implemented here.
@ -787,38 +792,36 @@ class Display(metaclass=Singleton):
else:
formatted_source_context = None
warning = WarningSummary(
details=(
Detail(
warning = _messages.WarningSummary(
event=_messages.Event(
msg=msg,
help_text=help_text,
formatted_source_context=formatted_source_context,
),
),
formatted_traceback=_traceback.maybe_capture_traceback(_traceback.TracebackEvent.WARNING),
),
)
if warning_ctx := _DeferredWarningContext.current(optional=True):
warning_ctx.capture(warning)
# DTFIX-RELEASE: what to do about propagating wrap_text?
# DTFIX3: what to do about propagating wrap_text?
return
self._warning(warning, wrap_text=not formatted)
@_proxy
def _warning(self, warning: WarningSummary, wrap_text: bool) -> None:
def _warning(self, warning: _messages.WarningSummary, wrap_text: bool) -> None:
"""Internal implementation detail, use `warning` instead."""
# This is the post-proxy half of the `warning` implementation.
# Any logic that must occur in the primary controller process needs to be implemented here.
msg = format_message(warning)
msg = _format_message(warning, _traceback.is_traceback_enabled(_traceback.TracebackEvent.WARNING))
msg = f"[WARNING]: {msg}"
if self._deduplicate(msg, self._warns):
return
# DTFIX-RELEASE: what should we do with wrap_message?
# DTFIX3: what should we do with wrap_message?
msg = self._wrap_message(msg=msg, wrap_text=wrap_text)
self.display(msg, color=C.config.get_config_value('COLOR_WARN'), stderr=True, caplevel=-2)
@ -872,15 +875,23 @@ class Display(metaclass=Singleton):
def error_as_warning(self, msg: str | None, exception: BaseException) -> None:
"""Display an exception as a warning."""
_skip_stackwalk = True
error = _utils._create_error_summary(exception, _traceback.TracebackEvent.WARNING)
event = _error_factory.ControllerEventFactory.from_exception(exception, _traceback.is_traceback_enabled(_traceback.TracebackEvent.WARNING))
if msg:
error = dataclasses.replace(error, details=(Detail(msg=msg),) + error.details)
event = _messages.Event(
msg=msg,
formatted_traceback=_traceback.maybe_capture_traceback(_traceback.TracebackEvent.WARNING),
chain=_messages.EventChain(
msg_reason=_errors.MSG_REASON_DIRECT_CAUSE,
traceback_reason="The above exception was the direct cause of the following warning:",
event=event,
),
)
warning = WarningSummary(
details=error.details,
formatted_traceback=error.formatted_traceback,
warning = _messages.WarningSummary(
event=event,
)
if warning_ctx := _DeferredWarningContext.current(optional=True):
@ -891,32 +902,41 @@ class Display(metaclass=Singleton):
def error(self, msg: str | BaseException, wrap_text: bool = True, stderr: bool = True) -> None:
"""Display an error message."""
_skip_stackwalk = True
# This is the pre-proxy half of the `error` implementation.
# Any logic that must occur on workers needs to be implemented here.
if isinstance(msg, BaseException):
error = _utils._create_error_summary(msg, _traceback.TracebackEvent.ERROR)
event = _error_factory.ControllerEventFactory.from_exception(msg, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR))
wrap_text = False
else:
error = ErrorSummary(details=(Detail(msg=msg),), formatted_traceback=_traceback.maybe_capture_traceback(_traceback.TracebackEvent.ERROR))
event = _messages.Event(
msg=msg,
formatted_traceback=_traceback.maybe_capture_traceback(_traceback.TracebackEvent.ERROR),
)
error = _messages.ErrorSummary(
event=event,
)
self._error(error, wrap_text=wrap_text, stderr=stderr)
@_proxy
def _error(self, error: ErrorSummary, wrap_text: bool, stderr: bool) -> None:
def _error(self, error: _messages.ErrorSummary, wrap_text: bool, stderr: bool) -> None:
"""Internal implementation detail, use `error` instead."""
# This is the post-proxy half of the `error` implementation.
# Any logic that must occur in the primary controller process needs to be implemented here.
msg = format_message(error)
msg = _format_message(error, _traceback.is_traceback_enabled(_traceback.TracebackEvent.ERROR))
msg = f'[ERROR]: {msg}'
if self._deduplicate(msg, self._errors):
return
# DTFIX-RELEASE: what should we do with wrap_message?
# DTFIX3: what should we do with wrap_message?
msg = self._wrap_message(msg=msg, wrap_text=wrap_text)
self.display(msg, color=C.config.get_config_value('COLOR_ERROR'), stderr=stderr, caplevel=-1)
@ -1146,9 +1166,9 @@ class _DeferredWarningContext(_ambient_context.AmbientContextBase):
def __init__(self, *, variables: dict[str, object]) -> None:
self._variables = variables # DTFIX-FUTURE: move this to an AmbientContext-derived TaskContext (once it exists)
self._deprecation_warnings: list[DeprecationSummary] = []
self._warnings: list[WarningSummary] = []
self._seen: set[WarningSummary] = set()
self._deprecation_warnings: list[_messages.DeprecationSummary] = []
self._warnings: list[_messages.WarningSummary] = []
self._seen: set[_messages.WarningSummary] = set()
@classmethod
def deprecation_warnings_enabled(cls) -> bool:
@ -1161,82 +1181,29 @@ class _DeferredWarningContext(_ambient_context.AmbientContextBase):
return C.config.get_config_value('DEPRECATION_WARNINGS', variables=variables)
def capture(self, warning: WarningSummary) -> None:
def capture(self, warning: _messages.WarningSummary) -> None:
"""Add the warning/deprecation to the context if it has not already been seen by this context."""
if warning in self._seen:
return
self._seen.add(warning)
if isinstance(warning, DeprecationSummary):
if isinstance(warning, _messages.DeprecationSummary):
self._deprecation_warnings.append(warning)
else:
self._warnings.append(warning)
def get_warnings(self) -> list[WarningSummary]:
def get_warnings(self) -> list[_messages.WarningSummary]:
"""Return a list of the captured non-deprecation warnings."""
# DTFIX-FUTURE: return a read-only list proxy instead
return self._warnings
def get_deprecation_warnings(self) -> list[DeprecationSummary]:
def get_deprecation_warnings(self) -> list[_messages.DeprecationSummary]:
"""Return a list of the captured deprecation warnings."""
# DTFIX-FUTURE: return a read-only list proxy instead
return self._deprecation_warnings
def _format_error_details(details: t.Sequence[Detail], formatted_tb: str | None = None) -> str:
details = _utils._collapse_error_details(details)
message_lines: list[str] = []
if len(details) > 1:
message_lines.append(_utils._dedupe_and_concat_message_chain([md.msg for md in details]))
message_lines.append('')
for idx, edc in enumerate(details):
if idx:
message_lines.extend((
'',
'<<< caused by >>>',
'',
))
message_lines.extend(_get_message_lines(edc.msg, edc.help_text, edc.formatted_source_context))
message_lines = [f'{line}\n' for line in message_lines]
if formatted_tb:
message_lines.append('\n')
message_lines.append(formatted_tb)
msg = "".join(message_lines).strip()
if '\n' in msg:
msg += '\n\n'
else:
msg += '\n'
return msg
def _get_message_lines(message: str, help_text: str | None, formatted_source_context: str | None) -> list[str]:
"""Return a list of error/warning message lines constructed from the given message, help text and source context."""
if help_text and not formatted_source_context and '\n' not in message and '\n' not in help_text:
return [f'{message} {help_text}'] # prefer a single-line message with help text when there is no source context
message_lines = [message]
if formatted_source_context:
message_lines.append(formatted_source_context)
if help_text:
message_lines.append('')
message_lines.append(help_text)
return message_lines
def _join_sentences(first: str | None, second: str | None) -> str:
"""Join two sentences together."""
first = (first or '').strip()
@ -1257,33 +1224,23 @@ def _join_sentences(first: str | None, second: str | None) -> str:
return ' '.join((first, second))
def format_message(summary: SummaryBase) -> str:
details: c.Sequence[Detail] = summary.details
if isinstance(summary, DeprecationSummary) and details:
# augment the first detail element for deprecations to include additional diagnostic info and help text
detail_list = list(details)
detail = detail_list[0]
deprecation_msg = _display._get_deprecation_message_with_plugin_info(
msg=detail.msg,
def _format_message(summary: _messages.SummaryBase, include_traceback: bool) -> str:
if isinstance(summary, _messages.DeprecationSummary):
deprecation_message = _display._get_deprecation_message_with_plugin_info(
msg=summary.event.msg,
version=summary.version,
date=summary.date,
deprecator=summary.deprecator,
)
detail_list[0] = dataclasses.replace(
detail,
msg=deprecation_msg,
help_text=detail.help_text,
)
details = detail_list
event = dataclasses.replace(summary.event, msg=deprecation_message)
else:
event = summary.event
return _format_error_details(details, summary.formatted_traceback)
return _event_formatting.format_event(event, include_traceback)
def _report_config_warnings(deprecator: PluginInfo) -> None:
def _report_config_warnings(deprecator: _messages.PluginInfo) -> None:
"""Called by config to report warnings/deprecations collected during a config parse."""
while config._errors:
msg, exception = config._errors.pop()

@ -2,7 +2,7 @@
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# deprecated: description="deprecate unsafe_proxy module" core_version="2.23"
# DTFIX-RELEASE: add full unit test coverage
# DTFIX5: add full unit test coverage
from __future__ import annotations
from collections.abc import Mapping, Set

@ -16,7 +16,7 @@
- '"some_bool = true" in toml_in.stdout'
- '"some_list = [" in toml_in.stdout'
# DTFIX-RELEASE: plug in variable visitor on TOML output and re-enable this test
# DTFIX3: plug in variable visitor on TOML output and re-enable this test
#- name: "test option: --toml with valid group name"
# command: ansible-inventory --list --toml -i {{ role_path }}/files/valid_sample.yml
# register: result

@ -1,5 +1,5 @@
- block:
# DTFIX-RELEASE: plug in variable visitor on TOML output and re-enable this test
# DTFIX3: plug in variable visitor on TOML output and re-enable this test
# - name: check baseline
# command: ansible-inventory -i '{{ role_path }}/files/valid_sample.yml' --list --toml
# register: limited

@ -1,8 +1,8 @@
from __future__ import annotations
from ansible.module_utils.datatag import deprecator_from_collection_name
from ansible.plugins.action import ActionBase
from ansible.utils.display import _display
from ansible.module_utils.common.messages import PluginInfo
# extra lines below to allow for adding more imports without shifting the line numbers of the code that follows
#
@ -15,7 +15,7 @@ from ansible.module_utils.common.messages import PluginInfo
class ActionModule(ActionBase):
def run(self, tmp=None, task_vars=None):
result = super(ActionModule, self).run(tmp, task_vars)
deprecator = PluginInfo._from_collection_name('ns.col')
deprecator = deprecator_from_collection_name('ns.col')
# ansible-deprecated-version - only ansible-core can encounter this
_display.deprecated(msg='ansible-deprecated-no-version')

@ -1,6 +1,6 @@
from __future__ import annotations
from ansible.module_utils.common.messages import PluginInfo
from ansible.module_utils.datatag import deprecator_from_collection_name
from ansible.module_utils.common.warnings import deprecate
# extra lines below to allow for adding more imports without shifting the line numbers of the code that follows
@ -15,7 +15,7 @@ from ansible.module_utils.common.warnings import deprecate
def do_stuff() -> None:
deprecator = PluginInfo._from_collection_name('ns.col')
deprecator = deprecator_from_collection_name('ns.col')
# ansible-deprecated-version - only ansible-core can encounter this
deprecate(msg='ansible-deprecated-no-version', collection_name='ns.col')

@ -4,12 +4,12 @@ It triggers sanity test failures that can only occur for ansible-core, which nee
"""
from __future__ import annotations
from ansible.module_utils.common.messages import PluginInfo
from ansible.module_utils.datatag import deprecator_from_collection_name
from ansible.module_utils.common.warnings import deprecate
def do_stuff() -> None:
deprecator = PluginInfo._from_collection_name('ansible.builtin')
deprecator = deprecator_from_collection_name('ansible.builtin')
deprecate(msg='ansible-deprecated-version', version='2.18')
deprecate(msg='ansible-deprecated-no-version')

@ -44,7 +44,7 @@
- that: 1 == 1
- that: a_var == "one"
- that: '{{ a_var == "one" }}'
# DTFIX-RELEASE: loops are not lazily templated, so this is not possible today, but could be in the future
# DTFIX1: loops are not lazily templated, so this is not possible today, but could be in the future
# - that: |
# "hi mom" == '{{ "hi mom" }}'
- that:

@ -148,7 +148,7 @@
- async_result.failed == true
- async_result is failed
- async_result.msg is contains 'failing via exception'
# DTFIX-RELEASE: enable tracebacks, ensure exception is populated
# DTFIX5: enable tracebacks, ensure exception is populated
#- async_result.exception is contains 'failing via exception'
- name: test leading junk before JSON

@ -10,7 +10,7 @@ from ansible.plugins.callback import CallbackBase
class CallbackModule(CallbackBase):
# DTFIX-RELEASE: validate VaultedValue redaction behavior
# DTFIX5: validate VaultedValue redaction behavior
CALLBACK_NEEDS_ENABLED = True
seen_tr = [] # track taskresult instances to ensure every call sees a unique instance

@ -1,7 +1,6 @@
from __future__ import annotations
from ansible.module_utils.common.messages import PluginInfo
from ansible.module_utils.datatag import deprecate_value
from ansible.module_utils.datatag import deprecate_value, deprecator_from_collection_name
def get_deprecation_kwargs() -> list[dict[str, object]]:
@ -10,9 +9,9 @@ def get_deprecation_kwargs() -> list[dict[str, object]]:
dict(
msg="Deprecation that passes deprecator and datetime.date.",
date='2034-01-02',
deprecator=PluginInfo._from_collection_name('bla.bla'),
deprecator=deprecator_from_collection_name('bla.bla'),
),
dict(msg="Deprecation that passes deprecator and string date.", date='2034-01-02', deprecator=PluginInfo._from_collection_name('bla.bla')),
dict(msg="Deprecation that passes deprecator and string date.", date='2034-01-02', deprecator=deprecator_from_collection_name('bla.bla')),
dict(msg="Deprecation that passes no deprecator, collection name, or date/version."),
]

@ -0,0 +1,3 @@
context/controller
shippable/posix/group3
gather_facts/no

@ -0,0 +1,12 @@
from __future__ import annotations
from ansible.module_utils.basic import AnsibleModule
def main() -> None:
module = AnsibleModule({})
module.fail_json("the fail1 module went bang")
if __name__ == '__main__':
main()

@ -0,0 +1,12 @@
from __future__ import annotations
from ansible.module_utils.basic import AnsibleModule
def main() -> None:
module = AnsibleModule({})
module.fail_json("the fail2 module went bang")
if __name__ == '__main__':
main()

@ -0,0 +1,12 @@
from __future__ import annotations
from ansible.module_utils.basic import AnsibleModule
def main() -> None:
module = AnsibleModule({})
module.exit_json()
if __name__ == '__main__':
main()

@ -0,0 +1,16 @@
- name: Gather facts with tracebacks enabled
shell: ansible-playbook {{role_path}}/test_gather_facts.yml
environment:
ANSIBLE_DISPLAY_TRACEBACK: error
register: with_traceback
- assert:
that:
- with_traceback.stdout is contains "Sub-Event 1 of 2"
- with_traceback.stdout is contains "Sub-Event 2 of 2"
- with_traceback.stdout is contains "Sub-Traceback 1 of 2"
- with_traceback.stdout is contains "Sub-Traceback 2 of 2"
- with_traceback.stdout is search "(?s)Traceback .*/action/gather_facts\.py"
- with_traceback.stdout is search "(?s)Traceback .* The following modules failed to execute"
- with_traceback.stdout is search "(?s)Traceback .* the fail1 module went bang"
- with_traceback.stdout is search "(?s)Traceback .* the fail2 module went bang"

@ -0,0 +1,23 @@
- hosts: localhost
gather_facts: no
vars:
ansible_facts_modules: [ fail1, success1, fail2 ]
tasks:
- name: Gather parallel and serially
gather_facts:
parallel: '{{ item }}'
ignore_errors: true
register: result
loop: [true, false]
- assert:
that:
- result.results | length == 2
- result.results[item] is failed
- result.results[item].exception is match "(?s)Traceback .* The following modules failed to execute"
- result.results[item].failed_modules | length == 2
- result.results[item].failed_modules.fail1.exception is match "(?s)Traceback .*the fail1 module went bang"
- result.results[item].failed_modules.fail2.exception is match "(?s)Traceback .*the fail2 module went bang"
loop:
- 0
- 1

@ -243,7 +243,7 @@
- "{{role_path}}/vars"
- name: ensure vars subdir is searched for var-named actions
# DTFIX-RELEASE: the following *should* work, but since task.action is not templated by TE, it does not
# DTFIX5: the following *should* work, but since task.action is not templated by TE, it does not
# action: '{{ "debug_v" ~ "ar_alias" }}'
# args:
# var: item

@ -1,12 +1,12 @@
from __future__ import annotations
from ansible.module_utils.common.messages import PluginInfo
from ansible.module_utils._internal import _messages
from ansible.plugins.lookup import LookupBase
class LookupModule(LookupBase):
def run(self, terms, variables=None, **kwargs):
return [PluginInfo(
return [_messages.PluginInfo(
resolved_name='resolved_name',
type='type',
)]

@ -50,7 +50,7 @@
assert:
that:
- "'[1, 2]' | ansible._protomatter.python_literal_eval == [1, 2]"
# DTFIX-RELEASE: This test requires fixing plugin captured error handling first.
# DTFIX5: This test requires fixing plugin captured error handling first.
# Once fixed, the error handling test below can be replaced by this assert.
# - "'x[1, 2]' | ansible._protomatter.python_literal_eval | true_type == 'CapturedExceptionMarker'"
- "'x[1, 2]' | ansible._protomatter.python_literal_eval(ignore_errors=True) == 'x[1, 2]'"
@ -144,11 +144,11 @@
- transformed.warnings | length > 0
- transformed.warnings[0] | type_debug == 'str'
- (transformed.warnings | ansible._protomatter.unmask(['WarningSummary']))[0] | type_debug == 'WarningSummary'
- (transformed.warnings | ansible._protomatter.unmask(['WarningSummary']))[0].details[0] | type_debug == 'Detail'
- (transformed.warnings | ansible._protomatter.unmask(['WarningSummary']))[0].event | type_debug == 'Event'
- transformed.deprecations | length > 0
- transformed.deprecations[0] | type_debug == 'dict'
- (transformed.deprecations | ansible._protomatter.unmask(['DeprecationSummary']))[0].details[0] | type_debug == 'Detail'
- (transformed.deprecations | ansible._protomatter.unmask(['DeprecationSummary']))[0].event | type_debug == 'Event'
# unmask the wrong type, ensure that the default transform still occurs
- (transformed.warnings | ansible._protomatter.unmask('EncryptedString'))[0] | type_debug == 'str'
# unmask at a higher level, validate that it propagates to child lazies
- (transformed | ansible._protomatter.unmask(['WarningSummary'])).warnings[0].details[0] | type_debug == 'Detail'
- (transformed | ansible._protomatter.unmask(['WarningSummary'])).warnings[0].event | type_debug == 'Event'

@ -7,8 +7,8 @@
override_value: '{{ not_defined }}'
register: var_undefined_overridden
# DTFIX-RELEASE: ensure that debug issues the undefined warning
# DTFIX-RELEASE: should some warnings (like this one) skip dedupe?
# DTFIX5: ensure that debug issues the undefined warning
# DTFIX5: should some warnings (like this one) skip dedupe?
- name: debug a templated var that is actually undefined
debug: var=undefined_template
vars:

@ -6,6 +6,7 @@ import pytest
from ansible.errors import AnsibleError
from ansible.module_utils.common._utils import get_all_subclasses
from ansible.module_utils._internal import _messages
from ansible._internal._templating._jinja_common import Marker, TruncationMarker, CapturedExceptionMarker, VaultExceptionMarker
from ansible._internal._templating._engine import TemplateEngine, TemplateOptions
from ansible._internal._templating._utils import TemplateContext
@ -34,7 +35,7 @@ def marker(request, template_context: TemplateContext) -> t.Iterator[Marker]:
if issubclass(request_type, TruncationMarker):
yield request_type()
elif issubclass(request_type, VaultExceptionMarker):
yield VaultExceptionMarker(ciphertext='a ciphertext', reason='a reason', traceback='a traceback')
yield VaultExceptionMarker(ciphertext='a ciphertext', event=_messages.Event(msg='a msg'))
elif issubclass(request_type, CapturedExceptionMarker):
try:
try:

@ -1,4 +1,4 @@
# DTFIX-RELEASE: more thorough tests are needed here, this is just a starting point
# DTFIX5: more thorough tests are needed here, this is just a starting point
from __future__ import annotations
@ -374,7 +374,7 @@ def test_lazy_container_operators(expression: str, expected_value: t.Any, expect
When the result is a container, items in the container are checked to see if they're lazy as appropriate.
This test uses a function to simulate Jinja plugin behavior, since plugins can use operators and methods that Jinja expressions cannot.
"""
# DTFIX-RELEASE: add a unit test to ensure every list/dict method has been overridden or on a list we can safely ignore
# DTFIX5: add a unit test to ensure every list/dict method has been overridden or on a list we can safely ignore
def l2f() -> list:
"""Return a lazy list that uses different lazy options, to ensure it cannot be lazy combined."""
return TemplateContext.current().templar.template([2], lazy_options=LazyOptions.SKIP_TEMPLATES)
@ -570,7 +570,7 @@ def test_lazy_persistence(expression: str, expected: t.Any) -> None:
))
def test_lazy_mutation_persistence(expression: str) -> None:
"""Verify that lazy containers persist values added after creation and return them as-is without modification, even if they contain trusted templates."""
# DTFIX-RELEASE: investigate relevance of this test now that mutation/dirty tracking is toast
# DTFIX5: investigate relevance of this test now that mutation/dirty tracking is toast
variables = dict(
data=dict(
l1=[None],
@ -601,7 +601,7 @@ def test_lazy_mutation_persistence(expression: str) -> None:
])
def test_lazy_mutation_cross_plugin_dirty_container(expr: str, new_value: t.Any, some_var: t.Any, expected_value: t.Any):
"""Ensure that new templates sourced from a plugin are not processed by subsequent plugins or template finalization."""
# DTFIX-RELEASE: investigate relevance of this test now that mutation/dirty tracking is toast
# DTFIX5: investigate relevance of this test now that mutation/dirty tracking is toast
def mutate_list(value: list) -> list:
value.append(new_value)

@ -50,6 +50,7 @@ from ansible._internal._templating._engine import TemplateEngine, TemplateOption
from ansible._internal._templating._jinja_bits import AnsibleEnvironment, AnsibleContext, is_possibly_template, is_possibly_all_template
from ansible._internal._templating._marker_behaviors import ReplacingMarkerBehavior
from ansible._internal._templating._utils import TemplateContext
from ansible.module_utils._internal import _event_utils
from ansible.utils.display import Display, _DeferredWarningContext
from units.mock.loader import DictDataLoader
from units.test_utils.controller.display import emits_warnings
@ -422,7 +423,7 @@ def test_evaluate_expression_errors(expr: str, error_type: type[Exception]):
@pytest.mark.parametrize("conditional,expected,variables", [
("1 == 2", False, None),
("test2_name | default(True)", True, None),
# DTFIX-RELEASE: more success cases?
# DTFIX5: more success cases?
])
def test_evaluate_conditional(conditional: str, expected: t.Any, variables: dict[str, t.Any] | None):
assert TemplateEngine().evaluate_conditional(TRUST.tag(conditional)) == expected
@ -510,7 +511,7 @@ def test_is_possibly_template_false(value: str) -> None:
def test_stop_on_container() -> None:
# DTFIX-RELEASE: add more test cases
# DTFIX5: add more test cases
assert TemplateEngine().resolve_to_container(TRUST.tag('{{ [ 1 ] }}')) == [1]
@ -576,7 +577,7 @@ def test_finalize_generator(value: t.Any, expected: t.Any) -> None:
yielder=yielder,
))
# DTFIX-RELEASE: we still need to deal with the "Encountered unsupported" warnings these generate
# DTFIX5: we still need to deal with the "Encountered unsupported" warnings these generate
assert templar.template(TRUST.tag(value)) == expected
@ -1040,9 +1041,9 @@ def test_deprecated_dedupe_and_source():
dep_warnings = dwc.get_deprecation_warnings()
assert len(dep_warnings) == 3
assert 'deprecated_string' in dep_warnings[0]._format()
assert 'indirect1 and deprecated_list and deprecated_dict' in dep_warnings[1]._format()
assert 'd1 and d2' in dep_warnings[2]._format()
assert 'deprecated_string' in _event_utils.format_event_brief_message(dep_warnings[0].event)
assert 'indirect1 and deprecated_list and deprecated_dict' in _event_utils.format_event_brief_message(dep_warnings[1].event)
assert 'd1 and d2' in _event_utils.format_event_brief_message(dep_warnings[2].event)
def test_jinja_const_template_leak(template_context: TemplateContext) -> None:

@ -0,0 +1,29 @@
from __future__ import annotations
import traceback
from ansible._internal._errors import _error_factory
from ansible._internal._event_formatting import format_event_traceback
def test_traceback_formatting() -> None:
"""Verify our traceback formatting mimics the Python traceback formatting."""
try:
try:
try:
try:
raise Exception('one')
except Exception as ex:
raise Exception('two') from ex
except Exception:
raise Exception('three')
except Exception as ex:
raise Exception('four') from ex
except Exception as ex:
saved_ex = ex
event = _error_factory.ControllerEventFactory.from_exception(saved_ex, True) # pylint: disable=used-before-assignment
ansible_tb = format_event_traceback(event)
python_tb = ''.join(traceback.format_exception(saved_ex))
assert ansible_tb == python_tb

@ -2,11 +2,13 @@ from __future__ import annotations
import pytest
from ansible._internal._errors import _error_factory
from ansible.errors import AnsibleError
from ansible._internal._errors._utils import _create_error_summary, get_chained_message
from ansible.module_utils.common.messages import ErrorSummary
from ansible._internal._datatag._tags import Origin
from ansible.utils.display import format_message
from ansible._internal._errors._utils import format_exception_message
from ansible.utils.display import _format_message
from ansible.module_utils._internal import _messages
def raise_exceptions(exceptions: list[BaseException]) -> None:
@ -190,10 +192,10 @@ def test_error_messages(exceptions: list[BaseException], expected_message_chain:
with pytest.raises(Exception) as error:
raise_exceptions(exceptions)
error_details = _create_error_summary(error.value).details
event = _error_factory.ControllerEventFactory.from_exception(error.value, False)
message_chain = get_chained_message(error.value)
formatted_message = format_message(ErrorSummary(details=error_details))
message_chain = format_exception_message(error.value)
formatted_message = _format_message(_messages.ErrorSummary(event=event), False)
assert message_chain == expected_message_chain
assert formatted_message.strip() == (expected_formatted_message or expected_message_chain)

@ -34,6 +34,7 @@ MODULE_UTILS_BASIC_FILES = frozenset(('ansible/__init__.py',
'ansible/module_utils/_internal/_debugging.py',
'ansible/module_utils/_internal/_deprecator.py',
'ansible/module_utils/_internal/_errors.py',
'ansible/module_utils/_internal/_event_utils.py',
'ansible/module_utils/_internal/_json/__init__.py',
'ansible/module_utils/_internal/_json/_legacy_encoder.py',
'ansible/module_utils/_internal/_json/_profiles/__init__.py',
@ -42,10 +43,13 @@ MODULE_UTILS_BASIC_FILES = frozenset(('ansible/__init__.py',
'ansible/module_utils/_internal/_json/_profiles/_tagless.py',
'ansible/module_utils/_internal/_traceback.py',
'ansible/module_utils/_internal/_validation.py',
'ansible/module_utils/_internal/_messages.py',
'ansible/module_utils/_internal/_patches/_dataclass_annotation_patch.py',
'ansible/module_utils/_internal/_patches/_socket_patch.py',
'ansible/module_utils/_internal/_patches/_sys_intern_patch.py',
'ansible/module_utils/_internal/_patches/__init__.py',
'ansible/module_utils/_internal/_stack.py',
'ansible/module_utils/_internal/_text_utils.py',
'ansible/module_utils/common/collections.py',
'ansible/module_utils/common/parameters.py',
'ansible/module_utils/common/warnings.py',
@ -54,7 +58,6 @@ MODULE_UTILS_BASIC_FILES = frozenset(('ansible/__init__.py',
'ansible/module_utils/common/file.py',
'ansible/module_utils/common/json.py',
'ansible/module_utils/common/locale.py',
'ansible/module_utils/common/messages.py',
'ansible/module_utils/common/process.py',
'ansible/module_utils/common/sys_info.py',
'ansible/module_utils/common/text/__init__.py',

@ -1,12 +0,0 @@
from __future__ import annotations
import typing as t
from ansible.module_utils.common.messages import SummaryBase, Detail
_TSummaryBase = t.TypeVar('_TSummaryBase', bound=SummaryBase)
def make_summary(summary_type: type[_TSummaryBase], *details: Detail, formatted_traceback: t.Optional[str] = None, **kwargs) -> _TSummaryBase:
"""Utility factory method to avoid inline tuples."""
return summary_type(details=details, formatted_traceback=formatted_traceback, **kwargs)

@ -7,5 +7,5 @@ from ansible.module_utils._internal._datatag._tags import Deprecated
def test_getaddrinfo() -> None:
"""Verify that `socket.getaddrinfo` works with a tagged port."""
# DTFIX-RELEASE: add additional args and validate output shape (ensure passthru is working)
# DTFIX5: add additional args and validate output shape (ensure passthru is working)
socket.getaddrinfo('localhost', Deprecated(msg='').tag(22))

@ -7,7 +7,7 @@ import ansible
import pathlib
import pytest
from ansible.module_utils.common.messages import PluginInfo
from ansible.module_utils._internal import _messages
from ansible.module_utils._internal import _deprecator
@ -43,8 +43,8 @@ def do_stuff():
('ansible.nonplugin_something', _deprecator.ANSIBLE_CORE_DEPRECATOR.resolved_name, _deprecator.ANSIBLE_CORE_DEPRECATOR.type),
# collections plugin callers
('ansible_collections.foo.bar.plugins.modules.module_thing', 'foo.bar.module_thing', 'module'),
('ansible_collections.foo.bar.plugins.filter.somefilter', 'foo.bar', PluginInfo._COLLECTION_ONLY_TYPE),
('ansible_collections.foo.bar.plugins.test.sometest', 'foo.bar', PluginInfo._COLLECTION_ONLY_TYPE),
('ansible_collections.foo.bar.plugins.filter.somefilter', 'foo.bar', _deprecator._COLLECTION_ONLY_TYPE),
('ansible_collections.foo.bar.plugins.test.sometest', 'foo.bar', _deprecator._COLLECTION_ONLY_TYPE),
# indeterminate callers (e.g. collection module_utils- must specify since they might be calling on behalf of another
('ansible_collections.foo.bar.plugins.module_utils.something',
_deprecator.INDETERMINATE_DEPRECATOR.resolved_name, _deprecator.INDETERMINATE_DEPRECATOR.type),
@ -62,7 +62,7 @@ def test_get_caller_plugin_info(python_fq_name: str, expected_resolved_name: str
loader.exec_module(mod)
pi: PluginInfo = mod.do_stuff()
pi: _messages.PluginInfo = mod.do_stuff()
if not expected_resolved_name and not expected_plugin_type:
assert pi is None

@ -7,12 +7,10 @@ from __future__ import annotations
import pytest
import typing as t
from ansible.module_utils._internal import _traceback
from ansible.module_utils._internal import _traceback, _messages
from ansible.module_utils.common import warnings
from ansible.module_utils.common.messages import WarningSummary, Detail
from ansible.module_utils.common.warnings import warn
from units.mock.messages import make_summary
from units.mock.module import ModuleEnvMocker
pytestmark = pytest.mark.usefixtures("module_env_mocker")
@ -21,7 +19,7 @@ pytestmark = pytest.mark.usefixtures("module_env_mocker")
def test_warn():
warn('Warning message')
assert warnings.get_warning_messages() == ('Warning message',)
assert warnings.get_warnings() == [make_summary(WarningSummary, Detail(msg='Warning message'))]
assert warnings.get_warnings() == [_messages.WarningSummary(event=_messages.Event(msg='Warning message'))]
def test_multiple_warnings():
@ -35,7 +33,7 @@ def test_multiple_warnings():
warn(w)
assert warnings.get_warning_messages() == tuple(messages)
assert warnings.get_warnings() == [make_summary(WarningSummary, Detail(msg=w)) for w in messages]
assert warnings.get_warnings() == [_messages.WarningSummary(event=_messages.Event(msg=w)) for w in messages]
def test_dedupe_with_traceback(module_env_mocker: ModuleEnvMocker) -> None:

@ -22,7 +22,7 @@ from ansible.module_utils._internal._json._profiles import (
_JSONSerializationProfile,
)
from ansible.module_utils.common.messages import ErrorSummary, WarningSummary, DeprecationSummary, Detail, PluginInfo
from ansible.module_utils._internal import _messages
from ansible.module_utils._internal._datatag import (
AnsibleSerializable,
@ -43,7 +43,6 @@ from ansible.module_utils._internal._datatag import (
)
from ansible.module_utils._internal._datatag._tags import Deprecated
from ansible.module_utils.datatag import native_type_name
from units.mock.messages import make_summary
if sys.version_info >= (3, 9):
from typing import get_type_hints
@ -79,11 +78,12 @@ class CopyProtocol(t.Protocol):
message_instances = [
Detail(msg="bla", formatted_source_context="sc"),
make_summary(ErrorSummary, Detail(msg="bla"), formatted_traceback="tb"),
make_summary(WarningSummary, Detail(msg="bla", formatted_source_context="sc"), formatted_traceback="tb"),
make_summary(DeprecationSummary, Detail(msg="bla", formatted_source_context="sc"), formatted_traceback="tb", version="1.2.3"),
PluginInfo(resolved_name='a.b.c', type='module'),
_messages.Event(msg="bla", formatted_source_context="sc"),
_messages.EventChain(msg_reason="a", traceback_reason="b", event=_messages.Event(msg="c")),
_messages.ErrorSummary(event=_messages.Event(msg="bla", formatted_traceback="tb")),
_messages.WarningSummary(event=_messages.Event(msg="bla", formatted_source_context="sc", formatted_traceback="tb")),
_messages.DeprecationSummary(event=_messages.Event(msg="bla", formatted_source_context="sc", formatted_traceback="tb"), version="1.2.3"),
_messages.PluginInfo(resolved_name='a.b.c', type='module'),
]
@ -531,7 +531,7 @@ class TestDatatagTarget(AutoParamSupport):
round_tripped_value = copy.deepcopy(value)
# DTFIX-RELEASE: ensure items in collections are copies
# DTFIX5: ensure items in collections are copies
assert_round_trip(value, round_tripped_value, via_copy=True)
@ -545,7 +545,7 @@ class TestDatatagTarget(AutoParamSupport):
if not isinstance(native_copy, int):
assert native_copy is not value._native_copy()
# DTFIX-RELEASE: ensure items in collections are not copies
# DTFIX5: ensure items in collections are not copies
assert native_copy == value
assert native_copy == value._native_copy()
@ -557,7 +557,7 @@ class TestDatatagTarget(AutoParamSupport):
round_tripped_value = copy.copy(value)
# DTFIX-RELEASE: ensure items in collections are not copies
# DTFIX5: ensure items in collections are not copies
assert_round_trip(value, round_tripped_value, via_copy=True)
@ -565,7 +565,7 @@ class TestDatatagTarget(AutoParamSupport):
def test_instance_copy_roundtrip(self, value: CopyProtocol):
round_tripped_value = value.copy()
# DTFIX-RELEASE: ensure items in collections are not copies
# DTFIX5: ensure items in collections are not copies
assert_round_trip(value, round_tripped_value)

@ -5,8 +5,8 @@ import tempfile
import pytest
from ansible._internal._errors._utils import get_chained_message
from ansible.errors import AnsibleJSONParserError
from ansible._internal._errors._utils import format_exception_message
from ansible._internal._datatag._tags import Origin
from ansible.parsing.utils.yaml import from_yaml
@ -30,7 +30,7 @@ def test_json_parser_error() -> None:
assert error.value.message == expected_message
assert error.value._original_message == expected_message
assert get_chained_message(error.value) == expected_message
assert format_exception_message(error.value) == expected_message
assert str(error.value) == expected_message
assert error.value.obj == Origin(path=str(source_path), line_num=line, col_num=col)

@ -34,6 +34,7 @@ from unittest.mock import patch, MagicMock
from ansible import errors
from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible.module_utils._internal import _messages
from ansible.module_utils._internal._datatag import AnsibleTagHelper
from ansible.parsing import vault
from ansible.parsing.vault import EncryptedString, VaultSecretsContext, AnsibleVaultError, VaultHelper
@ -939,12 +940,12 @@ origin = Origin(path="/test")
@pytest.mark.parametrize("value, expected_ciphertext", (
(origin.tag(EncryptedString(ciphertext="ciphertext")), "ciphertext"),
(origin.tag(VaultedValue(ciphertext="ciphertext").tag("something")), "ciphertext"),
(make_marker(VaultExceptionMarker, ciphertext=origin.tag("ciphertext"), reason="", traceback=""), "ciphertext"),
(make_marker(VaultExceptionMarker, ciphertext=origin.tag("ciphertext"), event=_messages.Event(msg="")), "ciphertext"),
(make_marker(TruncationMarker), None),
("not vaulted", None),
))
def test_vaulthelper_get_ciphertext(value: t.Any, expected_ciphertext: str | None) -> None:
"""Validate `get_ciphertext` helper reponses and tag preservation behavior."""
"""Validate `get_ciphertext` helper responses and tag preservation behavior."""
expected_tags = {origin} if expected_ciphertext is not None else set()
tagged_ciphertext = VaultHelper.get_ciphertext(value, with_tags=True)

@ -11,7 +11,7 @@ import pytest
import pytest_mock
from ansible import constants as C
from ansible._internal._errors._utils import get_chained_message
from ansible._internal._errors._utils import format_exception_message
from ansible._internal._datatag._tags import Origin
from ansible.parsing.utils.yaml import from_yaml
from ansible._internal._yaml._errors import AnsibleYAMLParserError
@ -87,7 +87,7 @@ def test_yaml_parser_error(
assert error.value.message == expected_message
assert error.value._original_message == expected_message
assert get_chained_message(error.value) == expected_message
assert format_exception_message(error.value) == expected_message
assert str(error.value) == expected_message
assert error.value.obj == Origin(path=str(source_path), line_num=line, col_num=col)

@ -52,9 +52,9 @@ class TestNetworkFacts(unittest.TestCase):
self.task.args = {}
plugin = GatherFactsAction(self.task, self.connection, self.play_context, loader=None, templar=self.templar, shared_loader_obj=None)
get_module_args = MagicMock()
get_module_args = MagicMock(return_value={})
plugin._get_module_args = get_module_args
plugin._execute_module = MagicMock()
plugin._execute_module = MagicMock(return_value={})
res = plugin.run(task_vars=self.fqcn_task_vars)
@ -78,9 +78,9 @@ class TestNetworkFacts(unittest.TestCase):
self.task.args = {}
plugin = GatherFactsAction(self.task, self.connection, self.play_context, loader=None, templar=self.templar, shared_loader_obj=None)
get_module_args = MagicMock()
get_module_args = MagicMock(return_value={})
plugin._get_module_args = get_module_args
plugin._execute_module = MagicMock()
plugin._execute_module = MagicMock(return_value={})
res = plugin.run(task_vars=self.fqcn_task_vars)

@ -4,7 +4,7 @@ import contextlib
import re
import typing as t
from ansible.module_utils.common.messages import WarningSummary, DeprecationSummary
from ansible.module_utils._internal import _messages
from ansible.utils.display import _DeferredWarningContext
@ -23,7 +23,7 @@ def emits_warnings(
warnings = ctx.get_warnings()
if ignore_boilerplate:
warnings = [warning for warning in warnings if not warning.details[0].msg.startswith('Deprecation warnings can be disabled by setting')]
warnings = [warning for warning in warnings if not warning.event.msg.startswith('Deprecation warnings can be disabled by setting')]
_check_messages('Warning', warning_pattern, warnings, allow_unmatched_message)
_check_messages('Deprecation', deprecation_pattern, deprecations, allow_unmatched_message)
@ -32,7 +32,7 @@ def emits_warnings(
def _check_messages(
label: str,
patterns: list[str] | str | None,
entries: list[WarningSummary] | list[DeprecationSummary],
entries: list[_messages.WarningSummary] | list[_messages.DeprecationSummary],
allow_unmatched_message: bool,
) -> None:
if patterns is None:

@ -65,7 +65,7 @@ class TestDatatagController(_TestDatatagTarget):
EncryptedString(ciphertext=ciphertext),
]
# DTFIX-RELEASE: ensure we're calculating the correct set of values for this context
# DTFIX5: ensure we're calculating the correct set of values for this context
@classmethod
def container_test_cases(cls) -> list:
return []

@ -14,13 +14,11 @@ from unittest.mock import MagicMock
import pytest
from ansible.module_utils._internal import _deprecator
from ansible.module_utils.common.messages import Detail, WarningSummary, DeprecationSummary, PluginInfo
from ansible.utils.display import _LIBC, _MAX_INT, Display, get_text_width, format_message
from ansible.module_utils.datatag import deprecator_from_collection_name
from ansible.module_utils._internal import _deprecator, _errors, _messages
from ansible.utils.display import _LIBC, _MAX_INT, Display, get_text_width, _format_message
from ansible.utils.multiprocessing import context as multiprocessing_context
from units.mock.messages import make_summary
@pytest.fixture
def problematic_wcswidth_chars():
@ -123,7 +121,7 @@ def test_Display_display_warn_fork(display_resource):
display = Display()
display.set_queue(queue)
display.warning('foo')
queue.send_display.assert_called_once_with('_warning', make_summary(WarningSummary, Detail(msg='foo')), wrap_text=True)
queue.send_display.assert_called_once_with('_warning', _messages.WarningSummary(event=_messages.Event(msg='foo')), wrap_text=True)
p = multiprocessing_context.Process(target=test)
p.start()
@ -153,12 +151,20 @@ def test_format_message_deprecation_with_multiple_details() -> None:
Verify that a DeprecationSummary with multiple Detail entries can be formatted.
No existing code generates deprecations with multiple details, but a future deprecation exception type would need to make use of this.
"""
result = format_message(DeprecationSummary(
details=(
Detail(msg='Ignoring ExceptionX.', help_text='Plugins must handle it internally.'),
Detail(msg='Something went wrong.', formatted_source_context='Origin: /some/path\n\n...'),
event = _messages.Event(
msg='Ignoring ExceptionX.',
help_text='Plugins must handle it internally.',
chain=_messages.EventChain(
msg_reason=_errors.MSG_REASON_DIRECT_CAUSE,
traceback_reason='', # not used in this test
event=_messages.Event(
msg='Something went wrong.',
formatted_source_context='Origin: /some/path\n\n...',
),
))
),
)
result = _format_message(_messages.DeprecationSummary(event=event), False)
assert result == '''Ignoring ExceptionX. This feature will be removed in the future: Something went wrong.
@ -175,15 +181,15 @@ Origin: /some/path
A_DATE = datetime.date(2025, 1, 1)
CORE = PluginInfo._from_collection_name('ansible.builtin')
CORE_MODULE = PluginInfo(resolved_name='ansible.builtin.ping', type='module')
CORE_PLUGIN = PluginInfo(resolved_name='ansible.builtin.debug', type='action')
COLL = PluginInfo._from_collection_name('ns.col')
COLL_MODULE = PluginInfo(resolved_name='ns.col.ping', type='module')
COLL_PLUGIN = PluginInfo(resolved_name='ns.col.debug', type='action')
CORE = deprecator_from_collection_name('ansible.builtin')
CORE_MODULE = _messages.PluginInfo(resolved_name='ansible.builtin.ping', type='module')
CORE_PLUGIN = _messages.PluginInfo(resolved_name='ansible.builtin.debug', type='action')
COLL = deprecator_from_collection_name('ns.col')
COLL_MODULE = _messages.PluginInfo(resolved_name='ns.col.ping', type='module')
COLL_PLUGIN = _messages.PluginInfo(resolved_name='ns.col.debug', type='action')
INDETERMINATE = _deprecator.INDETERMINATE_DEPRECATOR
LEGACY_MODULE = PluginInfo(resolved_name='ping', type='module')
LEGACY_PLUGIN = PluginInfo(resolved_name='debug', type='action')
LEGACY_MODULE = _messages.PluginInfo(resolved_name='ping', type='module')
LEGACY_PLUGIN = _messages.PluginInfo(resolved_name='debug', type='action')
@pytest.mark.parametrize('kwargs, expected', (

@ -1,4 +1,4 @@
# DTFIX-RELEASE: review this test for removal, should have been superseded by test_serialization_profiles.py
# DTFIX5: review this test for removal, should have been superseded by test_serialization_profiles.py
# some tests will need to be under module_utils to verify target-only Python versions
# if kept, de-dupe
from __future__ import annotations

@ -56,13 +56,13 @@ basic_values = (
{frozenset((1, 2)): "three"}, # hashable non-scalar key
)
# DTFIX-RELEASE: we need tests for recursion, specifically things like custom sequences and mappings when:
# DTFIX5: we need tests for recursion, specifically things like custom sequences and mappings when:
# 1) using the legacy serializer
# 2) containing types in the type map, such as tagged values
# e.g. -- does trust inversion get applied to a value inside a custom sequence or mapping
tag_values = {
Deprecated: Deprecated(msg='x'), # DTFIX-RELEASE: we need more exhaustive testing of the values supported by this tag to ensure schema ID is robust
Deprecated: Deprecated(msg='x'), # DTFIX5: we need more exhaustive testing of the values supported by this tag to ensure schema ID is robust
TrustedAsTemplate: TrustedAsTemplate(),
Origin: Origin(path='/tmp/x', line_num=1, col_num=2, description='y'),
VaultedValue: VaultedValue(ciphertext='x'),
@ -77,10 +77,10 @@ def test_cache_persistence_schema() -> None:
This test is only as comprehensive as these unit tests, so ensure profile data types are thoroughly covered.
If additional capabilities are added to the cache_persistence profile which are not tested, they will go undetected, leading to runtime failures.
"""
# DTFIX-RELEASE: update tests to ensure new fields on contracts will fail this test if they have defaults which are omitted from serialization
# DTFIX5: update tests to ensure new fields on contracts will fail this test if they have defaults which are omitted from serialization
# one possibility: monkeypatch the default field value omission away so that any new field will invalidate the schema
# DTFIX-RELEASE: ensure all types/attrs included in _profiles._common_module_response_types are represented here, since they can appear in cached responses
# DTFIX5: ensure all types/attrs included in _profiles._common_module_response_types are represented here, since they can appear in cached responses
expected_schema_id = 1
expected_schema_hash = "bf52e60cf1d25a3f8b6bfdf734781ee07cfe46e94189d2f538815c5000b617c6"
@ -318,7 +318,7 @@ class ProfileHelper:
additional_test_parameters: list[_TestParameters] = []
# DTFIX-RELEASE: need better testing for containers, especially for tagged values in containers
# DTFIX5: need better testing for containers, especially for tagged values in containers
additional_test_parameters.extend(ProfileHelper(_fallback_to_str._Profile.profile_name).create_parameters_from_values(
b'\x00', # valid utf-8 strict, JSON escape sequence required

Loading…
Cancel
Save