adjust PluginInfo to use PluginType enum (#85277)

* normalization fixups

Co-authored-by: Matt Clay <matt@mystile.com>
pull/83775/merge
Matt Davis 6 months ago committed by GitHub
parent 9f0a8075e3
commit 43c0132caa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -21,6 +21,11 @@ def plugin_info(value: _messages.PluginInfo) -> dict[str, str]:
return dataclasses.asdict(value)
def plugin_type(value: _messages.PluginType) -> str:
"""Render PluginType as a string."""
return value.value
def error_summary(value: _messages.ErrorSummary) -> str:
"""Render ErrorSummary as a formatted traceback for backward-compatibility with pre-2.19 TaskResult.exception."""
if _traceback._is_traceback_enabled(_traceback.TracebackEvent.ERROR):
@ -56,6 +61,7 @@ def encrypted_string(value: EncryptedString) -> str | VaultExceptionMarker:
_type_transform_mapping: dict[type, t.Callable[[t.Any], t.Any]] = {
_captured.CapturedErrorSummary: error_summary,
_messages.PluginInfo: plugin_info,
_messages.PluginType: plugin_type,
_messages.ErrorSummary: error_summary,
_messages.WarningSummary: warning_summary,
_messages.DeprecationSummary: deprecation_summary,

@ -5,6 +5,7 @@ import collections.abc as c
import copy
import dataclasses
import datetime
import enum
import inspect
import sys
@ -216,7 +217,7 @@ class AnsibleTagHelper:
return value
class AnsibleSerializable(metaclass=abc.ABCMeta):
class AnsibleSerializable:
__slots__ = _NO_INSTANCE_STORAGE
_known_type_map: t.ClassVar[t.Dict[str, t.Type['AnsibleSerializable']]] = {}
@ -274,6 +275,27 @@ class AnsibleSerializable(metaclass=abc.ABCMeta):
return f'{name}({arg_string})'
class AnsibleSerializableEnum(AnsibleSerializable, enum.Enum):
"""Base class for serializable enumerations."""
def _as_dict(self) -> t.Dict[str, t.Any]:
return dict(value=self.value)
@classmethod
def _from_dict(cls, d: t.Dict[str, t.Any]) -> t.Self:
return cls(d['value'].lower())
def __str__(self) -> str:
return self.value
def __repr__(self) -> str:
return f'<{self.__class__.__name__}.{self.name}>'
@staticmethod
def _generate_next_value_(name, start, count, last_values):
return name.lower()
class AnsibleSerializableWrapper(AnsibleSerializable, t.Generic[_T], metaclass=abc.ABCMeta):
__slots__ = ('_value',)

@ -5,7 +5,7 @@ import pathlib
import sys
import typing as t
from ansible.module_utils._internal import _stack, _messages, _validation
from ansible.module_utils._internal import _stack, _messages, _validation, _plugin_info
def deprecator_from_collection_name(collection_name: str | None) -> _messages.PluginInfo | None:
@ -19,7 +19,7 @@ def deprecator_from_collection_name(collection_name: str | None) -> _messages.Pl
return _messages.PluginInfo(
resolved_name=collection_name,
type=_COLLECTION_ONLY_TYPE,
type=None,
)
@ -54,7 +54,7 @@ def _path_as_core_plugininfo(path: str) -> _messages.PluginInfo | None:
if match := re.match(r'plugins/(?P<plugin_type>\w+)/(?P<plugin_name>\w+)', relpath):
plugin_name = match.group("plugin_name")
plugin_type = match.group("plugin_type")
plugin_type = _plugin_info.normalize_plugin_type(match.group("plugin_type"))
if plugin_type not in _DEPRECATOR_PLUGIN_TYPES:
# The plugin type isn't a known deprecator type, so we have to assume the caller is intermediate code.
@ -65,13 +65,13 @@ def _path_as_core_plugininfo(path: str) -> _messages.PluginInfo | None:
elif match := re.match(r'modules/(?P<module_name>\w+)', relpath):
# AnsiballZ Python package for core modules
plugin_name = match.group("module_name")
plugin_type = "module"
plugin_type = _messages.PluginType.MODULE
elif match := re.match(r'legacy/(?P<module_name>\w+)', relpath):
# AnsiballZ Python package for non-core library/role modules
namespace = 'ansible.legacy'
plugin_name = match.group("module_name")
plugin_type = "module"
plugin_type = _messages.PluginType.MODULE
else:
return ANSIBLE_CORE_DEPRECATOR # non-plugin core path, safe to use ansible-core for the same reason as the non-deprecator plugin type case above
@ -85,7 +85,7 @@ def _path_as_collection_plugininfo(path: str) -> _messages.PluginInfo | None:
if not (match := re.search(r'/ansible_collections/(?P<ns>\w+)/(?P<coll>\w+)/plugins/(?P<plugin_type>\w+)/(?P<plugin_name>\w+)', path)):
return None
plugin_type = match.group('plugin_type')
plugin_type = _plugin_info.normalize_plugin_type(match.group('plugin_type'))
if plugin_type in _AMBIGUOUS_DEPRECATOR_PLUGIN_TYPES:
# We're able to detect the namespace, collection and plugin type -- but we have no way to identify the plugin name currently.
@ -93,9 +93,6 @@ def _path_as_collection_plugininfo(path: str) -> _messages.PluginInfo | None:
# In the future we could improve the detection and/or make it easier for a caller to identify the plugin name.
return deprecator_from_collection_name('.'.join((match.group('ns'), match.group('coll'))))
if plugin_type == 'modules':
plugin_type = 'module'
if plugin_type not in _DEPRECATOR_PLUGIN_TYPES:
# The plugin type isn't a known deprecator type, so we have to assume the caller is intermediate code.
# We have no way of knowing if the intermediate code is deprecating its own feature, or acting on behalf of another plugin.
@ -107,46 +104,43 @@ def _path_as_collection_plugininfo(path: str) -> _messages.PluginInfo | None:
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')
INDETERMINATE_DEPRECATOR: t.Final = _messages.PluginInfo(resolved_name=None, type=None)
"""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',
_messages.PluginType.ACTION,
_messages.PluginType.BECOME,
_messages.PluginType.CACHE,
_messages.PluginType.CALLBACK,
_messages.PluginType.CLICONF,
_messages.PluginType.CONNECTION,
# DOC_FRAGMENTS - no code execution
# FILTER - basename inadequate to identify plugin
_messages.PluginType.HTTPAPI,
_messages.PluginType.INVENTORY,
_messages.PluginType.LOOKUP,
_messages.PluginType.MODULE, # only for collections
_messages.PluginType.NETCONF,
_messages.PluginType.SHELL,
_messages.PluginType.STRATEGY,
_messages.PluginType.TERMINAL,
# TEST - basename inadequate to identify plugin
_messages.PluginType.VARS,
}
)
"""Plugin types which are valid for identifying a deprecator for deprecation purposes."""
_AMBIGUOUS_DEPRECATOR_PLUGIN_TYPES: t.Final = frozenset(
{
'filter',
'test',
_messages.PluginType.FILTER,
_messages.PluginType.TEST,
}
)
"""Plugin types for which basename cannot be used to identify the plugin name."""

@ -87,6 +87,7 @@ For controller-to-module, type behavior is profile dependent.
_common_module_response_types: frozenset[type[AnsibleSerializable]] = frozenset(
{
_messages.PluginInfo,
_messages.PluginType,
_messages.Event,
_messages.EventChain,
_messages.ErrorSummary,

@ -8,6 +8,7 @@ A future release will remove the provisional status.
from __future__ import annotations as _annotations
import dataclasses as _dataclasses
import enum as _enum
import sys as _sys
import typing as _t
@ -21,14 +22,37 @@ else:
_dataclass_kwargs = dict(frozen=True)
class PluginType(_datatag.AnsibleSerializableEnum):
"""Enum of Ansible plugin types."""
ACTION = _enum.auto()
BECOME = _enum.auto()
CACHE = _enum.auto()
CALLBACK = _enum.auto()
CLICONF = _enum.auto()
CONNECTION = _enum.auto()
DOC_FRAGMENTS = _enum.auto()
FILTER = _enum.auto()
HTTPAPI = _enum.auto()
INVENTORY = _enum.auto()
LOOKUP = _enum.auto()
MODULE = _enum.auto()
NETCONF = _enum.auto()
SHELL = _enum.auto()
STRATEGY = _enum.auto()
TERMINAL = _enum.auto()
TEST = _enum.auto()
VARS = _enum.auto()
@_dataclasses.dataclass(**_dataclass_kwargs)
class PluginInfo(_datatag.AnsibleSerializableDataclass):
"""Information about a loaded plugin."""
resolved_name: str
resolved_name: _t.Optional[str]
"""The resolved canonical plugin name; always fully-qualified for collection plugins."""
type: str
type: _t.Optional[PluginType]
"""The plugin type."""

@ -21,5 +21,18 @@ def get_plugin_info(value: HasPluginInfo) -> _messages.PluginInfo:
"""Utility method that returns a `PluginInfo` from an object implementing the `HasPluginInfo` protocol."""
return _messages.PluginInfo(
resolved_name=value.ansible_name,
type=value.plugin_type,
type=normalize_plugin_type(value.plugin_type),
)
def normalize_plugin_type(value: str) -> _messages.PluginType | None:
"""Normalize value and return it as a PluginType, or None if the value does match any known plugin type."""
value = value.lower()
if value == 'modules':
value = 'module'
try:
return _messages.PluginType(value)
except ValueError:
return None

@ -603,20 +603,18 @@ class Display(metaclass=Singleton):
else:
removal_fragment = 'This feature will be removed'
if not deprecator or deprecator.type == _deprecator.INDETERMINATE_DEPRECATOR.type:
collection = None
plugin_fragment = ''
elif deprecator.type == _deprecator._COLLECTION_ONLY_TYPE:
collection = deprecator.resolved_name
if not deprecator or not deprecator.type:
# indeterminate has no resolved_name or type
# collections have a resolved_name but no type
collection = deprecator.resolved_name if deprecator else None
plugin_fragment = ''
else:
parts = deprecator.resolved_name.split('.')
plugin_name = parts[-1]
# 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'
plugin_type_name = str(deprecator.type) if deprecator.type is _messages.PluginType.MODULE else f'{deprecator.type} plugin'
collection = '.'.join(parts[:2]) if len(parts) > 2 else None
plugin_fragment = f'{plugin_type} {plugin_name!r}'
plugin_fragment = f'{plugin_type_name} {plugin_name!r}'
if collection and plugin_fragment:
plugin_fragment += ' in'

@ -7,6 +7,6 @@ from ansible.plugins.lookup import LookupBase
class LookupModule(LookupBase):
def run(self, terms, variables=None, **kwargs):
return [_messages.PluginInfo(
resolved_name='resolved_name',
type='type',
resolved_name='ns.col.module',
type=_messages.PluginType.MODULE,
)]

@ -43,8 +43,8 @@
vars:
some_var: Hello
expected_plugin_info:
resolved_name: resolved_name
type: type
resolved_name: ns.col.module
type: module
- name: test the python_literal_eval filter
assert:

@ -48,6 +48,7 @@ MODULE_UTILS_BASIC_FILES = frozenset(('ansible/__init__.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/_plugin_info.py',
'ansible/module_utils/_internal/_stack.py',
'ansible/module_utils/_internal/_text_utils.py',
'ansible/module_utils/common/collections.py',

@ -33,26 +33,25 @@ def do_stuff():
return super().exec_module(module)
@pytest.mark.parametrize("python_fq_name,expected_resolved_name,expected_plugin_type", (
@pytest.mark.parametrize("python_fq_name,expected_plugin_info", (
# legacy module callers
('ansible.legacy.blah', 'ansible.legacy.blah', 'module'),
('ansible.legacy.blah', _messages.PluginInfo(resolved_name='ansible.legacy.blah', type=_messages.PluginType.MODULE)),
# core callers
('ansible.modules.ping', 'ansible.builtin.ping', 'module'),
('ansible.plugins.filters.core', _deprecator.ANSIBLE_CORE_DEPRECATOR.resolved_name, _deprecator.ANSIBLE_CORE_DEPRECATOR.type),
('ansible.plugins.tests.core', _deprecator.ANSIBLE_CORE_DEPRECATOR.resolved_name, _deprecator.ANSIBLE_CORE_DEPRECATOR.type),
('ansible.nonplugin_something', _deprecator.ANSIBLE_CORE_DEPRECATOR.resolved_name, _deprecator.ANSIBLE_CORE_DEPRECATOR.type),
('ansible.modules.ping', _messages.PluginInfo(resolved_name='ansible.builtin.ping', type=_messages.PluginType.MODULE)),
('ansible.plugins.filter.core', _deprecator.ANSIBLE_CORE_DEPRECATOR),
('ansible.plugins.test.core', _deprecator.ANSIBLE_CORE_DEPRECATOR),
('ansible.nonplugin_something', _deprecator.ANSIBLE_CORE_DEPRECATOR),
# 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', _deprecator._COLLECTION_ONLY_TYPE),
('ansible_collections.foo.bar.plugins.test.sometest', 'foo.bar', _deprecator._COLLECTION_ONLY_TYPE),
('ansible_collections.foo.bar.plugins.modules.module_thing', _messages.PluginInfo(resolved_name='foo.bar.module_thing', type=_messages.PluginType.MODULE)),
('ansible_collections.foo.bar.plugins.filter.somefilter', _messages.PluginInfo(resolved_name='foo.bar', type=None)),
('ansible_collections.foo.bar.plugins.test.sometest', _messages.PluginInfo(resolved_name='foo.bar', type=None)),
# 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),
('ansible_collections.foo.bar.plugins.module_utils.something', _deprecator.INDETERMINATE_DEPRECATOR),
# other callers
('something.else', None, None),
('ansible_collections.foo.bar.nonplugin_something', None, None),
('something.else', None),
('ansible_collections.foo.bar.nonplugin_something', None),
))
def test_get_caller_plugin_info(python_fq_name: str, expected_resolved_name: str, expected_plugin_type: str):
def test_get_caller_plugin_info(python_fq_name: str, expected_plugin_info: _messages.PluginInfo):
"""Validates the expected `PluginInfo` values received from various types of core/non-core/collection callers."""
# invoke a standalone fake loader that generates a Python module with the specified FQ python name (converted to a corresponding __file__ entry) that
# pretends as if it called `get_caller_plugin_info()` and returns its result
@ -64,10 +63,4 @@ def test_get_caller_plugin_info(python_fq_name: str, expected_resolved_name: str
pi: _messages.PluginInfo = mod.do_stuff()
if not expected_resolved_name and not expected_plugin_type:
assert pi is None
return
assert pi is not None
assert pi.resolved_name == expected_resolved_name
assert pi.type == expected_plugin_type
assert pi == expected_plugin_info

@ -5,6 +5,7 @@ import collections.abc as c
import copy
import dataclasses
import datetime
import enum
import inspect
import json
@ -26,6 +27,7 @@ from ansible.module_utils._internal import _messages
from ansible.module_utils._internal._datatag import (
AnsibleSerializable,
AnsibleSerializableEnum,
AnsibleSingletonTagBase,
AnsibleTaggedObject,
NotTaggableError,
@ -83,7 +85,8 @@ message_instances = [
_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'),
_messages.PluginInfo(resolved_name='a.b.c', type=_messages.PluginType.MODULE),
_messages.PluginType.MODULE,
]
@ -97,7 +100,7 @@ def assert_round_trip(original_value, round_tripped_value, via_copy=False):
return
# singleton values should rehydrate as the shared singleton instance, all others should be a new instance
if isinstance(original_value, AnsibleSingletonTagBase):
if isinstance(original_value, (AnsibleSingletonTagBase, enum.Enum)):
assert original_value is round_tripped_value
else:
assert original_value is not round_tripped_value
@ -483,6 +486,8 @@ class TestDatatagTarget(AutoParamSupport):
excluded_type_names = {
AnsibleTaggedObject.__name__, # base class, cannot be abstract
AnsibleSerializableDataclass.__name__, # base class, cannot be abstract
AnsibleSerializable.__name__, # base class, cannot be abstract
AnsibleSerializableEnum.__name__, # base class, cannot be abstract
# these types are all controller-only, so it's easier to have static type names instead of importing them
'JinjaConstTemplate', # serialization not required
'_EncryptedSource', # serialization not required
@ -643,7 +648,7 @@ class TestDatatagTarget(AutoParamSupport):
"""Assert that __slots__ are properly defined on the given serializable type."""
if value in (AnsibleSerializable, AnsibleTaggedObject):
expect_slots = True # non-dataclass base types have no attributes, but still use slots
elif issubclass(value, (int, bytes, tuple)):
elif issubclass(value, (int, bytes, tuple, enum.Enum)):
# non-empty slots are not supported by these variable-length data types
# see: https://docs.python.org/3/reference/datamodel.html
expect_slots = False

@ -837,6 +837,7 @@ class TestVaultLib(unittest.TestCase):
def test_verify_encrypted_string_methods():
"""Verify all relevant methods on `str` are implemented on `EncryptedString`."""
str_methods_to_not_implement = {
'__class__',
'__dir__',
'__getattribute__', # generated by Python, identical to the one on `str` in Python 3.13+, but different on earlier versions
'__getnewargs__',

@ -182,14 +182,14 @@ Origin: /some/path
A_DATE = datetime.date(2025, 1, 1)
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')
CORE_MODULE = _messages.PluginInfo(resolved_name='ansible.builtin.ping', type=_messages.PluginType.MODULE)
CORE_PLUGIN = _messages.PluginInfo(resolved_name='ansible.builtin.debug', type=_messages.PluginType.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')
COLL_MODULE = _messages.PluginInfo(resolved_name='ns.col.ping', type=_messages.PluginType.MODULE)
COLL_PLUGIN = _messages.PluginInfo(resolved_name='ns.col.debug', type=_messages.PluginType.ACTION)
INDETERMINATE = _deprecator.INDETERMINATE_DEPRECATOR
LEGACY_MODULE = _messages.PluginInfo(resolved_name='ping', type='module')
LEGACY_PLUGIN = _messages.PluginInfo(resolved_name='debug', type='action')
LEGACY_MODULE = _messages.PluginInfo(resolved_name='ping', type=_messages.PluginType.MODULE)
LEGACY_PLUGIN = _messages.PluginInfo(resolved_name='debug', type=_messages.PluginType.ACTION)
@pytest.mark.parametrize('kwargs, expected', (

Loading…
Cancel
Save