From 242bb9ebab567f9a6a874c199503ad141bad0622 Mon Sep 17 00:00:00 2001 From: Matt Clay Date: Mon, 19 May 2025 17:48:00 -0700 Subject: [PATCH] DTFIX recategorization and error/warning refactor (#85181) Co-authored-by: Matt Davis --- .../fragments/templates_types_datatagging.yml | 12 +- lib/ansible/_internal/_collection_proxy.py | 2 +- lib/ansible/_internal/_errors/_captured.py | 31 ++- .../_internal/_errors/_error_factory.py | 89 ++++++++ lib/ansible/_internal/_errors/_utils.py | 158 +------------- lib/ansible/_internal/_event_formatting.py | 127 +++++++++++ lib/ansible/_internal/_json/__init__.py | 8 +- .../_json/_profiles/_inventory_legacy.py | 2 +- .../_internal/_json/_profiles/_legacy.py | 6 +- lib/ansible/_internal/_templating/_datatag.py | 1 + lib/ansible/_internal/_templating/_engine.py | 2 +- .../_internal/_templating/_jinja_bits.py | 6 +- .../_internal/_templating/_jinja_common.py | 24 +- .../_internal/_templating/_lazy_containers.py | 2 +- .../_internal/_templating/_transform.py | 38 ++-- lib/ansible/_internal/_templating/_utils.py | 2 +- lib/ansible/_internal/_yaml/_dumper.py | 2 +- lib/ansible/cli/__init__.py | 4 +- lib/ansible/cli/doc.py | 4 +- lib/ansible/cli/inventory.py | 2 +- lib/ansible/config/base.yml | 1 + lib/ansible/errors/__init__.py | 11 +- lib/ansible/executor/module_common.py | 8 +- lib/ansible/executor/task_executor.py | 24 +- lib/ansible/executor/task_result.py | 6 +- .../module_utils/_internal/_ansiballz.py | 6 +- .../_internal/_datatag/__init__.py | 7 +- .../module_utils/_internal/_datatag/_tags.py | 4 +- .../module_utils/_internal/_deprecator.py | 114 ++++++---- lib/ansible/module_utils/_internal/_errors.py | 103 +++++++-- .../module_utils/_internal/_event_utils.py | 61 ++++++ .../_internal/_json/_profiles/__init__.py | 7 +- .../_internal/_json/_profiles/_tagless.py | 2 +- .../messages.py => _internal/_messages.py} | 75 +++---- .../module_utils/_internal/_plugin_info.py | 2 +- lib/ansible/module_utils/_internal/_stack.py | 22 ++ .../module_utils/_internal/_text_utils.py | 6 + .../module_utils/_internal/_traceback.py | 16 +- lib/ansible/module_utils/basic.py | 28 ++- lib/ansible/module_utils/common/arg_spec.py | 4 +- lib/ansible/module_utils/common/warnings.py | 64 ++++-- lib/ansible/module_utils/datatag.py | 7 +- lib/ansible/parsing/ajson.py | 2 +- lib/ansible/parsing/mod_args.py | 2 +- lib/ansible/parsing/vault/__init__.py | 6 +- lib/ansible/playbook/playbook_include.py | 2 +- lib/ansible/playbook/taggable.py | 6 +- lib/ansible/plugins/action/__init__.py | 39 +++- lib/ansible/plugins/action/gather_facts.py | 13 +- lib/ansible/plugins/callback/__init__.py | 10 +- lib/ansible/plugins/callback/junit.py | 6 +- lib/ansible/plugins/connection/ssh.py | 5 +- lib/ansible/plugins/filter/core.py | 4 +- lib/ansible/plugins/loader.py | 10 +- lib/ansible/plugins/test/core.py | 2 +- lib/ansible/template/__init__.py | 2 +- lib/ansible/utils/display.py | 205 +++++++----------- lib/ansible/utils/unsafe_proxy.py | 2 +- .../targets/ansible-inventory/tasks/toml.yml | 2 +- .../ansible-inventory/tasks/toml_output.yml | 2 +- .../col/plugins/action/do_deprecated_stuff.py | 4 +- .../plugins/module_utils/deprecated_utils.py | 4 +- .../deprecated_thing.py | 4 +- .../integration/targets/assert/tasks/main.yml | 2 +- test/integration/targets/async/tasks/main.yml | 2 +- .../legacy_warning_display.py | 2 +- .../module_utils/shared_deprecation.py | 7 +- .../targets/gather_facts-errors/aliases | 3 + .../gather_facts-errors/library/fail1.py | 12 + .../gather_facts-errors/library/fail2.py | 12 + .../gather_facts-errors/library/success1.py | 12 + .../gather_facts-errors/tasks/main.yml | 16 ++ .../gather_facts-errors/test_gather_facts.yml | 23 ++ .../targets/lookup_first_found/tasks/main.yml | 2 +- .../lookup_plugins/synthetic_plugin_info.py | 4 +- .../targets/protomatter/tasks/main.yml | 8 +- .../targets/var_templating/undefined.yml | 4 +- test/units/_internal/templating/conftest.py | 3 +- .../templating/test_lazy_containers.py | 8 +- .../_internal/templating/test_templar.py | 13 +- test/units/_internal/test_event_formatting.py | 29 +++ test/units/errors/test_utils.py | 14 +- .../module_common/test_recursive_finder.py | 5 +- test/units/mock/messages.py | 12 - .../_internal/_patches/test_socket_patch.py | 2 +- .../module_utils/_internal/test_deprecator.py | 8 +- .../module_utils/common/warnings/test_warn.py | 8 +- .../module_utils/datatag/test_datatag.py | 22 +- test/units/parsing/utils/test_yaml.py | 4 +- test/units/parsing/vault/test_vault.py | 5 +- test/units/parsing/yaml/test_errors.py | 4 +- .../units/plugins/action/test_gather_facts.py | 8 +- test/units/test_utils/controller/display.py | 6 +- test/units/utils/test_datatag.py | 2 +- test/units/utils/test_display.py | 44 ++-- test/units/utils/test_serialization.py | 2 +- .../utils/test_serialization_profiles.py | 10 +- 97 files changed, 1045 insertions(+), 705 deletions(-) create mode 100644 lib/ansible/_internal/_errors/_error_factory.py create mode 100644 lib/ansible/_internal/_event_formatting.py create mode 100644 lib/ansible/module_utils/_internal/_event_utils.py rename lib/ansible/module_utils/{common/messages.py => _internal/_messages.py} (55%) create mode 100644 lib/ansible/module_utils/_internal/_stack.py create mode 100644 lib/ansible/module_utils/_internal/_text_utils.py create mode 100644 test/integration/targets/gather_facts-errors/aliases create mode 100644 test/integration/targets/gather_facts-errors/library/fail1.py create mode 100644 test/integration/targets/gather_facts-errors/library/fail2.py create mode 100644 test/integration/targets/gather_facts-errors/library/success1.py create mode 100644 test/integration/targets/gather_facts-errors/tasks/main.yml create mode 100644 test/integration/targets/gather_facts-errors/test_gather_facts.yml create mode 100644 test/units/_internal/test_event_formatting.py delete mode 100644 test/units/mock/messages.py diff --git a/changelogs/fragments/templates_types_datatagging.yml b/changelogs/fragments/templates_types_datatagging.yml index 8c929bd6cbb..9b63a67d015 100644 --- a/changelogs/fragments/templates_types_datatagging.yml +++ b/changelogs/fragments/templates_types_datatagging.yml @@ -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. diff --git a/lib/ansible/_internal/_collection_proxy.py b/lib/ansible/_internal/_collection_proxy.py index 4e7321a5c16..b14dcf386fa 100644 --- a/lib/ansible/_internal/_collection_proxy.py +++ b/lib/ansible/_internal/_collection_proxy.py @@ -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',) diff --git a/lib/ansible/_internal/_errors/_captured.py b/lib/ansible/_internal/_errors/_captured.py index 20fc63cd4f7..c4396b35600 100644 --- a/lib/ansible/_internal/_errors/_captured.py +++ b/lib/ansible/_internal/_errors/_captured.py @@ -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.'))),), - formatted_traceback=cls._normalize_traceback(exception), + 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 diff --git a/lib/ansible/_internal/_errors/_error_factory.py b/lib/ansible/_internal/_errors/_error_factory.py new file mode 100644 index 00000000000..7e48957e525 --- /dev/null +++ b/lib/ansible/_internal/_errors/_error_factory.py @@ -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 diff --git a/lib/ansible/_internal/_errors/_utils.py b/lib/ansible/_internal/_errors/_utils.py index b77c2961548..b85522c1f1c 100644 --- a/lib/ansible/_internal/_errors/_utils.py +++ b/lib/ansible/_internal/_errors/_utils.py @@ -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) diff --git a/lib/ansible/_internal/_event_formatting.py b/lib/ansible/_internal/_event_formatting.py new file mode 100644 index 00000000000..b06af1b3f20 --- /dev/null +++ b/lib/ansible/_internal/_event_formatting.py @@ -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 diff --git a/lib/ansible/_internal/_json/__init__.py b/lib/ansible/_internal/_json/__init__.py index 201f0ca1ecc..069e51dac5d 100644 --- a/lib/ansible/_internal/_json/__init__.py +++ b/lib/ansible/_internal/_json/__init__.py @@ -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) diff --git a/lib/ansible/_internal/_json/_profiles/_inventory_legacy.py b/lib/ansible/_internal/_json/_profiles/_inventory_legacy.py index aa9c8ea1057..97941957fdf 100644 --- a/lib/ansible/_internal/_json/_profiles/_inventory_legacy.py +++ b/lib/ansible/_internal/_json/_profiles/_inventory_legacy.py @@ -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: diff --git a/lib/ansible/_internal/_json/_profiles/_legacy.py b/lib/ansible/_internal/_json/_profiles/_legacy.py index c1638468a9d..95f23c103b2 100644 --- a/lib/ansible/_internal/_json/_profiles/_legacy.py +++ b/lib/ansible/_internal/_json/_profiles/_legacy.py @@ -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) diff --git a/lib/ansible/_internal/_templating/_datatag.py b/lib/ansible/_internal/_templating/_datatag.py index f3898998bc4..db03fd1cb31 100644 --- a/lib/ansible/_internal/_templating/_datatag.py +++ b/lib/ansible/_internal/_templating/_datatag.py @@ -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 diff --git a/lib/ansible/_internal/_templating/_engine.py b/lib/ansible/_internal/_templating/_engine.py index f8809198d38..de3d70e38d1 100644 --- a/lib/ansible/_internal/_templating/_engine.py +++ b/lib/ansible/_internal/_templating/_engine.py @@ -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 {} diff --git a/lib/ansible/_internal/_templating/_jinja_bits.py b/lib/ansible/_internal/_templating/_jinja_bits.py index 71e62d25cfd..8603307c4e5 100644 --- a/lib/ansible/_internal/_templating/_jinja_bits.py +++ b/lib/ansible/_internal/_templating/_jinja_bits.py @@ -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) diff --git a/lib/ansible/_internal/_templating/_jinja_common.py b/lib/ansible/_internal/_templating/_jinja_common.py index f73d67636ec..42413f951b1 100644 --- a/lib/ansible/_internal/_templating/_jinja_common.py +++ b/lib/ansible/_internal/_templating/_jinja_common.py @@ -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 diff --git a/lib/ansible/_internal/_templating/_lazy_containers.py b/lib/ansible/_internal/_templating/_lazy_containers.py index 714bc37f357..6873064544c 100644 --- a/lib/ansible/_internal/_templating/_lazy_containers.py +++ b/lib/ansible/_internal/_templating/_lazy_containers.py @@ -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' diff --git a/lib/ansible/_internal/_templating/_transform.py b/lib/ansible/_internal/_templating/_transform.py index 82413a7f6ee..6293341bb23 100644 --- a/lib/ansible/_internal/_templating/_transform.py +++ b/lib/ansible/_internal/_templating/_transform.py @@ -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.""" diff --git a/lib/ansible/_internal/_templating/_utils.py b/lib/ansible/_internal/_templating/_utils.py index 1f77075dae7..7c6be307f31 100644 --- a/lib/ansible/_internal/_templating/_utils.py +++ b/lib/ansible/_internal/_templating/_utils.py @@ -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 | { diff --git a/lib/ansible/_internal/_yaml/_dumper.py b/lib/ansible/_internal/_yaml/_dumper.py index dc54ae8ee3a..9d00fda480c 100644 --- a/lib/ansible/_internal/_yaml/_dumper.py +++ b/lib/ansible/_internal/_yaml/_dumper.py @@ -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) diff --git a/lib/ansible/cli/__init__.py b/lib/ansible/cli/__init__.py index 22b7480c385..309c119c0f9 100644 --- a/lib/ansible/cli/__init__.py +++ b/lib/ansible/cli/__init__.py @@ -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'))) diff --git a/lib/ansible/cli/doc.py b/lib/ansible/cli/doc.py index 550ac9de9a9..458d6db4d6d 100755 --- a/lib/ansible/cli/doc.py +++ b/lib/ansible/cli/doc.py @@ -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'] diff --git a/lib/ansible/cli/inventory.py b/lib/ansible/cli/inventory.py index 8033b2e0f95..1a2d9d75ef4 100755 --- a/lib/ansible/cli/inventory.py +++ b/lib/ansible/cli/inventory.py @@ -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']: diff --git a/lib/ansible/config/base.yml b/lib/ansible/config/base.yml index 62c4b8b5ac4..72905dcf2f2 100644 --- a/lib/ansible/config/base.yml +++ b/lib/ansible/config/base.yml @@ -1335,6 +1335,7 @@ DISPLAY_TRACEBACK: - error - warning - deprecated + - deprecated_value - always - never version_added: "2.19" diff --git a/lib/ansible/errors/__init__.py b/lib/ansible/errors/__init__.py index 30021ebef62..60ea173576a 100644 --- a/lib/ansible/errors/__init__.py +++ b/lib/ansible/errors/__init__.py @@ -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: diff --git a/lib/ansible/executor/module_common.py b/lib/ansible/executor/module_common.py index 11793168215..79832e8b929 100644 --- a/lib/ansible/executor/module_common.py +++ b/lib/ansible/executor/module_common.py @@ -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 diff --git a/lib/ansible/executor/task_executor.py b/lib/ansible/executor/task_executor.py index 1283613086c..061d46e4c5e 100644 --- a/lib/ansible/executor/task_executor.py +++ b/lib/ansible/executor/task_executor.py @@ -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, ) diff --git a/lib/ansible/executor/task_result.py b/lib/ansible/executor/task_result.py index a60c97cab17..dfdbd41dddb 100644 --- a/lib/ansible/executor/task_result.py +++ b/lib/ansible/executor/task_result.py @@ -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]: diff --git a/lib/ansible/module_utils/_internal/_ansiballz.py b/lib/ansible/module_utils/_internal/_ansiballz.py index 65d0781a62f..b0e0e1a26b6 100644 --- a/lib/ansible/module_utils/_internal/_ansiballz.py +++ b/lib/ansible/module_utils/_internal/_ansiballz.py @@ -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) diff --git a/lib/ansible/module_utils/_internal/_datatag/__init__.py b/lib/ansible/module_utils/_internal/_datatag/__init__.py index 4192d8d9b24..5fba8623a32 100644 --- a/lib/ansible/module_utils/_internal/_datatag/__init__.py +++ b/lib/ansible/module_utils/_internal/_datatag/__init__.py @@ -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 - _dataclass_validation.inject_post_init_validation(cls, cls._validation_allow_subclasses) # code gen a real __post_init__ method + 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: diff --git a/lib/ansible/module_utils/_internal/_datatag/_tags.py b/lib/ansible/module_utils/_internal/_datatag/_tags.py index 9b271e142ee..011aeed46c5 100644 --- a/lib/ansible/module_utils/_internal/_datatag/_tags.py +++ b/lib/ansible/module_utils/_internal/_datatag/_tags.py @@ -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 diff --git a/lib/ansible/module_utils/_internal/_deprecator.py b/lib/ansible/module_utils/_internal/_deprecator.py index 64ab40da0e3..f31942a1938 100644 --- a/lib/ansible/module_utils/_internal/_deprecator.py +++ b/lib/ansible/module_utils/_internal/_deprecator.py @@ -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', - } -) -"""Plugin types for which basename cannot be used to identify the plugin name.""" + return _messages.PluginInfo( + resolved_name=collection_name, + type=_COLLECTION_ONLY_TYPE, + ) -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\w+)/(?P\w+)/plugins/(?P\w+)/(?P\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.""" diff --git a/lib/ansible/module_utils/_internal/_errors.py b/lib/ansible/module_utils/_internal/_errors.py index b6e6d749071..294a74210f0 100644 --- a/lib/ansible/module_utils/_internal/_errors.py +++ b/lib/ansible/module_utils/_internal/_errors.py @@ -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 >>>' -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] = [] +class EventFactory: + """Factory for creating `Event` instances from `BaseException` instances on targets.""" - while target_exception: - error_details.append(Detail(msg=str(target_exception).strip())) + _MAX_DEPTH = 10 + """Maximum exception chain depth. Exceptions beyond this depth will be omitted.""" - target_exception = target_exception.__cause__ + @classmethod + def from_exception(cls, exception: BaseException, include_traceback: bool) -> _messages.Event: + return cls(include_traceback)._convert_exception(exception) - return tuple(error_details) + 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 _get_cause(self, exception: BaseException) -> BaseException | None: + return exception.__cause__ + + def _get_context(self, exception: BaseException) -> BaseException | None: + if exception.__suppress_context__: + return None + + return exception.__context__ + + def _get_events(self, exception: BaseException) -> tuple[_messages.Event, ...] | None: + # deprecated: description='move BaseExceptionGroup support here from ControllerEventFactory' python_version='3.10' + return None diff --git a/lib/ansible/module_utils/_internal/_event_utils.py b/lib/ansible/module_utils/_internal/_event_utils.py new file mode 100644 index 00000000000..fba88691872 --- /dev/null +++ b/lib/ansible/module_utils/_internal/_event_utils.py @@ -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 diff --git a/lib/ansible/module_utils/_internal/_json/_profiles/__init__.py b/lib/ansible/module_utils/_internal/_json/_profiles/__init__.py index 8dd1c8eb1f4..e28c83739b0 100644 --- a/lib/ansible/module_utils/_internal/_json/_profiles/__init__.py +++ b/lib/ansible/module_utils/_internal/_json/_profiles/__init__.py @@ -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 diff --git a/lib/ansible/module_utils/_internal/_json/_profiles/_tagless.py b/lib/ansible/module_utils/_internal/_json/_profiles/_tagless.py index 504049d78e8..c741cf9aa97 100644 --- a/lib/ansible/module_utils/_internal/_json/_profiles/_tagless.py +++ b/lib/ansible/module_utils/_internal/_json/_profiles/_tagless.py @@ -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, diff --git a/lib/ansible/module_utils/common/messages.py b/lib/ansible/module_utils/_internal/_messages.py similarity index 55% rename from lib/ansible/module_utils/common/messages.py rename to lib/ansible/module_utils/_internal/_messages.py index 9d7d5f2bfe8..7c1634f12d6 100644 --- a/lib/ansible/module_utils/common/messages.py +++ b/lib/ansible/module_utils/_internal/_messages.py @@ -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.validate_collection_name(collection_name) + _validation_auto_enabled = False - return cls( - resolved_name=collection_name, - type=cls._COLLECTION_ONLY_TYPE, - ) + def __post_init__(self): ... # required for deferred dataclass validation + + 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 diff --git a/lib/ansible/module_utils/_internal/_plugin_info.py b/lib/ansible/module_utils/_internal/_plugin_info.py index 12ae2998d6b..a4d5776746c 100644 --- a/lib/ansible/module_utils/_internal/_plugin_info.py +++ b/lib/ansible/module_utils/_internal/_plugin_info.py @@ -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): diff --git a/lib/ansible/module_utils/_internal/_stack.py b/lib/ansible/module_utils/_internal/_stack.py new file mode 100644 index 00000000000..00ca51d820b --- /dev/null +++ b/lib/ansible/module_utils/_internal/_stack.py @@ -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 diff --git a/lib/ansible/module_utils/_internal/_text_utils.py b/lib/ansible/module_utils/_internal/_text_utils.py new file mode 100644 index 00000000000..aefad6bdbf6 --- /dev/null +++ b/lib/ansible/module_utils/_internal/_text_utils.py @@ -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}' diff --git a/lib/ansible/module_utils/_internal/_traceback.py b/lib/ansible/module_utils/_internal/_traceback.py index 1e405eff1f8..abc33f70902 100644 --- a/lib/ansible/module_utils/_internal/_traceback.py +++ b/lib/ansible/module_utils/_internal/_traceback.py @@ -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) diff --git a/lib/ansible/module_utils/basic.py b/lib/ansible/module_utils/basic.py index 1f56d60f114..2f4ab349818 100644 --- a/lib/ansible/module_utils/basic.py +++ b/lib/ansible/module_utils/basic.py @@ -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. - - 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) + # 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. + + 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 diff --git a/lib/ansible/module_utils/common/arg_spec.py b/lib/ansible/module_utils/common/arg_spec.py index f4883778ec4..5044f58a8cc 100644 --- a/lib/ansible/module_utils/common/arg_spec.py +++ b/lib/ansible/module_utils/common/arg_spec.py @@ -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: diff --git a/lib/ansible/module_utils/common/warnings.py b/lib/ansible/module_utils/common/warnings.py index 4d405590939..ee403d5fba3 100644 --- a/lib/ansible/module_utils/common/warnings.py +++ b/lib/ansible/module_utils/common/warnings.py @@ -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), ), - 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), ), - 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.""" diff --git a/lib/ansible/module_utils/datatag.py b/lib/ansible/module_utils/datatag.py index e32b0ec7538..c89ba1f5f91 100644 --- a/lib/ansible/module_utils/datatag.py +++ b/lib/ansible/module_utils/datatag.py @@ -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) diff --git a/lib/ansible/parsing/ajson.py b/lib/ansible/parsing/ajson.py index c1b215d844e..abad49608fa 100644 --- a/lib/ansible/parsing/ajson.py +++ b/lib/ansible/parsing/ajson.py @@ -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( diff --git a/lib/ansible/parsing/mod_args.py b/lib/ansible/parsing/mod_args.py index c19d56e91df..0b3d0c7ab3f 100644 --- a/lib/ansible/parsing/mod_args.py +++ b/lib/ansible/parsing/mod_args.py @@ -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: diff --git a/lib/ansible/parsing/vault/__init__.py b/lib/ansible/parsing/vault/__init__.py index 0cf19dcb4d5..0e6635730d9 100644 --- a/lib/ansible/parsing/vault/__init__.py +++ b/lib/ansible/parsing/vault/__init__.py @@ -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 diff --git a/lib/ansible/playbook/playbook_include.py b/lib/ansible/playbook/playbook_include.py index e7fdad0e7df..9cdd5ee58d3 100644 --- a/lib/ansible/playbook/playbook_include.py +++ b/lib/ansible/playbook/playbook_include.py @@ -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()) diff --git a/lib/ansible/playbook/taggable.py b/lib/ansible/playbook/taggable.py index 163e3380018..85162659f01 100644 --- a/lib/ansible/playbook/taggable.py +++ b/lib/ansible/playbook/taggable.py @@ -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) diff --git a/lib/ansible/plugins/action/__init__.py b/lib/ansible/plugins/action/__init__.py index c6073bd44e1..deba249c39c 100644 --- a/lib/ansible/plugins/action/__init__.py +++ b/lib/ansible/plugins/action/__init__.py @@ -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 diff --git a/lib/ansible/plugins/action/gather_facts.py b/lib/ansible/plugins/action/gather_facts.py index 64dc457cfb7..cb0e8c741f6 100644 --- a/lib/ansible/plugins/action/gather_facts.py +++ b/lib/ansible/plugins/action/gather_facts.py @@ -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 diff --git a/lib/ansible/plugins/callback/__init__.py b/lib/ansible/plugins/callback/__init__.py index 1d2ed693cc3..bb3840f405f 100644 --- a/lib/ansible/plugins/callback/__init__.py +++ b/lib/ansible/plugins/callback/__init__.py @@ -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): diff --git a/lib/ansible/plugins/callback/junit.py b/lib/ansible/plugins/callback/junit.py index 812f6fe583a..1459c7adebe 100644 --- a/lib/ansible/plugins/callback/junit.py +++ b/lib/ansible/plugins/callback/junit.py @@ -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'] diff --git a/lib/ansible/plugins/connection/ssh.py b/lib/ansible/plugins/connection/ssh.py index 3e854a612b5..43feb711ec6 100644 --- a/lib/ansible/plugins/connection/ssh.py +++ b/lib/ansible/plugins/connection/ssh.py @@ -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'): diff --git a/lib/ansible/plugins/filter/core.py b/lib/ansible/plugins/filter/core.py index f725b3500e1..e28fadadfab 100644 --- a/lib/ansible/plugins/filter/core.py +++ b/lib/ansible/plugins/filter/core.py @@ -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) diff --git a/lib/ansible/plugins/loader.py b/lib/ansible/plugins/loader.py index 56e14f56887..a30633e4c0e 100644 --- a/lib/ansible/plugins/loader.py +++ b/lib/ansible/plugins/loader.py @@ -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) diff --git a/lib/ansible/plugins/test/core.py b/lib/ansible/plugins/test/core.py index e29e8c24bcb..37fc2173552 100644 --- a/lib/ansible/plugins/test/core.py +++ b/lib/ansible/plugins/test/core.py @@ -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): diff --git a/lib/ansible/template/__init__.py b/lib/ansible/template/__init__.py index 842cfe38090..66219b6ba35 100644 --- a/lib/ansible/template/__init__.py +++ b/lib/ansible/template/__init__.py @@ -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( diff --git a/lib/ansible/utils/display.py b/lib/ansible/utils/display.py index 208df5f5c35..e0a2bf73e60 100644 --- a/lib/ansible/utils/display.py +++ b/lib/ansible/utils/display.py @@ -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( - msg=msg, - formatted_source_context=formatted_source_context, - help_text=help_text, - ), + 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( - msg=msg, - help_text=help_text, - formatted_source_context=formatted_source_context, - ), + 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), ), - 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() diff --git a/lib/ansible/utils/unsafe_proxy.py b/lib/ansible/utils/unsafe_proxy.py index ac92d6e7bc7..9ecd6aa5999 100644 --- a/lib/ansible/utils/unsafe_proxy.py +++ b/lib/ansible/utils/unsafe_proxy.py @@ -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 diff --git a/test/integration/targets/ansible-inventory/tasks/toml.yml b/test/integration/targets/ansible-inventory/tasks/toml.yml index 2fdcf9c67ec..8bc77a8bb64 100644 --- a/test/integration/targets/ansible-inventory/tasks/toml.yml +++ b/test/integration/targets/ansible-inventory/tasks/toml.yml @@ -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 diff --git a/test/integration/targets/ansible-inventory/tasks/toml_output.yml b/test/integration/targets/ansible-inventory/tasks/toml_output.yml index b0da6847642..8eaac51f393 100644 --- a/test/integration/targets/ansible-inventory/tasks/toml_output.yml +++ b/test/integration/targets/ansible-inventory/tasks/toml_output.yml @@ -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 diff --git a/test/integration/targets/ansible-test-sanity-pylint/ansible_collections/ns/col/plugins/action/do_deprecated_stuff.py b/test/integration/targets/ansible-test-sanity-pylint/ansible_collections/ns/col/plugins/action/do_deprecated_stuff.py index f7dc448c66f..76c31cd9659 100644 --- a/test/integration/targets/ansible-test-sanity-pylint/ansible_collections/ns/col/plugins/action/do_deprecated_stuff.py +++ b/test/integration/targets/ansible-test-sanity-pylint/ansible_collections/ns/col/plugins/action/do_deprecated_stuff.py @@ -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') diff --git a/test/integration/targets/ansible-test-sanity-pylint/ansible_collections/ns/col/plugins/module_utils/deprecated_utils.py b/test/integration/targets/ansible-test-sanity-pylint/ansible_collections/ns/col/plugins/module_utils/deprecated_utils.py index 5e1d183d730..0e30277b1fb 100644 --- a/test/integration/targets/ansible-test-sanity-pylint/ansible_collections/ns/col/plugins/module_utils/deprecated_utils.py +++ b/test/integration/targets/ansible-test-sanity-pylint/ansible_collections/ns/col/plugins/module_utils/deprecated_utils.py @@ -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') diff --git a/test/integration/targets/ansible-test-sanity-pylint/deprecated_thing.py b/test/integration/targets/ansible-test-sanity-pylint/deprecated_thing.py index b37db5337b1..a87844db530 100644 --- a/test/integration/targets/ansible-test-sanity-pylint/deprecated_thing.py +++ b/test/integration/targets/ansible-test-sanity-pylint/deprecated_thing.py @@ -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') diff --git a/test/integration/targets/assert/tasks/main.yml b/test/integration/targets/assert/tasks/main.yml index 427c54a62de..030b82ea808 100644 --- a/test/integration/targets/assert/tasks/main.yml +++ b/test/integration/targets/assert/tasks/main.yml @@ -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: diff --git a/test/integration/targets/async/tasks/main.yml b/test/integration/targets/async/tasks/main.yml index 334889f5704..66dac18b26b 100644 --- a/test/integration/targets/async/tasks/main.yml +++ b/test/integration/targets/async/tasks/main.yml @@ -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 diff --git a/test/integration/targets/callback-legacy-warnings/callback_plugins/legacy_warning_display.py b/test/integration/targets/callback-legacy-warnings/callback_plugins/legacy_warning_display.py index c40f41a2a4e..02b5d21c6c4 100644 --- a/test/integration/targets/callback-legacy-warnings/callback_plugins/legacy_warning_display.py +++ b/test/integration/targets/callback-legacy-warnings/callback_plugins/legacy_warning_display.py @@ -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 diff --git a/test/integration/targets/deprecations/collections/ansible_collections/foo/bar/plugins/module_utils/shared_deprecation.py b/test/integration/targets/deprecations/collections/ansible_collections/foo/bar/plugins/module_utils/shared_deprecation.py index 9442309ee48..24015b7c739 100644 --- a/test/integration/targets/deprecations/collections/ansible_collections/foo/bar/plugins/module_utils/shared_deprecation.py +++ b/test/integration/targets/deprecations/collections/ansible_collections/foo/bar/plugins/module_utils/shared_deprecation.py @@ -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."), ] diff --git a/test/integration/targets/gather_facts-errors/aliases b/test/integration/targets/gather_facts-errors/aliases new file mode 100644 index 00000000000..8f80e7ad8dc --- /dev/null +++ b/test/integration/targets/gather_facts-errors/aliases @@ -0,0 +1,3 @@ +context/controller +shippable/posix/group3 +gather_facts/no diff --git a/test/integration/targets/gather_facts-errors/library/fail1.py b/test/integration/targets/gather_facts-errors/library/fail1.py new file mode 100644 index 00000000000..3dec8499701 --- /dev/null +++ b/test/integration/targets/gather_facts-errors/library/fail1.py @@ -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() diff --git a/test/integration/targets/gather_facts-errors/library/fail2.py b/test/integration/targets/gather_facts-errors/library/fail2.py new file mode 100644 index 00000000000..e9db108e0b1 --- /dev/null +++ b/test/integration/targets/gather_facts-errors/library/fail2.py @@ -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() diff --git a/test/integration/targets/gather_facts-errors/library/success1.py b/test/integration/targets/gather_facts-errors/library/success1.py new file mode 100644 index 00000000000..4aa631e2600 --- /dev/null +++ b/test/integration/targets/gather_facts-errors/library/success1.py @@ -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() diff --git a/test/integration/targets/gather_facts-errors/tasks/main.yml b/test/integration/targets/gather_facts-errors/tasks/main.yml new file mode 100644 index 00000000000..22f1a9a6834 --- /dev/null +++ b/test/integration/targets/gather_facts-errors/tasks/main.yml @@ -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" diff --git a/test/integration/targets/gather_facts-errors/test_gather_facts.yml b/test/integration/targets/gather_facts-errors/test_gather_facts.yml new file mode 100644 index 00000000000..be3a69256a0 --- /dev/null +++ b/test/integration/targets/gather_facts-errors/test_gather_facts.yml @@ -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 diff --git a/test/integration/targets/lookup_first_found/tasks/main.yml b/test/integration/targets/lookup_first_found/tasks/main.yml index 4a9c002cc50..e8595c3b5de 100644 --- a/test/integration/targets/lookup_first_found/tasks/main.yml +++ b/test/integration/targets/lookup_first_found/tasks/main.yml @@ -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 diff --git a/test/integration/targets/protomatter/lookup_plugins/synthetic_plugin_info.py b/test/integration/targets/protomatter/lookup_plugins/synthetic_plugin_info.py index c5968f217cd..9daf8b98278 100644 --- a/test/integration/targets/protomatter/lookup_plugins/synthetic_plugin_info.py +++ b/test/integration/targets/protomatter/lookup_plugins/synthetic_plugin_info.py @@ -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', )] diff --git a/test/integration/targets/protomatter/tasks/main.yml b/test/integration/targets/protomatter/tasks/main.yml index 766b3882647..41a834ed207 100644 --- a/test/integration/targets/protomatter/tasks/main.yml +++ b/test/integration/targets/protomatter/tasks/main.yml @@ -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' diff --git a/test/integration/targets/var_templating/undefined.yml b/test/integration/targets/var_templating/undefined.yml index ed7b9c54181..fd5e1536a26 100644 --- a/test/integration/targets/var_templating/undefined.yml +++ b/test/integration/targets/var_templating/undefined.yml @@ -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: diff --git a/test/units/_internal/templating/conftest.py b/test/units/_internal/templating/conftest.py index ad634336765..13e1be056e7 100644 --- a/test/units/_internal/templating/conftest.py +++ b/test/units/_internal/templating/conftest.py @@ -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: diff --git a/test/units/_internal/templating/test_lazy_containers.py b/test/units/_internal/templating/test_lazy_containers.py index ccb6e387c5f..87d4f11e9ff 100644 --- a/test/units/_internal/templating/test_lazy_containers.py +++ b/test/units/_internal/templating/test_lazy_containers.py @@ -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) diff --git a/test/units/_internal/templating/test_templar.py b/test/units/_internal/templating/test_templar.py index c14ef02a45e..3a6bb2b1ac8 100644 --- a/test/units/_internal/templating/test_templar.py +++ b/test/units/_internal/templating/test_templar.py @@ -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: diff --git a/test/units/_internal/test_event_formatting.py b/test/units/_internal/test_event_formatting.py new file mode 100644 index 00000000000..64fd98b5aa9 --- /dev/null +++ b/test/units/_internal/test_event_formatting.py @@ -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 diff --git a/test/units/errors/test_utils.py b/test/units/errors/test_utils.py index 17ec214a63b..fb3debcfdf3 100644 --- a/test/units/errors/test_utils.py +++ b/test/units/errors/test_utils.py @@ -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) diff --git a/test/units/executor/module_common/test_recursive_finder.py b/test/units/executor/module_common/test_recursive_finder.py index 57b75ac90e3..05fb23fcf31 100644 --- a/test/units/executor/module_common/test_recursive_finder.py +++ b/test/units/executor/module_common/test_recursive_finder.py @@ -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', diff --git a/test/units/mock/messages.py b/test/units/mock/messages.py deleted file mode 100644 index f1ab40b628d..00000000000 --- a/test/units/mock/messages.py +++ /dev/null @@ -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) diff --git a/test/units/module_utils/_internal/_patches/test_socket_patch.py b/test/units/module_utils/_internal/_patches/test_socket_patch.py index 176bd2ffbef..707cc81d1b7 100644 --- a/test/units/module_utils/_internal/_patches/test_socket_patch.py +++ b/test/units/module_utils/_internal/_patches/test_socket_patch.py @@ -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)) diff --git a/test/units/module_utils/_internal/test_deprecator.py b/test/units/module_utils/_internal/test_deprecator.py index 6c873c37040..76f9f45546a 100644 --- a/test/units/module_utils/_internal/test_deprecator.py +++ b/test/units/module_utils/_internal/test_deprecator.py @@ -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 diff --git a/test/units/module_utils/common/warnings/test_warn.py b/test/units/module_utils/common/warnings/test_warn.py index bbf4fb8a800..f62ad361966 100644 --- a/test/units/module_utils/common/warnings/test_warn.py +++ b/test/units/module_utils/common/warnings/test_warn.py @@ -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: diff --git a/test/units/module_utils/datatag/test_datatag.py b/test/units/module_utils/datatag/test_datatag.py index 487d7a805a7..9d88561d0c1 100644 --- a/test/units/module_utils/datatag/test_datatag.py +++ b/test/units/module_utils/datatag/test_datatag.py @@ -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) diff --git a/test/units/parsing/utils/test_yaml.py b/test/units/parsing/utils/test_yaml.py index 340b984eedf..26e7d704804 100644 --- a/test/units/parsing/utils/test_yaml.py +++ b/test/units/parsing/utils/test_yaml.py @@ -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) diff --git a/test/units/parsing/vault/test_vault.py b/test/units/parsing/vault/test_vault.py index 3f612ba912e..fa705dda8a3 100644 --- a/test/units/parsing/vault/test_vault.py +++ b/test/units/parsing/vault/test_vault.py @@ -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) diff --git a/test/units/parsing/yaml/test_errors.py b/test/units/parsing/yaml/test_errors.py index d60aa5b0b7b..3253d348e31 100644 --- a/test/units/parsing/yaml/test_errors.py +++ b/test/units/parsing/yaml/test_errors.py @@ -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) diff --git a/test/units/plugins/action/test_gather_facts.py b/test/units/plugins/action/test_gather_facts.py index f99a0e84ff0..f123c02ef68 100644 --- a/test/units/plugins/action/test_gather_facts.py +++ b/test/units/plugins/action/test_gather_facts.py @@ -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) diff --git a/test/units/test_utils/controller/display.py b/test/units/test_utils/controller/display.py index b768fe3f109..f15c68a597e 100644 --- a/test/units/test_utils/controller/display.py +++ b/test/units/test_utils/controller/display.py @@ -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: diff --git a/test/units/utils/test_datatag.py b/test/units/utils/test_datatag.py index f8400e0baa4..d7e066fef21 100644 --- a/test/units/utils/test_datatag.py +++ b/test/units/utils/test_datatag.py @@ -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 [] diff --git a/test/units/utils/test_display.py b/test/units/utils/test_display.py index 7acedd18f44..5e6f51d843a 100644 --- a/test/units/utils/test_display.py +++ b/test/units/utils/test_display.py @@ -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', ( diff --git a/test/units/utils/test_serialization.py b/test/units/utils/test_serialization.py index 7b3cb52d4d8..35fb8bf9fd9 100644 --- a/test/units/utils/test_serialization.py +++ b/test/units/utils/test_serialization.py @@ -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 diff --git a/test/units/utils/test_serialization_profiles.py b/test/units/utils/test_serialization_profiles.py index 5c5242fff54..b364a3337cd 100644 --- a/test/units/utils/test_serialization_profiles.py +++ b/test/units/utils/test_serialization_profiles.py @@ -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