mirror of https://github.com/ansible/ansible.git
Switch to stackwalk caller ID (#85095)
* See changelog fragment for most changes. * Defer early config warnings until display is functioning, eliminating related fallback display logic. * Added more type annotations and docstrings. * ansible-test - pylint sanity for deprecations improved. * Refactored inline legacy resolutions in PluginLoader. Co-authored-by: Matt Clay <matt@mystile.com>pull/85096/head
parent
e4cac2ac33
commit
ff6998f2b9
@ -0,0 +1,17 @@
|
||||
minor_changes:
|
||||
- modules - The ``AnsibleModule.deprecate`` function no longer sends deprecation messages to the target host's logging system.
|
||||
- ansible-test - Improved ``pylint`` checks for Ansible-specific deprecation functions.
|
||||
- deprecations - Removed support for specifying deprecation dates as a ``datetime.date``, which was included in an earlier 2.19 pre-release.
|
||||
- deprecations - Some argument names to ``deprecate_value`` for consistency with existing APIs.
|
||||
An earlier 2.19 pre-release included a ``removal_`` prefix on the ``date`` and ``version`` arguments.
|
||||
- deprecations - Collection name strings not of the form ``ns.coll`` passed to deprecation API functions will result in an error.
|
||||
- collection metadata - The collection loader now parses scalar values from ``meta/runtime.yml`` as strings.
|
||||
This avoids issues caused by unquoted values such as versions or dates being parsed as types other than strings.
|
||||
- deprecation warnings - Deprecation warning APIs automatically capture the identity of the deprecating plugin.
|
||||
The ``collection_name`` argument is only required to correctly attribute deprecations that occur in module_utils or other non-plugin code.
|
||||
- deprecation warnings - Improved deprecation messages to more clearly indicate the affected content, including plugin name when available.
|
||||
|
||||
deprecated_features:
|
||||
- plugins - Accessing plugins with ``_``-prefixed filenames without the ``_`` prefix is deprecated.
|
||||
- Passing a ``warnings` or ``deprecations`` key to ``exit_json`` or ``fail_json`` is deprecated.
|
||||
Use ``AnsibleModule.warn`` or ``AnsibleModule.deprecate`` instead.
|
||||
@ -1,64 +0,0 @@
|
||||
"""Patch broken ClassVar support in dataclasses when ClassVar is accessed via a module other than `typing`."""
|
||||
|
||||
# deprecated: description='verify ClassVar support in dataclasses has been fixed in Python before removing this patching code', python_version='3.12'
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import sys
|
||||
import typing as t
|
||||
|
||||
# trigger the bug by exposing typing.ClassVar via a module reference that is not `typing`
|
||||
_ts = sys.modules[__name__]
|
||||
ClassVar = t.ClassVar
|
||||
|
||||
|
||||
def patch_dataclasses_is_type() -> None:
|
||||
if not _is_patch_needed():
|
||||
return # pragma: nocover
|
||||
|
||||
try:
|
||||
real_is_type = dataclasses._is_type # type: ignore[attr-defined]
|
||||
except AttributeError: # pragma: nocover
|
||||
raise RuntimeError("unable to patch broken dataclasses ClassVar support") from None
|
||||
|
||||
# patch dataclasses._is_type - impl from https://github.com/python/cpython/blob/4c6d4f5cb33e48519922d635894eef356faddba2/Lib/dataclasses.py#L709-L765
|
||||
def _is_type(annotation, cls, a_module, a_type, is_type_predicate):
|
||||
match = dataclasses._MODULE_IDENTIFIER_RE.match(annotation) # type: ignore[attr-defined]
|
||||
if match:
|
||||
ns = None
|
||||
module_name = match.group(1)
|
||||
if not module_name:
|
||||
# No module name, assume the class's module did
|
||||
# "from dataclasses import InitVar".
|
||||
ns = sys.modules.get(cls.__module__).__dict__
|
||||
else:
|
||||
# Look up module_name in the class's module.
|
||||
module = sys.modules.get(cls.__module__)
|
||||
if module and module.__dict__.get(module_name): # this is the patched line; removed `is a_module`
|
||||
ns = sys.modules.get(a_type.__module__).__dict__
|
||||
if ns and is_type_predicate(ns.get(match.group(2)), a_module):
|
||||
return True
|
||||
return False
|
||||
|
||||
_is_type._orig_impl = real_is_type # type: ignore[attr-defined] # stash this away to allow unit tests to undo the patch
|
||||
|
||||
dataclasses._is_type = _is_type # type: ignore[attr-defined]
|
||||
|
||||
try:
|
||||
if _is_patch_needed():
|
||||
raise RuntimeError("patching had no effect") # pragma: nocover
|
||||
except Exception as ex: # pragma: nocover
|
||||
dataclasses._is_type = real_is_type # type: ignore[attr-defined]
|
||||
raise RuntimeError("dataclasses ClassVar support is still broken after patching") from ex
|
||||
|
||||
|
||||
def _is_patch_needed() -> bool:
|
||||
@dataclasses.dataclass
|
||||
class CheckClassVar:
|
||||
# this is the broken case requiring patching: ClassVar dot-referenced from a module that is not `typing` is treated as an instance field
|
||||
# DTFIX-RELEASE: add link to CPython bug report to-be-filed (or update associated deprecation comments if we don't)
|
||||
a_classvar: _ts.ClassVar[int] # type: ignore[name-defined]
|
||||
a_field: int
|
||||
|
||||
return len(dataclasses.fields(CheckClassVar)) != 1
|
||||
@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import pathlib
|
||||
import sys
|
||||
import typing as t
|
||||
|
||||
from ansible.module_utils.common.messages import PluginInfo
|
||||
|
||||
_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."""
|
||||
|
||||
INDETERMINATE_DEPRECATOR: t.Final = PluginInfo(resolved_name='indeterminate', type='indeterminate')
|
||||
"""Singleton `PluginInfo` instance for indeterminate deprecator."""
|
||||
|
||||
_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."""
|
||||
|
||||
_AMBIGUOUS_DEPRECATOR_PLUGIN_TYPES = frozenset(
|
||||
{
|
||||
'filter',
|
||||
'test',
|
||||
}
|
||||
)
|
||||
"""Plugin types for which basename cannot be used to identify the plugin name."""
|
||||
|
||||
|
||||
def get_best_deprecator(*, deprecator: PluginInfo | None = None, collection_name: str | None = None) -> PluginInfo:
|
||||
"""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
|
||||
|
||||
|
||||
def get_caller_plugin_info() -> 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):
|
||||
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:
|
||||
"""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))
|
||||
except ValueError:
|
||||
return None # not ansible-core
|
||||
|
||||
namespace = 'ansible.builtin'
|
||||
|
||||
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")
|
||||
|
||||
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.
|
||||
# Callers in this case need to identify the deprecating plugin name, otherwise only ansible-core will be reported.
|
||||
# Reporting ansible-core is never wrong, it just may be missing an additional detail (plugin name) in the "on behalf of" case.
|
||||
return ANSIBLE_CORE_DEPRECATOR
|
||||
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"
|
||||
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"
|
||||
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
|
||||
|
||||
name = f'{namespace}.{plugin_name}'
|
||||
|
||||
return PluginInfo(resolved_name=name, type=plugin_type)
|
||||
|
||||
|
||||
def _path_as_collection_plugininfo(path: str) -> PluginInfo | None:
|
||||
"""Return a `PluginInfo` instance if the provided `path` refers to a collection plugin."""
|
||||
if not (match := re.search(r'/ansible_collections/(?P<ns>\w+)/(?P<coll>\w+)/plugins/(?P<plugin_type>\w+)/(?P<plugin_name>\w+)', path)):
|
||||
return None
|
||||
|
||||
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.
|
||||
# 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'))))
|
||||
|
||||
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.
|
||||
# Callers in this case need to identify the deprecator to avoid ambiguity, since it could be the same collection or another collection.
|
||||
return INDETERMINATE_DEPRECATOR
|
||||
|
||||
name = '.'.join((match.group('ns'), match.group('coll'), match.group('plugin_name')))
|
||||
|
||||
return PluginInfo(resolved_name=name, type=plugin_type)
|
||||
@ -1,49 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from ._ambient_context import AmbientContextBase
|
||||
from ..common.messages import PluginInfo
|
||||
|
||||
|
||||
class HasPluginInfo(t.Protocol):
|
||||
"""Protocol to type-annotate and expose PluginLoader-set values."""
|
||||
|
||||
@property
|
||||
def _load_name(self) -> str:
|
||||
"""The requested name used to load the plugin."""
|
||||
|
||||
@property
|
||||
def ansible_name(self) -> str:
|
||||
"""Fully resolved plugin name."""
|
||||
|
||||
@property
|
||||
def plugin_type(self) -> str:
|
||||
"""Plugin type name."""
|
||||
|
||||
|
||||
class PluginExecContext(AmbientContextBase):
|
||||
"""Execution context that wraps all plugin invocations to allow infrastructure introspection of the currently-executing plugin instance."""
|
||||
|
||||
def __init__(self, executing_plugin: HasPluginInfo) -> None:
|
||||
self._executing_plugin = executing_plugin
|
||||
|
||||
@property
|
||||
def executing_plugin(self) -> HasPluginInfo:
|
||||
return self._executing_plugin
|
||||
|
||||
@property
|
||||
def plugin_info(self) -> PluginInfo:
|
||||
return PluginInfo(
|
||||
requested_name=self._executing_plugin._load_name,
|
||||
resolved_name=self._executing_plugin.ansible_name,
|
||||
type=self._executing_plugin.plugin_type,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_current_plugin_info(cls) -> PluginInfo | None:
|
||||
"""Utility method to extract a PluginInfo for the currently executing plugin (or None if no plugin is executing)."""
|
||||
if ctx := cls.current(optional=True):
|
||||
return ctx.plugin_info
|
||||
|
||||
return None
|
||||
@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from ..common import messages as _messages
|
||||
|
||||
|
||||
class HasPluginInfo(t.Protocol):
|
||||
"""Protocol to type-annotate and expose PluginLoader-set values."""
|
||||
|
||||
@property
|
||||
def ansible_name(self) -> str | None:
|
||||
"""Fully resolved plugin name."""
|
||||
|
||||
@property
|
||||
def plugin_type(self) -> str:
|
||||
"""Plugin type name."""
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import keyword
|
||||
|
||||
|
||||
def validate_collection_name(collection_name: object, name: str = 'collection_name') -> None:
|
||||
"""Validate a collection name."""
|
||||
if not isinstance(collection_name, str):
|
||||
raise TypeError(f"{name} must be {str} instead of {type(collection_name)}")
|
||||
|
||||
parts = collection_name.split('.')
|
||||
|
||||
if len(parts) != 2 or not all(part.isidentifier() and not keyword.iskeyword(part) for part in parts):
|
||||
raise ValueError(f"{name} must consist of two non-keyword identifiers separated by '.'")
|
||||
@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
#
|
||||
#
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
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')
|
||||
|
||||
# ansible-deprecated-version - only ansible-core can encounter this
|
||||
_display.deprecated(msg='ansible-deprecated-no-version')
|
||||
# ansible-invalid-deprecated-version - only ansible-core can encounter this
|
||||
_display.deprecated(msg='collection-deprecated-version', version='1.0.0')
|
||||
_display.deprecated(msg='collection-invalid-deprecated-version', version='not-a-version')
|
||||
# ansible-deprecated-no-collection-name - only a module_utils can encounter this
|
||||
_display.deprecated(msg='wrong-collection-deprecated', collection_name='ns.wrong', version='3.0.0')
|
||||
_display.deprecated(msg='ansible-expired-deprecated-date', date='2000-01-01')
|
||||
_display.deprecated(msg='ansible-invalid-deprecated-date', date='not-a-date')
|
||||
_display.deprecated(msg='ansible-deprecated-both-version-and-date', version='3.0.0', date='2099-01-01')
|
||||
_display.deprecated(msg='removal-version-must-be-major', version='3.1.0')
|
||||
# ansible-deprecated-date-not-permitted - only ansible-core can encounter this
|
||||
_display.deprecated(msg='ansible-deprecated-unnecessary-collection-name', deprecator=deprecator, version='3.0.0')
|
||||
# ansible-deprecated-collection-name-not-permitted - only ansible-core can encounter this
|
||||
_display.deprecated(msg='ansible-deprecated-both-collection-name-and-deprecator', collection_name='ns.col', deprecator=deprecator, version='3.0.0')
|
||||
|
||||
return result
|
||||
@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ansible.module_utils.common.messages import PluginInfo
|
||||
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
|
||||
#
|
||||
#
|
||||
#
|
||||
#
|
||||
#
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
def do_stuff() -> None:
|
||||
deprecator = PluginInfo._from_collection_name('ns.col')
|
||||
|
||||
# ansible-deprecated-version - only ansible-core can encounter this
|
||||
deprecate(msg='ansible-deprecated-no-version', collection_name='ns.col')
|
||||
# ansible-invalid-deprecated-version - only ansible-core can encounter this
|
||||
deprecate(msg='collection-deprecated-version', collection_name='ns.col', version='1.0.0')
|
||||
deprecate(msg='collection-invalid-deprecated-version', collection_name='ns.col', version='not-a-version')
|
||||
# ansible-deprecated-no-collection-name - module_utils cannot encounter this
|
||||
deprecate(msg='wrong-collection-deprecated', collection_name='ns.wrong', version='3.0.0')
|
||||
deprecate(msg='ansible-expired-deprecated-date', collection_name='ns.col', date='2000-01-01')
|
||||
deprecate(msg='ansible-invalid-deprecated-date', collection_name='ns.col', date='not-a-date')
|
||||
deprecate(msg='ansible-deprecated-both-version-and-date', collection_name='ns.col', version='3.0.0', date='2099-01-01')
|
||||
deprecate(msg='removal-version-must-be-major', collection_name='ns.col', version='3.1.0')
|
||||
# ansible-deprecated-date-not-permitted - only ansible-core can encounter this
|
||||
# ansible-deprecated-unnecessary-collection-name - module_utils cannot encounter this
|
||||
# ansible-deprecated-collection-name-not-permitted - only ansible-core can encounter this
|
||||
deprecate(msg='ansible-deprecated-both-collection-name-and-deprecator', collection_name='ns.col', deprecator=deprecator, version='3.0.0')
|
||||
@ -0,0 +1,28 @@
|
||||
"""
|
||||
This file is not used by the integration test, but serves a related purpose.
|
||||
It triggers sanity test failures that can only occur for ansible-core, which need to be ignored to ensure the pylint plugin is functioning properly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ansible.module_utils.common.messages import PluginInfo
|
||||
from ansible.module_utils.common.warnings import deprecate
|
||||
|
||||
|
||||
def do_stuff() -> None:
|
||||
deprecator = PluginInfo._from_collection_name('ansible.builtin')
|
||||
|
||||
deprecate(msg='ansible-deprecated-version', version='2.18')
|
||||
deprecate(msg='ansible-deprecated-no-version')
|
||||
deprecate(msg='ansible-invalid-deprecated-version', version='not-a-version')
|
||||
# collection-deprecated-version - ansible-core cannot encounter this
|
||||
# collection-invalid-deprecated-version - ansible-core cannot encounter this
|
||||
# ansible-deprecated-no-collection-name - ansible-core cannot encounter this
|
||||
# wrong-collection-deprecated - ansible-core cannot encounter this
|
||||
# ansible-expired-deprecated-date - ansible-core cannot encounter this
|
||||
# ansible-invalid-deprecated-date - ansible-core cannot encounter this
|
||||
# ansible-deprecated-both-version-and-date - ansible-core cannot encounter this
|
||||
# removal-version-must-be-major - ansible-core cannot encounter this
|
||||
deprecate(msg='ansible-deprecated-date-not-permitted', date='2099-01-01')
|
||||
deprecate(msg='ansible-deprecated-unnecessary-collection-name', deprecator=deprecator, version='2.99')
|
||||
deprecate(msg='ansible-deprecated-collection-name-not-permitted', collection_name='ansible.builtin', version='2.99')
|
||||
# ansible-deprecated-both-collection-name-and-deprecator - ansible-core cannot encounter this
|
||||
@ -1 +1,36 @@
|
||||
plugins/lookup/deprecated.py:26:0: collection-deprecated-version: Deprecated version ('2.0.0') found in call to Display.deprecated or AnsibleModule.deprecate
|
||||
plugins/action/do_deprecated_stuff.py:21:8: ansible-deprecated-no-version: Found 'ansible.utils.display.Display.deprecated' call without a version or date
|
||||
plugins/action/do_deprecated_stuff.py:23:8: collection-deprecated-version: Deprecated version '1.0.0' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/action/do_deprecated_stuff.py:24:8: collection-invalid-deprecated-version: Invalid deprecated version 'not-a-version' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/action/do_deprecated_stuff.py:26:8: wrong-collection-deprecated: Wrong collection_name 'ns.wrong' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/action/do_deprecated_stuff.py:27:8: ansible-expired-deprecated-date: Expired date '2000-01-01' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/action/do_deprecated_stuff.py:28:8: ansible-invalid-deprecated-date: Invalid date 'not-a-date' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/action/do_deprecated_stuff.py:29:8: ansible-deprecated-both-version-and-date: Both version and date found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/action/do_deprecated_stuff.py:30:8: removal-version-must-be-major: Removal version '3.1.0' must be a major release, not a minor or patch release, see https://semver.org/
|
||||
plugins/action/do_deprecated_stuff.py:32:8: ansible-deprecated-unnecessary-collection-name: Unnecessary 'deprecator' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/action/do_deprecated_stuff.py:34:8: ansible-deprecated-both-collection-name-and-deprecator: Both collection_name and deprecator found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/action/do_deprecated_stuff.py:34:8: ansible-deprecated-unnecessary-collection-name: Unnecessary 'deprecator' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/lookup/deprecated.py:58:8: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.module_utils.basic.AnsibleModule.deprecate'
|
||||
plugins/lookup/deprecated.py:70:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/lookup/deprecated.py:71:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.module_utils.common.warnings.deprecate'
|
||||
plugins/lookup/deprecated.py:72:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/lookup/deprecated.py:73:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/lookup/deprecated.py:74:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/lookup/deprecated.py:75:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/lookup/deprecated.py:76:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/lookup/deprecated.py:77:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.module_utils.common.warnings.deprecate'
|
||||
plugins/lookup/deprecated.py:78:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.module_utils.common.warnings.deprecate'
|
||||
plugins/lookup/deprecated.py:79:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.module_utils.datatag.deprecate_value'
|
||||
plugins/lookup/deprecated.py:80:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.module_utils.datatag.deprecate_value'
|
||||
plugins/lookup/deprecated.py:81:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/lookup/deprecated.py:82:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/lookup/deprecated.py:83:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/lookup/deprecated.py:84:4: collection-deprecated-version: Deprecated version '2.0.0' found in call to 'ansible.utils.display.Display.deprecated'
|
||||
plugins/module_utils/deprecated_utils.py:21:4: ansible-deprecated-no-version: Found 'ansible.module_utils.common.warnings.deprecate' call without a version or date
|
||||
plugins/module_utils/deprecated_utils.py:23:4: collection-deprecated-version: Deprecated version '1.0.0' found in call to 'ansible.module_utils.common.warnings.deprecate'
|
||||
plugins/module_utils/deprecated_utils.py:24:4: collection-invalid-deprecated-version: Invalid deprecated version 'not-a-version' found in call to 'ansible.module_utils.common.warnings.deprecate'
|
||||
plugins/module_utils/deprecated_utils.py:26:4: wrong-collection-deprecated: Wrong collection_name 'ns.wrong' found in call to 'ansible.module_utils.common.warnings.deprecate'
|
||||
plugins/module_utils/deprecated_utils.py:27:4: ansible-expired-deprecated-date: Expired date '2000-01-01' found in call to 'ansible.module_utils.common.warnings.deprecate'
|
||||
plugins/module_utils/deprecated_utils.py:28:4: ansible-invalid-deprecated-date: Invalid date 'not-a-date' found in call to 'ansible.module_utils.common.warnings.deprecate'
|
||||
plugins/module_utils/deprecated_utils.py:29:4: ansible-deprecated-both-version-and-date: Both version and date found in call to 'ansible.module_utils.common.warnings.deprecate'
|
||||
plugins/module_utils/deprecated_utils.py:30:4: removal-version-must-be-major: Removal version '3.1.0' must be a major release, not a minor or patch release, see https://semver.org/
|
||||
plugins/module_utils/deprecated_utils.py:34:4: ansible-deprecated-both-collection-name-and-deprecator: Both collection_name and deprecator found in call to 'ansible.module_utils.common.warnings.deprecate'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
[WARNING]: Deprecation warnings can be disabled by setting `deprecation_warnings=False` in ansible.cfg.
|
||||
[DEPRECATION WARNING]: `something_old` is deprecated, don't use it! This feature will be removed in version 1.2.3.
|
||||
[DEPRECATION WARNING]: `something_old` is deprecated, don't use it! This feature will be removed in version 1.2.3.
|
||||
[DEPRECATION WARNING]: `something_old` is deprecated, don't use it! This feature will be removed from module 'tagging_sample' in collection 'ansible.legacy' version 9.9999.
|
||||
[DEPRECATION WARNING]: `something_old` is deprecated, don't use it! This feature will be removed from module 'tagging_sample' in collection 'ansible.legacy' version 9.9999.
|
||||
[WARNING]: Encountered untrusted template or expression.
|
||||
[DEPRECATION WARNING]: `something_old` is deprecated, don't use it! This feature will be removed in version 1.2.3.
|
||||
[DEPRECATION WARNING]: `something_old` is deprecated, don't use it! This feature will be removed from module 'tagging_sample' in collection 'ansible.legacy' version 9.9999.
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils._internal._datatag._tags import Deprecated
|
||||
|
||||
|
||||
def main():
|
||||
mod = AnsibleModule(argument_spec={
|
||||
'sensitive_module_arg': dict(default='NO DISPLAY, sensitive arg to module', type='str', no_log=True),
|
||||
})
|
||||
|
||||
result = {
|
||||
'good_key': 'good value',
|
||||
'deprecated_key': Deprecated(msg="`deprecated_key` is deprecated, don't use it!", removal_version='1.2.3').tag('deprecated value'),
|
||||
'sensitive_module_arg': mod.params['sensitive_module_arg'],
|
||||
'unmarked_template': '{{ ["me", "see", "not", "should"] | sort(reverse=true) | join(" ") }}',
|
||||
'changed': False
|
||||
}
|
||||
|
||||
mod.exit_json(**result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -1,14 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ansible.module_utils.datatag import deprecate_value
|
||||
from ansible.plugins.action import ActionBase
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
def run(self, tmp=None, task_vars=None):
|
||||
result = super().run(tmp, task_vars)
|
||||
result.update(deprecated_thing=deprecate_value("deprecated thing", msg="Deprecated thing is deprecated.", removal_version='999.999'))
|
||||
|
||||
self._display.deprecated("did a deprecated thing", version="999.999")
|
||||
|
||||
return result
|
||||
@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ansible.plugins.action import ActionBase
|
||||
from ansible.module_utils.common.warnings import deprecate
|
||||
|
||||
from ..module_utils.shared_deprecation import get_deprecation_kwargs, get_deprecated_value
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
def run(self, tmp=None, task_vars=None):
|
||||
result = super().run(tmp, task_vars)
|
||||
|
||||
for deprecate_kw in get_deprecation_kwargs():
|
||||
deprecate(**deprecate_kw) # pylint: disable=ansible-deprecated-no-version
|
||||
|
||||
result.update(deprecated_result=get_deprecated_value())
|
||||
|
||||
return result
|
||||
@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ansible.module_utils.common.messages import PluginInfo
|
||||
from ansible.module_utils.datatag import deprecate_value
|
||||
|
||||
|
||||
def get_deprecation_kwargs() -> list[dict[str, object]]:
|
||||
return [
|
||||
dict(msg="Deprecation that passes collection_name, version, and help_text.", version='9999.9', collection_name='bla.bla', help_text="Help text."),
|
||||
dict(
|
||||
msg="Deprecation that passes deprecator and datetime.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=PluginInfo._from_collection_name('bla.bla')),
|
||||
dict(msg="Deprecation that passes no deprecator, collection name, or date/version."),
|
||||
]
|
||||
|
||||
|
||||
def get_deprecated_value() -> str:
|
||||
return deprecate_value( # pylint: disable=ansible-deprecated-unnecessary-collection-name,ansible-deprecated-collection-name-not-permitted
|
||||
value='a deprecated value',
|
||||
msg="value is deprecated",
|
||||
collection_name='foo.bar',
|
||||
version='9999.9',
|
||||
)
|
||||
@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ..module_utils.shared_deprecation import get_deprecation_kwargs, get_deprecated_value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
m = AnsibleModule({})
|
||||
m.warn("This is a warning.")
|
||||
|
||||
for deprecate_kw in get_deprecation_kwargs():
|
||||
m.deprecate(**deprecate_kw) # pylint: disable=ansible-deprecated-no-version
|
||||
|
||||
m.exit_json(deprecated_result=get_deprecated_value())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -1,19 +1,42 @@
|
||||
- hosts: testhost
|
||||
gather_facts: no
|
||||
tasks:
|
||||
- name: invoke an action that fires a deprecation and returns a deprecated value
|
||||
action_with_dep:
|
||||
register: action_result
|
||||
- name: invoke a module that fires deprecations and returns a deprecated value
|
||||
foo.bar.noisy:
|
||||
register: noisy_module_result
|
||||
|
||||
- name: invoke an action that fires deprecations and returns a deprecated value
|
||||
foo.bar.noisy_action:
|
||||
register: noisy_action_result
|
||||
|
||||
- name: validate deprecation warnings fired by action/module
|
||||
assert:
|
||||
that:
|
||||
- item.deprecations | length == 4
|
||||
- item.deprecations[0].msg is contains "passes collection_name, version, and help_text"
|
||||
- item.deprecations[0].collection_name == 'bla.bla'
|
||||
- item.deprecations[0].version == '9999.9'
|
||||
- item.deprecations[1].msg is contains "passes deprecator and date"
|
||||
- item.deprecations[1].collection_name == 'bla.bla'
|
||||
- item.deprecations[1].date == '2034-01-02'
|
||||
- item.deprecations[2].msg is contains "passes deprecator and string date"
|
||||
- item.deprecations[2].collection_name == 'bla.bla'
|
||||
- item.deprecations[2].date == '2034-01-02'
|
||||
- item.deprecations[3].msg is contains "passes no deprecator, collection name, or date/version"
|
||||
- item.deprecations[3].collection_name == 'foo.bar'
|
||||
- item.deprecations[3].date is not defined
|
||||
loop: '{{ [noisy_module_result, noisy_action_result] }}'
|
||||
|
||||
- name: touch the deprecated value
|
||||
debug:
|
||||
var: action_result.deprecated_thing
|
||||
var: noisy_module_result.deprecated_result
|
||||
register: debug_result
|
||||
|
||||
- name: validate the presence of the deprecation warnings
|
||||
- name: validate deprecation warnings from tagged result
|
||||
assert:
|
||||
that:
|
||||
- action_result.deprecations | length == 1
|
||||
- action_result.deprecations[0].msg is contains "did a deprecated thing"
|
||||
- debug_result.deprecations | length == 1
|
||||
- debug_result.deprecations[0].msg is contains "Deprecated thing is deprecated."
|
||||
- debug_result.deprecations[0].msg is contains "value is deprecated"
|
||||
- debug_result.deprecations[0].date is not defined
|
||||
- debug_result.deprecations[0].version is defined
|
||||
- debug_result.deprecations[0].collection_name == 'foo.bar'
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
- hosts: testhost
|
||||
gather_facts: no
|
||||
tasks:
|
||||
- name: invoke a module that returns a warning and deprecation warning
|
||||
noisy:
|
||||
register: result
|
||||
|
||||
- name: verify the warning and deprecation are visible in templating
|
||||
assert:
|
||||
that:
|
||||
- result.warnings | length == 1
|
||||
- result.warnings[0] == "This is a warning."
|
||||
- result.deprecations | length == 1
|
||||
- result.deprecations[0].msg == "This is a deprecation."
|
||||
@ -1,14 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
|
||||
def main() -> None:
|
||||
m = AnsibleModule({})
|
||||
m.warn("This is a warning.")
|
||||
m.deprecate("This is a deprecation.", version='9999.9')
|
||||
m.exit_json()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -1,399 +0,0 @@
|
||||
"""Ansible specific plyint plugin for checking deprecations."""
|
||||
|
||||
# (c) 2018, Matt Martz <matt@sivel.net>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
import shlex
|
||||
import typing as t
|
||||
from tokenize import COMMENT, TokenInfo
|
||||
|
||||
import astroid
|
||||
|
||||
try:
|
||||
from pylint.checkers.utils import check_messages
|
||||
except ImportError:
|
||||
from pylint.checkers.utils import only_required_for_messages as check_messages
|
||||
|
||||
from pylint.checkers import BaseChecker, BaseTokenChecker
|
||||
|
||||
from ansible.module_utils.compat.version import LooseVersion
|
||||
from ansible.release import __version__ as ansible_version_raw
|
||||
from ansible.utils.version import SemanticVersion
|
||||
|
||||
MSGS = {
|
||||
'E9501': ("Deprecated version (%r) found in call to Display.deprecated "
|
||||
"or AnsibleModule.deprecate",
|
||||
"ansible-deprecated-version",
|
||||
"Used when a call to Display.deprecated specifies a version "
|
||||
"less than or equal to the current version of Ansible",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9502': ("Display.deprecated call without a version or date",
|
||||
"ansible-deprecated-no-version",
|
||||
"Used when a call to Display.deprecated does not specify a "
|
||||
"version or date",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9503': ("Invalid deprecated version (%r) found in call to "
|
||||
"Display.deprecated or AnsibleModule.deprecate",
|
||||
"ansible-invalid-deprecated-version",
|
||||
"Used when a call to Display.deprecated specifies an invalid "
|
||||
"Ansible version number",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9504': ("Deprecated version (%r) found in call to Display.deprecated "
|
||||
"or AnsibleModule.deprecate",
|
||||
"collection-deprecated-version",
|
||||
"Used when a call to Display.deprecated specifies a collection "
|
||||
"version less than or equal to the current version of this "
|
||||
"collection",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9505': ("Invalid deprecated version (%r) found in call to "
|
||||
"Display.deprecated or AnsibleModule.deprecate",
|
||||
"collection-invalid-deprecated-version",
|
||||
"Used when a call to Display.deprecated specifies an invalid "
|
||||
"collection version number",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9506': ("No collection name found in call to Display.deprecated or "
|
||||
"AnsibleModule.deprecate",
|
||||
"ansible-deprecated-no-collection-name",
|
||||
"The current collection name in format `namespace.name` must "
|
||||
"be provided as collection_name when calling Display.deprecated "
|
||||
"or AnsibleModule.deprecate (`ansible.builtin` for ansible-core)",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9507': ("Wrong collection name (%r) found in call to "
|
||||
"Display.deprecated or AnsibleModule.deprecate",
|
||||
"wrong-collection-deprecated",
|
||||
"The name of the current collection must be passed to the "
|
||||
"Display.deprecated resp. AnsibleModule.deprecate calls "
|
||||
"(`ansible.builtin` for ansible-core)",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9508': ("Expired date (%r) found in call to Display.deprecated "
|
||||
"or AnsibleModule.deprecate",
|
||||
"ansible-deprecated-date",
|
||||
"Used when a call to Display.deprecated specifies a date "
|
||||
"before today",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9509': ("Invalid deprecated date (%r) found in call to "
|
||||
"Display.deprecated or AnsibleModule.deprecate",
|
||||
"ansible-invalid-deprecated-date",
|
||||
"Used when a call to Display.deprecated specifies an invalid "
|
||||
"date. It must be a string in format `YYYY-MM-DD` (ISO 8601)",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9510': ("Both version and date found in call to "
|
||||
"Display.deprecated or AnsibleModule.deprecate",
|
||||
"ansible-deprecated-both-version-and-date",
|
||||
"Only one of version and date must be specified",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9511': ("Removal version (%r) must be a major release, not a minor or "
|
||||
"patch release (see the specification at https://semver.org/)",
|
||||
"removal-version-must-be-major",
|
||||
"Used when a call to Display.deprecated or "
|
||||
"AnsibleModule.deprecate for a collection specifies a version "
|
||||
"which is not of the form x.0.0",
|
||||
{'minversion': (2, 6)}),
|
||||
}
|
||||
|
||||
|
||||
ANSIBLE_VERSION = LooseVersion('.'.join(ansible_version_raw.split('.')[:3]))
|
||||
|
||||
|
||||
def _get_expr_name(node):
|
||||
"""Function to get either ``attrname`` or ``name`` from ``node.func.expr``
|
||||
|
||||
Created specifically for the case of ``display.deprecated`` or ``self._display.deprecated``
|
||||
"""
|
||||
try:
|
||||
return node.func.expr.attrname
|
||||
except AttributeError:
|
||||
# If this fails too, we'll let it raise, the caller should catch it
|
||||
return node.func.expr.name
|
||||
|
||||
|
||||
def _get_func_name(node):
|
||||
"""Function to get either ``attrname`` or ``name`` from ``node.func``
|
||||
|
||||
Created specifically for the case of ``from ansible.module_utils.common.warnings import deprecate``
|
||||
"""
|
||||
try:
|
||||
return node.func.attrname
|
||||
except AttributeError:
|
||||
return node.func.name
|
||||
|
||||
|
||||
def parse_isodate(value):
|
||||
"""Parse an ISO 8601 date string."""
|
||||
msg = 'Expected ISO 8601 date string (YYYY-MM-DD)'
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(msg)
|
||||
# From Python 3.7 in, there is datetime.date.fromisoformat(). For older versions,
|
||||
# we have to do things manually.
|
||||
if not re.match('^[0-9]{4}-[0-9]{2}-[0-9]{2}$', value):
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
return datetime.datetime.strptime(value, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
raise ValueError(msg) from None
|
||||
|
||||
|
||||
class AnsibleDeprecatedChecker(BaseChecker):
|
||||
"""Checks for Display.deprecated calls to ensure that the ``version``
|
||||
has not passed or met the time for removal
|
||||
"""
|
||||
|
||||
name = 'deprecated'
|
||||
msgs = MSGS
|
||||
|
||||
options = (
|
||||
('collection-name', {
|
||||
'default': None,
|
||||
'type': 'string',
|
||||
'metavar': '<name>',
|
||||
'help': 'The collection\'s name used to check collection names in deprecations.',
|
||||
}),
|
||||
('collection-version', {
|
||||
'default': None,
|
||||
'type': 'string',
|
||||
'metavar': '<version>',
|
||||
'help': 'The collection\'s version number used to check deprecations.',
|
||||
}),
|
||||
)
|
||||
|
||||
def _check_date(self, node, date):
|
||||
if not isinstance(date, str):
|
||||
self.add_message('ansible-invalid-deprecated-date', node=node, args=(date,))
|
||||
return
|
||||
|
||||
try:
|
||||
date_parsed = parse_isodate(date)
|
||||
except ValueError:
|
||||
self.add_message('ansible-invalid-deprecated-date', node=node, args=(date,))
|
||||
return
|
||||
|
||||
if date_parsed < datetime.date.today():
|
||||
self.add_message('ansible-deprecated-date', node=node, args=(date,))
|
||||
|
||||
def _check_version(self, node, version, collection_name):
|
||||
if collection_name is None:
|
||||
collection_name = 'ansible.builtin'
|
||||
if not isinstance(version, (str, float)):
|
||||
if collection_name == 'ansible.builtin':
|
||||
symbol = 'ansible-invalid-deprecated-version'
|
||||
else:
|
||||
symbol = 'collection-invalid-deprecated-version'
|
||||
self.add_message(symbol, node=node, args=(version,))
|
||||
return
|
||||
|
||||
version_no = str(version)
|
||||
|
||||
if collection_name == 'ansible.builtin':
|
||||
# Ansible-base
|
||||
try:
|
||||
if not version_no:
|
||||
raise ValueError('Version string should not be empty')
|
||||
loose_version = LooseVersion(str(version_no))
|
||||
if ANSIBLE_VERSION >= loose_version:
|
||||
self.add_message('ansible-deprecated-version', node=node, args=(version,))
|
||||
except ValueError:
|
||||
self.add_message('ansible-invalid-deprecated-version', node=node, args=(version,))
|
||||
elif collection_name:
|
||||
# Collections
|
||||
try:
|
||||
if not version_no:
|
||||
raise ValueError('Version string should not be empty')
|
||||
semantic_version = SemanticVersion(version_no)
|
||||
if collection_name == self.collection_name and self.collection_version is not None:
|
||||
if self.collection_version >= semantic_version:
|
||||
self.add_message('collection-deprecated-version', node=node, args=(version,))
|
||||
if semantic_version.major != 0 and (semantic_version.minor != 0 or semantic_version.patch != 0):
|
||||
self.add_message('removal-version-must-be-major', node=node, args=(version,))
|
||||
except ValueError:
|
||||
self.add_message('collection-invalid-deprecated-version', node=node, args=(version,))
|
||||
|
||||
@property
|
||||
def collection_name(self) -> t.Optional[str]:
|
||||
"""Return the collection name, or None if ansible-core is being tested."""
|
||||
return self.linter.config.collection_name
|
||||
|
||||
@property
|
||||
def collection_version(self) -> t.Optional[SemanticVersion]:
|
||||
"""Return the collection version, or None if ansible-core is being tested."""
|
||||
if self.linter.config.collection_version is None:
|
||||
return None
|
||||
sem_ver = SemanticVersion(self.linter.config.collection_version)
|
||||
# Ignore pre-release for version comparison to catch issues before the final release is cut.
|
||||
sem_ver.prerelease = ()
|
||||
return sem_ver
|
||||
|
||||
@check_messages(*(MSGS.keys()))
|
||||
def visit_call(self, node):
|
||||
"""Visit a call node."""
|
||||
version = None
|
||||
date = None
|
||||
collection_name = None
|
||||
try:
|
||||
funcname = _get_func_name(node)
|
||||
if (funcname == 'deprecated' and 'display' in _get_expr_name(node) or
|
||||
funcname == 'deprecate'):
|
||||
if node.keywords:
|
||||
for keyword in node.keywords:
|
||||
if len(node.keywords) == 1 and keyword.arg is None:
|
||||
# This is likely a **kwargs splat
|
||||
return
|
||||
if keyword.arg == 'version':
|
||||
if isinstance(keyword.value.value, astroid.Name):
|
||||
# This is likely a variable
|
||||
return
|
||||
version = keyword.value.value
|
||||
if keyword.arg == 'date':
|
||||
if isinstance(keyword.value.value, astroid.Name):
|
||||
# This is likely a variable
|
||||
return
|
||||
date = keyword.value.value
|
||||
if keyword.arg == 'collection_name':
|
||||
if isinstance(keyword.value.value, astroid.Name):
|
||||
# This is likely a variable
|
||||
return
|
||||
collection_name = keyword.value.value
|
||||
if not version and not date:
|
||||
try:
|
||||
version = node.args[1].value
|
||||
except IndexError:
|
||||
self.add_message('ansible-deprecated-no-version', node=node)
|
||||
return
|
||||
if version and date:
|
||||
self.add_message('ansible-deprecated-both-version-and-date', node=node)
|
||||
|
||||
if collection_name:
|
||||
this_collection = collection_name == (self.collection_name or 'ansible.builtin')
|
||||
if not this_collection:
|
||||
self.add_message('wrong-collection-deprecated', node=node, args=(collection_name,))
|
||||
elif self.collection_name is not None:
|
||||
self.add_message('ansible-deprecated-no-collection-name', node=node)
|
||||
|
||||
if date:
|
||||
self._check_date(node, date)
|
||||
elif version:
|
||||
self._check_version(node, version, collection_name)
|
||||
except AttributeError:
|
||||
# Not the type of node we are interested in
|
||||
pass
|
||||
|
||||
|
||||
class AnsibleDeprecatedCommentChecker(BaseTokenChecker):
|
||||
"""Checks for ``# deprecated:`` comments to ensure that the ``version``
|
||||
has not passed or met the time for removal
|
||||
"""
|
||||
|
||||
name = 'deprecated-comment'
|
||||
msgs = {
|
||||
'E9601': ("Deprecated core version (%r) found: %s",
|
||||
"ansible-deprecated-version-comment",
|
||||
"Used when a '# deprecated:' comment specifies a version "
|
||||
"less than or equal to the current version of Ansible",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9602': ("Deprecated comment contains invalid keys %r",
|
||||
"ansible-deprecated-version-comment-invalid-key",
|
||||
"Used when a '#deprecated:' comment specifies invalid data",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9603': ("Deprecated comment missing version",
|
||||
"ansible-deprecated-version-comment-missing-version",
|
||||
"Used when a '#deprecated:' comment specifies invalid data",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9604': ("Deprecated python version (%r) found: %s",
|
||||
"ansible-deprecated-python-version-comment",
|
||||
"Used when a '#deprecated:' comment specifies a python version "
|
||||
"less than or equal to the minimum python version",
|
||||
{'minversion': (2, 6)}),
|
||||
'E9605': ("Deprecated comment contains invalid version %r: %s",
|
||||
"ansible-deprecated-version-comment-invalid-version",
|
||||
"Used when a '#deprecated:' comment specifies an invalid version",
|
||||
{'minversion': (2, 6)}),
|
||||
}
|
||||
|
||||
def process_tokens(self, tokens: list[TokenInfo]) -> None:
|
||||
for token in tokens:
|
||||
if token.type == COMMENT:
|
||||
self._process_comment(token)
|
||||
|
||||
def _deprecated_string_to_dict(self, token: TokenInfo, string: str) -> dict[str, str]:
|
||||
valid_keys = {'description', 'core_version', 'python_version'}
|
||||
data = dict.fromkeys(valid_keys)
|
||||
for opt in shlex.split(string):
|
||||
if '=' not in opt:
|
||||
data[opt] = None
|
||||
continue
|
||||
key, _sep, value = opt.partition('=')
|
||||
data[key] = value
|
||||
if not any((data['core_version'], data['python_version'])):
|
||||
self.add_message(
|
||||
'ansible-deprecated-version-comment-missing-version',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
)
|
||||
bad = set(data).difference(valid_keys)
|
||||
if bad:
|
||||
self.add_message(
|
||||
'ansible-deprecated-version-comment-invalid-key',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
args=(','.join(bad),)
|
||||
)
|
||||
return data
|
||||
|
||||
def _process_python_version(self, token: TokenInfo, data: dict[str, str]) -> None:
|
||||
check_version = '.'.join(map(str, self.linter.config.py_version))
|
||||
|
||||
try:
|
||||
if LooseVersion(data['python_version']) < LooseVersion(check_version):
|
||||
self.add_message(
|
||||
'ansible-deprecated-python-version-comment',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
args=(
|
||||
data['python_version'],
|
||||
data['description'] or 'description not provided',
|
||||
),
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.add_message(
|
||||
'ansible-deprecated-version-comment-invalid-version',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
args=(data['python_version'], exc)
|
||||
)
|
||||
|
||||
def _process_core_version(self, token: TokenInfo, data: dict[str, str]) -> None:
|
||||
try:
|
||||
if ANSIBLE_VERSION >= LooseVersion(data['core_version']):
|
||||
self.add_message(
|
||||
'ansible-deprecated-version-comment',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
args=(
|
||||
data['core_version'],
|
||||
data['description'] or 'description not provided',
|
||||
)
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.add_message(
|
||||
'ansible-deprecated-version-comment-invalid-version',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
args=(data['core_version'], exc)
|
||||
)
|
||||
|
||||
def _process_comment(self, token: TokenInfo) -> None:
|
||||
if token.string.startswith('# deprecated:'):
|
||||
data = self._deprecated_string_to_dict(token, token.string[13:].strip())
|
||||
if data['core_version']:
|
||||
self._process_core_version(token, data)
|
||||
if data['python_version']:
|
||||
self._process_python_version(token, data)
|
||||
|
||||
|
||||
def register(linter):
|
||||
"""required method to auto register this checker """
|
||||
linter.register_checker(AnsibleDeprecatedChecker(linter))
|
||||
linter.register_checker(AnsibleDeprecatedCommentChecker(linter))
|
||||
@ -0,0 +1,475 @@
|
||||
"""Ansible-specific pylint plugin for checking deprecation calls."""
|
||||
|
||||
# (c) 2018, Matt Martz <matt@sivel.net>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import functools
|
||||
import pathlib
|
||||
|
||||
import astroid
|
||||
import astroid.context
|
||||
import astroid.typing
|
||||
|
||||
import pylint.lint
|
||||
import pylint.checkers
|
||||
import pylint.checkers.utils
|
||||
|
||||
import ansible.release
|
||||
|
||||
from ansible.module_utils._internal._deprecator import INDETERMINATE_DEPRECATOR, _path_as_collection_plugininfo
|
||||
from ansible.module_utils.compat.version import StrictVersion
|
||||
from ansible.utils.version import SemanticVersion
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, kw_only=True)
|
||||
class DeprecationCallArgs:
|
||||
"""Arguments passed to a deprecation function."""
|
||||
|
||||
msg: object = None
|
||||
version: object = None
|
||||
date: object = None
|
||||
collection_name: object = None
|
||||
deprecator: object = None
|
||||
help_text: object = None # only on Display.deprecated, warnings.deprecate and deprecate_value
|
||||
obj: object = None # only on Display.deprecated and warnings.deprecate
|
||||
removed: object = None # only on Display.deprecated
|
||||
value: object = None # only on deprecate_value
|
||||
|
||||
|
||||
class AnsibleDeprecatedChecker(pylint.checkers.BaseChecker):
|
||||
"""Checks for deprecated calls to ensure proper usage."""
|
||||
|
||||
name = 'deprecated-calls'
|
||||
msgs = {
|
||||
'E9501': (
|
||||
"Deprecated version %r found in call to %r",
|
||||
"ansible-deprecated-version",
|
||||
None,
|
||||
),
|
||||
'E9502': (
|
||||
"Found %r call without a version or date",
|
||||
"ansible-deprecated-no-version",
|
||||
None,
|
||||
),
|
||||
'E9503': (
|
||||
"Invalid deprecated version %r found in call to %r",
|
||||
"ansible-invalid-deprecated-version",
|
||||
None,
|
||||
),
|
||||
'E9504': (
|
||||
"Deprecated version %r found in call to %r",
|
||||
"collection-deprecated-version",
|
||||
None,
|
||||
),
|
||||
'E9505': (
|
||||
"Invalid deprecated version %r found in call to %r",
|
||||
"collection-invalid-deprecated-version",
|
||||
None,
|
||||
),
|
||||
'E9506': (
|
||||
"No collection_name or deprecator found in call to %r",
|
||||
"ansible-deprecated-no-collection-name",
|
||||
None,
|
||||
),
|
||||
'E9507': (
|
||||
"Wrong collection_name %r found in call to %r",
|
||||
"wrong-collection-deprecated",
|
||||
None,
|
||||
),
|
||||
'E9508': (
|
||||
"Expired date %r found in call to %r",
|
||||
"ansible-expired-deprecated-date",
|
||||
None,
|
||||
),
|
||||
'E9509': (
|
||||
"Invalid date %r found in call to %r",
|
||||
"ansible-invalid-deprecated-date",
|
||||
None,
|
||||
),
|
||||
'E9510': (
|
||||
"Both version and date found in call to %r",
|
||||
"ansible-deprecated-both-version-and-date",
|
||||
None,
|
||||
),
|
||||
'E9511': (
|
||||
"Removal version %r must be a major release, not a minor or patch release, see https://semver.org/",
|
||||
"removal-version-must-be-major",
|
||||
None,
|
||||
),
|
||||
'E9512': (
|
||||
"Passing date is not permitted in call to %r for ansible-core, use a version instead",
|
||||
"ansible-deprecated-date-not-permitted",
|
||||
None,
|
||||
),
|
||||
'E9513': (
|
||||
"Unnecessary %r found in call to %r",
|
||||
"ansible-deprecated-unnecessary-collection-name",
|
||||
None,
|
||||
),
|
||||
'E9514': (
|
||||
"Passing collection_name not permitted in call to %r for ansible-core, use deprecator instead",
|
||||
"ansible-deprecated-collection-name-not-permitted",
|
||||
None,
|
||||
),
|
||||
'E9515': (
|
||||
"Both collection_name and deprecator found in call to %r",
|
||||
"ansible-deprecated-both-collection-name-and-deprecator",
|
||||
None,
|
||||
),
|
||||
}
|
||||
|
||||
options = (
|
||||
(
|
||||
'collection-name',
|
||||
dict(
|
||||
default=None,
|
||||
type='string',
|
||||
metavar='<name>',
|
||||
help="The name of the collection to check.",
|
||||
),
|
||||
),
|
||||
(
|
||||
'collection-version',
|
||||
dict(
|
||||
default=None,
|
||||
type='string',
|
||||
metavar='<version>',
|
||||
help="The version of the collection to check.",
|
||||
),
|
||||
),
|
||||
(
|
||||
'collection-path',
|
||||
dict(
|
||||
default=None,
|
||||
type='string',
|
||||
metavar='<path>',
|
||||
help="The path of the collection to check.",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
ANSIBLE_VERSION = StrictVersion('.'.join(ansible.release.__version__.split('.')[:3]))
|
||||
"""The current ansible-core X.Y.Z version."""
|
||||
|
||||
DEPRECATION_MODULE_FUNCTIONS: dict[tuple[str, str], tuple[str, ...]] = {
|
||||
('ansible.module_utils.common.warnings', 'deprecate'): ('msg', 'version', 'date', 'collection_name'),
|
||||
('ansible.module_utils.datatag', 'deprecate_value'): ('value', 'msg'),
|
||||
('ansible.module_utils.basic', 'AnsibleModule.deprecate'): ('msg', 'version', 'date', 'collection_name'),
|
||||
('ansible.utils.display', 'Display.deprecated'): ('msg', 'version', 'removed', 'date', 'collection_name'),
|
||||
}
|
||||
"""Mapping of deprecation module+function and their positional arguments."""
|
||||
|
||||
DEPRECATION_MODULES = frozenset(key[0] for key in DEPRECATION_MODULE_FUNCTIONS)
|
||||
"""Modules which contain deprecation functions."""
|
||||
|
||||
DEPRECATION_FUNCTIONS = {'.'.join(key): value for key, value in DEPRECATION_MODULE_FUNCTIONS.items()}
|
||||
"""Mapping of deprecation functions and their positional arguments."""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.inference_context = astroid.context.InferenceContext()
|
||||
self.module_cache: dict[str, astroid.Module] = {}
|
||||
|
||||
@functools.cached_property
|
||||
def collection_name(self) -> str | None:
|
||||
"""Return the collection name, or None if ansible-core is being tested."""
|
||||
return self.linter.config.collection_name or None
|
||||
|
||||
@functools.cached_property
|
||||
def collection_path(self) -> pathlib.Path:
|
||||
"""Return the collection path. Not valid when ansible-core is being tested."""
|
||||
return pathlib.Path(self.linter.config.collection_path)
|
||||
|
||||
@functools.cached_property
|
||||
def collection_version(self) -> SemanticVersion | None:
|
||||
"""Return the collection version, or None if ansible-core is being tested."""
|
||||
if not self.linter.config.collection_version:
|
||||
return None
|
||||
|
||||
sem_ver = SemanticVersion(self.linter.config.collection_version)
|
||||
sem_ver.prerelease = () # ignore pre-release for version comparison to catch issues before the final release is cut
|
||||
|
||||
return sem_ver
|
||||
|
||||
@functools.cached_property
|
||||
def is_ansible_core(self) -> bool:
|
||||
"""True if ansible-core is being tested."""
|
||||
return not self.collection_name
|
||||
|
||||
@functools.cached_property
|
||||
def today_utc(self) -> datetime.date:
|
||||
"""Today's date in UTC."""
|
||||
return datetime.datetime.now(tz=datetime.timezone.utc).date()
|
||||
|
||||
def is_deprecator_required(self) -> bool | None:
|
||||
"""Determine is a `collection_name` or `deprecator` is required (True), unnecessary (False) or optional (None)."""
|
||||
if self.is_ansible_core:
|
||||
return False # in ansible-core, never provide the deprecator -- if it really is needed, disable the sanity test inline for that line of code
|
||||
|
||||
plugin_info = _path_as_collection_plugininfo(self.linter.current_file)
|
||||
|
||||
if plugin_info is INDETERMINATE_DEPRECATOR:
|
||||
return True # deprecator cannot be detected, caller must provide deprecator
|
||||
|
||||
# deprecation: description='deprecate collection_name/deprecator now that detection is widely available' core_version='2.23'
|
||||
# When this deprecation triggers, change the return type here to False.
|
||||
# At that point, callers should be able to omit the collection_name/deprecator in all but a few cases (inline ignores can be used for those cases)
|
||||
return None
|
||||
|
||||
@pylint.checkers.utils.only_required_for_messages(*(msgs.keys()))
|
||||
def visit_call(self, node: astroid.Call) -> None:
|
||||
"""Visit a call node."""
|
||||
if inferred := self.infer(node.func):
|
||||
name = self.get_fully_qualified_name(inferred)
|
||||
|
||||
if args := self.DEPRECATION_FUNCTIONS.get(name):
|
||||
self.check_call(node, name, args)
|
||||
|
||||
def infer(self, node: astroid.NodeNG) -> astroid.NodeNG | None:
|
||||
"""Return the inferred node from the given node, or `None` if it cannot be unambiguously inferred."""
|
||||
names: list[str] = []
|
||||
target: astroid.NodeNG | None = node
|
||||
inferred: astroid.typing.InferenceResult | None = None
|
||||
|
||||
while target:
|
||||
if inferred := astroid.util.safe_infer(target, self.inference_context):
|
||||
break
|
||||
|
||||
if isinstance(target, astroid.Call):
|
||||
inferred = self.infer(target.func)
|
||||
break
|
||||
|
||||
if isinstance(target, astroid.FunctionDef):
|
||||
inferred = target
|
||||
break
|
||||
|
||||
if isinstance(target, astroid.Name):
|
||||
target = self.infer_name(target)
|
||||
elif isinstance(target, astroid.AssignName) and isinstance(target.parent, astroid.Assign):
|
||||
target = target.parent.value
|
||||
elif isinstance(target, astroid.Attribute):
|
||||
names.append(target.attrname)
|
||||
target = target.expr
|
||||
else:
|
||||
break
|
||||
|
||||
for name in reversed(names):
|
||||
if not isinstance(inferred, (astroid.Module, astroid.ClassDef)):
|
||||
inferred = None
|
||||
break
|
||||
|
||||
try:
|
||||
inferred = inferred[name]
|
||||
except KeyError:
|
||||
inferred = None
|
||||
else:
|
||||
inferred = self.infer(inferred)
|
||||
|
||||
if isinstance(inferred, astroid.FunctionDef) and isinstance(inferred.parent, astroid.ClassDef):
|
||||
inferred = astroid.BoundMethod(inferred, inferred.parent)
|
||||
|
||||
return inferred
|
||||
|
||||
def infer_name(self, node: astroid.Name) -> astroid.NodeNG | None:
|
||||
"""Infer the node referenced by the given name, or `None` if it cannot be unambiguously inferred."""
|
||||
scope = node.scope()
|
||||
name = None
|
||||
|
||||
while scope:
|
||||
try:
|
||||
assignment = scope[node.name]
|
||||
except KeyError:
|
||||
scope = scope.parent.scope() if scope.parent else None
|
||||
continue
|
||||
|
||||
if isinstance(assignment, astroid.AssignName) and isinstance(assignment.parent, astroid.Assign):
|
||||
name = assignment.parent.value
|
||||
elif isinstance(assignment, astroid.ImportFrom):
|
||||
if module := self.get_module(assignment):
|
||||
scope = module.scope()
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
return name
|
||||
|
||||
def get_module(self, node: astroid.ImportFrom) -> astroid.Module | None:
|
||||
"""Import the requested module if possible and cache the result."""
|
||||
module_name = pylint.checkers.utils.get_import_name(node, node.modname)
|
||||
|
||||
if module_name not in self.DEPRECATION_MODULES:
|
||||
return None # avoid unnecessary import overhead
|
||||
|
||||
if module := self.module_cache.get(module_name):
|
||||
return module
|
||||
|
||||
module = node.do_import_module()
|
||||
|
||||
if module.name != module_name:
|
||||
raise RuntimeError(f'Attempted to import {module_name!r} but found {module.name!r} instead.')
|
||||
|
||||
self.module_cache[module_name] = module
|
||||
|
||||
return module
|
||||
|
||||
@staticmethod
|
||||
def get_fully_qualified_name(node: astroid.NodeNG) -> str | None:
|
||||
"""Return the fully qualified name of the given inferred node."""
|
||||
parent = node.parent
|
||||
parts: tuple[str, ...] | None
|
||||
|
||||
if isinstance(node, astroid.FunctionDef) and isinstance(parent, astroid.Module):
|
||||
parts = (parent.name, node.name)
|
||||
elif isinstance(node, astroid.BoundMethod) and isinstance(parent, astroid.ClassDef) and isinstance(parent.parent, astroid.Module):
|
||||
parts = (parent.parent.name, parent.name, node.name)
|
||||
else:
|
||||
parts = None
|
||||
|
||||
return '.'.join(parts) if parts else None
|
||||
|
||||
def check_call(self, node: astroid.Call, name: str, args: tuple[str, ...]) -> None:
|
||||
"""Check the given deprecation call node for valid arguments."""
|
||||
call_args = self.get_deprecation_call_args(node, args)
|
||||
|
||||
self.check_collection_name(node, name, call_args)
|
||||
|
||||
if not call_args.version and not call_args.date:
|
||||
self.add_message('ansible-deprecated-no-version', node=node, args=(name,))
|
||||
return
|
||||
|
||||
if call_args.date and self.is_ansible_core:
|
||||
self.add_message('ansible-deprecated-date-not-permitted', node=node, args=(name,))
|
||||
return
|
||||
|
||||
if call_args.version and call_args.date:
|
||||
self.add_message('ansible-deprecated-both-version-and-date', node=node, args=(name,))
|
||||
return
|
||||
|
||||
if call_args.date:
|
||||
self.check_date(node, name, call_args)
|
||||
|
||||
if call_args.version:
|
||||
self.check_version(node, name, call_args)
|
||||
|
||||
@staticmethod
|
||||
def get_deprecation_call_args(node: astroid.Call, args: tuple[str, ...]) -> DeprecationCallArgs:
|
||||
"""Get the deprecation call arguments from the given node."""
|
||||
fields: dict[str, object] = {}
|
||||
|
||||
for idx, arg in enumerate(node.args):
|
||||
field = args[idx]
|
||||
fields[field] = arg
|
||||
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg is not None:
|
||||
fields[keyword.arg] = keyword.value
|
||||
|
||||
for key, value in fields.items():
|
||||
if isinstance(value, astroid.Const):
|
||||
fields[key] = value.value
|
||||
|
||||
return DeprecationCallArgs(**fields)
|
||||
|
||||
def check_collection_name(self, node: astroid.Call, name: str, args: DeprecationCallArgs) -> None:
|
||||
"""Check the collection name provided to the given call node."""
|
||||
deprecator_requirement = self.is_deprecator_required()
|
||||
|
||||
if self.is_ansible_core and args.collection_name:
|
||||
self.add_message('ansible-deprecated-collection-name-not-permitted', node=node, args=(name,))
|
||||
return
|
||||
|
||||
if args.collection_name and args.deprecator:
|
||||
self.add_message('ansible-deprecated-both-collection-name-and-deprecator', node=node, args=(name,))
|
||||
|
||||
if deprecator_requirement is True:
|
||||
if not args.collection_name and not args.deprecator:
|
||||
self.add_message('ansible-deprecated-no-collection-name', node=node, args=(name,))
|
||||
return
|
||||
elif deprecator_requirement is False:
|
||||
if args.collection_name:
|
||||
self.add_message('ansible-deprecated-unnecessary-collection-name', node=node, args=('collection_name', name,))
|
||||
return
|
||||
|
||||
if args.deprecator:
|
||||
self.add_message('ansible-deprecated-unnecessary-collection-name', node=node, args=('deprecator', name,))
|
||||
return
|
||||
else:
|
||||
# collection_name may be needed for backward compat with 2.18 and earlier, since it is only detected in 2.19 and later
|
||||
|
||||
if args.deprecator:
|
||||
# Unlike collection_name, which is needed for backward compat, deprecator is generally not needed by collections.
|
||||
# For the very rare cases where this is needed by collections, an inline pylint ignore can be used to silence it.
|
||||
self.add_message('ansible-deprecated-unnecessary-collection-name', node=node, args=('deprecator', name,))
|
||||
return
|
||||
|
||||
expected_collection_name = 'ansible.builtin' if self.is_ansible_core else self.collection_name
|
||||
|
||||
if args.collection_name and args.collection_name != expected_collection_name:
|
||||
# if collection_name is provided and a constant, report when it does not match the expected name
|
||||
self.add_message('wrong-collection-deprecated', node=node, args=(args.collection_name, name))
|
||||
|
||||
def check_version(self, node: astroid.Call, name: str, args: DeprecationCallArgs) -> None:
|
||||
"""Check the version provided to the given call node."""
|
||||
if self.collection_name:
|
||||
self.check_collection_version(node, name, args)
|
||||
else:
|
||||
self.check_core_version(node, name, args)
|
||||
|
||||
def check_core_version(self, node: astroid.Call, name: str, args: DeprecationCallArgs) -> None:
|
||||
"""Check the core version provided to the given call node."""
|
||||
try:
|
||||
if not isinstance(args.version, str) or not args.version:
|
||||
raise ValueError()
|
||||
|
||||
strict_version = StrictVersion(args.version)
|
||||
except ValueError:
|
||||
self.add_message('ansible-invalid-deprecated-version', node=node, args=(args.version, name))
|
||||
return
|
||||
|
||||
if self.ANSIBLE_VERSION >= strict_version:
|
||||
self.add_message('ansible-deprecated-version', node=node, args=(args.version, name))
|
||||
|
||||
def check_collection_version(self, node: astroid.Call, name: str, args: DeprecationCallArgs) -> None:
|
||||
"""Check the collection version provided to the given call node."""
|
||||
try:
|
||||
if not isinstance(args.version, str) or not args.version:
|
||||
raise ValueError()
|
||||
|
||||
semantic_version = SemanticVersion(args.version)
|
||||
except ValueError:
|
||||
self.add_message('collection-invalid-deprecated-version', node=node, args=(args.version, name))
|
||||
return
|
||||
|
||||
if self.collection_version >= semantic_version:
|
||||
self.add_message('collection-deprecated-version', node=node, args=(args.version, name))
|
||||
|
||||
if semantic_version.major != 0 and (semantic_version.minor != 0 or semantic_version.patch != 0):
|
||||
self.add_message('removal-version-must-be-major', node=node, args=(args.version,))
|
||||
|
||||
def check_date(self, node: astroid.Call, name: str, args: DeprecationCallArgs) -> None:
|
||||
"""Check the date provided to the given call node."""
|
||||
try:
|
||||
date_parsed = self.parse_isodate(args.date)
|
||||
except (ValueError, TypeError):
|
||||
self.add_message('ansible-invalid-deprecated-date', node=node, args=(args.date, name))
|
||||
else:
|
||||
if date_parsed < self.today_utc:
|
||||
self.add_message('ansible-expired-deprecated-date', node=node, args=(args.date, name))
|
||||
|
||||
@staticmethod
|
||||
def parse_isodate(value: object) -> datetime.date:
|
||||
"""Parse an ISO 8601 date string."""
|
||||
if isinstance(value, str):
|
||||
return datetime.date.fromisoformat(value)
|
||||
|
||||
raise TypeError(type(value))
|
||||
|
||||
|
||||
def register(linter: pylint.lint.PyLinter) -> None:
|
||||
"""Required method to auto-register this checker."""
|
||||
linter.register_checker(AnsibleDeprecatedChecker(linter))
|
||||
@ -0,0 +1,137 @@
|
||||
"""Ansible-specific pylint plugin for checking deprecation comments."""
|
||||
|
||||
# (c) 2018, Matt Martz <matt@sivel.net>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import tokenize
|
||||
|
||||
import pylint.checkers
|
||||
import pylint.lint
|
||||
|
||||
import ansible.release
|
||||
|
||||
from ansible.module_utils.compat.version import LooseVersion
|
||||
|
||||
|
||||
class AnsibleDeprecatedCommentChecker(pylint.checkers.BaseTokenChecker):
|
||||
"""Checks for ``# deprecated:`` comments to ensure that the ``version`` has not passed or met the time for removal."""
|
||||
|
||||
name = 'deprecated-comment'
|
||||
msgs = {
|
||||
'E9601': (
|
||||
"Deprecated core version (%r) found: %s",
|
||||
"ansible-deprecated-version-comment",
|
||||
None,
|
||||
),
|
||||
'E9602': (
|
||||
"Deprecated comment contains invalid keys %r",
|
||||
"ansible-deprecated-version-comment-invalid-key",
|
||||
None,
|
||||
),
|
||||
'E9603': (
|
||||
"Deprecated comment missing version",
|
||||
"ansible-deprecated-version-comment-missing-version",
|
||||
None,
|
||||
),
|
||||
'E9604': (
|
||||
"Deprecated python version (%r) found: %s",
|
||||
"ansible-deprecated-python-version-comment",
|
||||
None,
|
||||
),
|
||||
'E9605': (
|
||||
"Deprecated comment contains invalid version %r: %s",
|
||||
"ansible-deprecated-version-comment-invalid-version",
|
||||
None,
|
||||
),
|
||||
}
|
||||
|
||||
ANSIBLE_VERSION = LooseVersion('.'.join(ansible.release.__version__.split('.')[:3]))
|
||||
"""The current ansible-core X.Y.Z version."""
|
||||
|
||||
def process_tokens(self, tokens: list[tokenize.TokenInfo]) -> None:
|
||||
for token in tokens:
|
||||
if token.type == tokenize.COMMENT:
|
||||
self._process_comment(token)
|
||||
|
||||
def _deprecated_string_to_dict(self, token: tokenize.TokenInfo, string: str) -> dict[str, str]:
|
||||
valid_keys = {'description', 'core_version', 'python_version'}
|
||||
data = dict.fromkeys(valid_keys)
|
||||
for opt in shlex.split(string):
|
||||
if '=' not in opt:
|
||||
data[opt] = None
|
||||
continue
|
||||
key, _sep, value = opt.partition('=')
|
||||
data[key] = value
|
||||
if not any((data['core_version'], data['python_version'])):
|
||||
self.add_message(
|
||||
'ansible-deprecated-version-comment-missing-version',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
)
|
||||
bad = set(data).difference(valid_keys)
|
||||
if bad:
|
||||
self.add_message(
|
||||
'ansible-deprecated-version-comment-invalid-key',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
args=(','.join(bad),),
|
||||
)
|
||||
return data
|
||||
|
||||
def _process_python_version(self, token: tokenize.TokenInfo, data: dict[str, str]) -> None:
|
||||
check_version = '.'.join(map(str, self.linter.config.py_version)) # minimum supported Python version provided by ansible-test
|
||||
|
||||
try:
|
||||
if LooseVersion(check_version) > LooseVersion(data['python_version']):
|
||||
self.add_message(
|
||||
'ansible-deprecated-python-version-comment',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
args=(
|
||||
data['python_version'],
|
||||
data['description'] or 'description not provided',
|
||||
),
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.add_message(
|
||||
'ansible-deprecated-version-comment-invalid-version',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
args=(data['python_version'], exc),
|
||||
)
|
||||
|
||||
def _process_core_version(self, token: tokenize.TokenInfo, data: dict[str, str]) -> None:
|
||||
try:
|
||||
if self.ANSIBLE_VERSION >= LooseVersion(data['core_version']):
|
||||
self.add_message(
|
||||
'ansible-deprecated-version-comment',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
args=(
|
||||
data['core_version'],
|
||||
data['description'] or 'description not provided',
|
||||
),
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.add_message(
|
||||
'ansible-deprecated-version-comment-invalid-version',
|
||||
line=token.start[0],
|
||||
col_offset=token.start[1],
|
||||
args=(data['core_version'], exc),
|
||||
)
|
||||
|
||||
def _process_comment(self, token: tokenize.TokenInfo) -> None:
|
||||
if token.string.startswith('# deprecated:'):
|
||||
data = self._deprecated_string_to_dict(token, token.string[13:].strip())
|
||||
if data['core_version']:
|
||||
self._process_core_version(token, data)
|
||||
if data['python_version']:
|
||||
self._process_python_version(token, data)
|
||||
|
||||
|
||||
def register(linter: pylint.lint.PyLinter) -> None:
|
||||
"""Required method to auto-register this checker."""
|
||||
linter.register_checker(AnsibleDeprecatedCommentChecker(linter))
|
||||
@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.abc
|
||||
import importlib.util
|
||||
|
||||
import ansible
|
||||
import pathlib
|
||||
import pytest
|
||||
|
||||
from ansible.module_utils.common.messages import PluginInfo
|
||||
from ansible.module_utils._internal import _deprecator
|
||||
|
||||
|
||||
class FakePathLoader(importlib.abc.SourceLoader):
|
||||
"""A test loader that can fake out the code/frame paths to simulate callers of various types without relying on actual files on disk."""
|
||||
def get_filename(self, fullname):
|
||||
if fullname.startswith('ansible.'):
|
||||
basepath = pathlib.Path(ansible.__file__).parent.parent
|
||||
else:
|
||||
basepath = '/x/y'
|
||||
|
||||
return f'{basepath}/{fullname.replace(".", "/")}'
|
||||
|
||||
def get_data(self, path):
|
||||
return b'''
|
||||
from ansible.module_utils._internal import _deprecator
|
||||
|
||||
def do_stuff():
|
||||
return _deprecator.get_caller_plugin_info()
|
||||
'''
|
||||
|
||||
def exec_module(self, module):
|
||||
return super().exec_module(module)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("python_fq_name,expected_resolved_name,expected_plugin_type", (
|
||||
# legacy module callers
|
||||
('ansible.legacy.blah', 'ansible.legacy.blah', '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),
|
||||
# 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),
|
||||
# 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),
|
||||
# other callers
|
||||
('something.else', None, None),
|
||||
('ansible_collections.foo.bar.nonplugin_something', None, None),
|
||||
))
|
||||
def test_get_caller_plugin_info(python_fq_name: str, expected_resolved_name: str, expected_plugin_type: str):
|
||||
"""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
|
||||
loader = FakePathLoader()
|
||||
spec = importlib.util.spec_from_loader(name=python_fq_name, loader=loader)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
|
||||
loader.exec_module(mod)
|
||||
|
||||
pi: 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
|
||||
@ -1,91 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (c) 2017 Ansible Project
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import typing as t
|
||||
|
||||
import pytest
|
||||
|
||||
from ansible.module_utils._internal._ansiballz import _ModulePluginWrapper
|
||||
from ansible.module_utils._internal._plugin_exec_context import PluginExecContext
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils.common.json import Direction, get_module_decoder
|
||||
from ansible.module_utils.common import warnings
|
||||
from ansible.module_utils.common.messages import Detail, DeprecationSummary, WarningSummary, PluginInfo
|
||||
|
||||
from units.mock.messages import make_summary
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("module_env_mocker")
|
||||
|
||||
|
||||
@pytest.mark.parametrize('stdin', [{}], indirect=['stdin'])
|
||||
def test_warn(am, capfd):
|
||||
|
||||
am.warn('warning1')
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
am.exit_json(warnings=['warning2'])
|
||||
out, err = capfd.readouterr()
|
||||
actual = json.loads(out, cls=get_module_decoder('legacy', Direction.MODULE_TO_CONTROLLER))['warnings']
|
||||
expected = [make_summary(WarningSummary, Detail(msg=msg)) for msg in ['warning1', 'warning2']]
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kwargs,plugin_name,stdin', (
|
||||
(dict(msg='deprecation1'), None, {}),
|
||||
(dict(msg='deprecation3', version='2.4'), None, {}),
|
||||
(dict(msg='deprecation4', date='2020-03-10'), None, {}),
|
||||
(dict(msg='deprecation5'), 'ansible.builtin.ping', {}),
|
||||
(dict(msg='deprecation7', version='2.4'), 'ansible.builtin.ping', {}),
|
||||
(dict(msg='deprecation8', date='2020-03-10'), 'ansible.builtin.ping', {}),
|
||||
), indirect=['stdin'])
|
||||
def test_deprecate(am: AnsibleModule, capfd, kwargs: dict[str, t.Any], plugin_name: str | None) -> None:
|
||||
plugin_info = PluginInfo(requested_name=plugin_name, resolved_name=plugin_name, type='module') if plugin_name else None
|
||||
executing_plugin = _ModulePluginWrapper(plugin_info) if plugin_info else None
|
||||
collection_name = plugin_name.rpartition('.')[0] if plugin_name else None
|
||||
|
||||
with PluginExecContext.when(bool(executing_plugin), executing_plugin=executing_plugin):
|
||||
am.deprecate(**kwargs)
|
||||
|
||||
assert warnings.get_deprecation_messages() == (dict(collection_name=collection_name, **kwargs),)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
am.exit_json(deprecations=['deprecation9', ('deprecation10', '2.4')])
|
||||
|
||||
out, err = capfd.readouterr()
|
||||
|
||||
output = json.loads(out, cls=get_module_decoder('legacy', Direction.MODULE_TO_CONTROLLER))
|
||||
|
||||
assert ('warnings' not in output or output['warnings'] == [])
|
||||
|
||||
msg = kwargs.pop('msg')
|
||||
|
||||
assert output['deprecations'] == [
|
||||
make_summary(DeprecationSummary, Detail(msg=msg), **kwargs, plugin=plugin_info),
|
||||
make_summary(DeprecationSummary, Detail(msg='deprecation9'), plugin=plugin_info),
|
||||
make_summary(DeprecationSummary, Detail(msg='deprecation10'), version='2.4', plugin=plugin_info),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('stdin', [{}], indirect=['stdin'])
|
||||
def test_deprecate_without_list(am, capfd):
|
||||
with pytest.raises(SystemExit):
|
||||
am.exit_json(deprecations='Simple deprecation warning')
|
||||
|
||||
out, err = capfd.readouterr()
|
||||
output = json.loads(out, cls=get_module_decoder('legacy', Direction.MODULE_TO_CONTROLLER))
|
||||
assert ('warnings' not in output or output['warnings'] == [])
|
||||
assert output['deprecations'] == [
|
||||
make_summary(DeprecationSummary, Detail(msg='Simple deprecation warning')),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('stdin', [{}], indirect=['stdin'])
|
||||
def test_deprecate_without_list_version_date_not_set(am, capfd):
|
||||
with pytest.raises(AssertionError) as ctx:
|
||||
am.deprecate('Simple deprecation warning', date='', version='') # pylint: disable=ansible-deprecated-no-version
|
||||
assert ctx.value.args[0] == "implementation error -- version and date must not both be set"
|
||||
Loading…
Reference in New Issue