diff options
author | Felix Fontein <felix@fontein.de> | 2020-05-13 22:58:09 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-05-13 13:58:09 -0700 |
commit | 0e15375ffeb27376b95e4ceed50a85cbc0e75b4c (patch) | |
tree | 7c1952edfb8023b90c82451e24d19eb38bdb99b0 /test | |
parent | f0b6f76bc61cbef5f45fcf8cfb39ca5767f074f6 (diff) | |
download | ansible-0e15375ffeb27376b95e4ceed50a85cbc0e75b4c.tar.gz |
Add deprecated removed_in_version and deprecated_aliases version tests (#66920)
Diffstat (limited to 'test')
12 files changed, 332 insertions, 13 deletions
diff --git a/test/lib/ansible_test/_data/collection_detail.py b/test/lib/ansible_test/_data/collection_detail.py new file mode 100644 index 0000000000..e7c883ca01 --- /dev/null +++ b/test/lib/ansible_test/_data/collection_detail.py @@ -0,0 +1,95 @@ +"""Retrieve collection detail.""" +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import json +import os +import re +import sys + +import yaml + + +# See semantic versioning specification (https://semver.org/) +NUMERIC_IDENTIFIER = r'(?:0|[1-9][0-9]*)' +ALPHANUMERIC_IDENTIFIER = r'(?:[0-9]*[a-zA-Z-][a-zA-Z0-9-]*)' + +PRE_RELEASE_IDENTIFIER = r'(?:' + NUMERIC_IDENTIFIER + r'|' + ALPHANUMERIC_IDENTIFIER + r')' +BUILD_IDENTIFIER = r'[a-zA-Z0-9-]+' # equivalent to r'(?:[0-9]+|' + ALPHANUMERIC_IDENTIFIER + r')' + +VERSION_CORE = NUMERIC_IDENTIFIER + r'\.' + NUMERIC_IDENTIFIER + r'\.' + NUMERIC_IDENTIFIER +PRE_RELEASE = r'(?:-' + PRE_RELEASE_IDENTIFIER + r'(?:\.' + PRE_RELEASE_IDENTIFIER + r')*)?' +BUILD = r'(?:\+' + BUILD_IDENTIFIER + r'(?:\.' + BUILD_IDENTIFIER + r')*)?' + +SEMVER_REGULAR_EXPRESSION = r'^' + VERSION_CORE + PRE_RELEASE + BUILD + r'$' + + +def validate_version(version): + """Raise exception if the provided version is not None or a valid semantic version.""" + if version is None: + return + if not re.match(SEMVER_REGULAR_EXPRESSION, version): + raise Exception('Invalid version number "{0}". Collection version numbers must ' + 'follow semantic versioning (https://semver.org/).'.format(version)) + + +def read_manifest_json(collection_path): + """Return collection information from the MANIFEST.json file.""" + manifest_path = os.path.join(collection_path, 'MANIFEST.json') + + if not os.path.exists(manifest_path): + return None + + try: + with open(manifest_path) as manifest_file: + manifest = json.load(manifest_file) + + collection_info = manifest.get('collection_info') or dict() + + result = dict( + version=collection_info.get('version'), + ) + validate_version(result['version']) + except Exception as ex: # pylint: disable=broad-except + raise Exception('{0}: {1}'.format(os.path.basename(manifest_path), ex)) + + return result + + +def read_galaxy_yml(collection_path): + """Return collection information from the galaxy.yml file.""" + galaxy_path = os.path.join(collection_path, 'galaxy.yml') + + if not os.path.exists(galaxy_path): + return None + + try: + with open(galaxy_path) as galaxy_file: + galaxy = yaml.safe_load(galaxy_file) + + result = dict( + version=galaxy.get('version'), + ) + validate_version(result['version']) + except Exception as ex: # pylint: disable=broad-except + raise Exception('{0}: {1}'.format(os.path.basename(galaxy_path), ex)) + + return result + + +def main(): + """Retrieve collection detail.""" + collection_path = sys.argv[1] + + try: + result = read_manifest_json(collection_path) or read_galaxy_yml(collection_path) or dict() + except Exception as ex: # pylint: disable=broad-except + result = dict( + error='{0}'.format(ex), + ) + + print(json.dumps(result)) + + +if __name__ == '__main__': + main() diff --git a/test/lib/ansible_test/_data/sanity/pylint/config/collection.cfg b/test/lib/ansible_test/_data/sanity/pylint/config/collection.cfg index 6af0b7dab0..c86765e3d0 100644 --- a/test/lib/ansible_test/_data/sanity/pylint/config/collection.cfg +++ b/test/lib/ansible_test/_data/sanity/pylint/config/collection.cfg @@ -3,7 +3,6 @@ disable= abstract-method, access-member-before-definition, - ansible-deprecated-version, arguments-differ, assignment-from-no-return, assignment-from-none, diff --git a/test/lib/ansible_test/_data/sanity/pylint/config/default.cfg b/test/lib/ansible_test/_data/sanity/pylint/config/default.cfg index ee84cede0c..b279926392 100644 --- a/test/lib/ansible_test/_data/sanity/pylint/config/default.cfg +++ b/test/lib/ansible_test/_data/sanity/pylint/config/default.cfg @@ -3,7 +3,6 @@ disable= abstract-method, access-member-before-definition, - ansible-deprecated-version, arguments-differ, assignment-from-no-return, assignment-from-none, diff --git a/test/lib/ansible_test/_data/sanity/pylint/plugins/deprecated.py b/test/lib/ansible_test/_data/sanity/pylint/plugins/deprecated.py index d0f0417182..f1274f848d 100644 --- a/test/lib/ansible_test/_data/sanity/pylint/plugins/deprecated.py +++ b/test/lib/ansible_test/_data/sanity/pylint/plugins/deprecated.py @@ -13,6 +13,7 @@ from pylint.checkers import BaseChecker from pylint.checkers.utils import check_messages 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 " @@ -30,7 +31,20 @@ MSGS = { "Display.deprecated or AnsibleModule.deprecate", "ansible-invalid-deprecated-version", "Used when a call to Display.deprecated specifies an invalid " - "version number", + "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)}), } @@ -59,6 +73,35 @@ class AnsibleDeprecatedChecker(BaseChecker): name = 'deprecated' msgs = MSGS + options = ( + ('is-collection', { + 'default': False, + 'type': 'yn', + 'metavar': '<y_or_n>', + 'help': 'Whether this is a collection or not.', + }), + ('collection-version', { + 'default': None, + 'type': 'string', + 'metavar': '<version>', + 'help': 'The collection\'s version number used to check deprecations.', + }), + ) + + def __init__(self, *args, **kwargs): + self.collection_version = None + self.is_collection = False + self.version_constructor = LooseVersion + super(AnsibleDeprecatedChecker, self).__init__(*args, **kwargs) + + def set_option(self, optname, value, action=None, optdict=None): + super(AnsibleDeprecatedChecker, self).set_option(optname, value, action, optdict) + if optname == 'collection-version' and value is not None: + self.version_constructor = SemanticVersion + self.collection_version = self.version_constructor(self.config.collection_version) + if optname == 'is-collection': + self.is_collection = self.config.is_collection + @check_messages(*(MSGS.keys())) def visit_call(self, node): version = None @@ -83,10 +126,17 @@ class AnsibleDeprecatedChecker(BaseChecker): return try: - if ANSIBLE_VERSION >= LooseVersion(str(version)): + loose_version = self.version_constructor(str(version)) + if self.is_collection and self.collection_version is not None: + if self.collection_version >= loose_version: + self.add_message('collection-deprecated-version', node=node, args=(version,)) + if not self.is_collection and 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,)) + if self.is_collection: + self.add_message('collection-invalid-deprecated-version', node=node, args=(version,)) + else: + self.add_message('ansible-invalid-deprecated-version', node=node, args=(version,)) except AttributeError: # Not the type of node we are interested in pass diff --git a/test/lib/ansible_test/_data/sanity/validate-modules/validate_modules/main.py b/test/lib/ansible_test/_data/sanity/validate-modules/validate_modules/main.py index 6632b03632..2e24095c6d 100644 --- a/test/lib/ansible_test/_data/sanity/validate-modules/validate_modules/main.py +++ b/test/lib/ansible_test/_data/sanity/validate-modules/validate_modules/main.py @@ -32,8 +32,9 @@ import traceback from collections import OrderedDict from contextlib import contextmanager -from distutils.version import StrictVersion +from distutils.version import StrictVersion, LooseVersion from fnmatch import fnmatch + import yaml from ansible import __version__ as ansible_version @@ -43,6 +44,7 @@ from ansible.module_utils._text import to_bytes from ansible.plugins.loader import fragment_loader from ansible.utils.collection_loader import AnsibleCollectionLoader from ansible.utils.plugin_docs import BLACKLIST, add_fragments, get_docstring +from ansible.utils.version import SemanticVersion from .module_args import AnsibleModuleImportError, AnsibleModuleNotInitialized, get_argument_spec @@ -87,6 +89,9 @@ SUBPROCESS_REGEX = re.compile(r'subprocess\.Po.*') OS_CALL_REGEX = re.compile(r'os\.call.*') +LOOSE_ANSIBLE_VERSION = LooseVersion('.'.join(ansible_version.split('.')[:3])) + + class ReporterEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Exception): @@ -238,7 +243,8 @@ class ModuleValidator(Validator): WHITELIST_FUTURE_IMPORTS = frozenset(('absolute_import', 'division', 'print_function')) - def __init__(self, path, analyze_arg_spec=False, collection=None, base_branch=None, git_cache=None, reporter=None, routing=None): + def __init__(self, path, analyze_arg_spec=False, collection=None, collection_version=None, + base_branch=None, git_cache=None, reporter=None, routing=None): super(ModuleValidator, self).__init__(reporter=reporter or Reporter()) self.path = path @@ -247,8 +253,15 @@ class ModuleValidator(Validator): self.analyze_arg_spec = analyze_arg_spec + self.Version = LooseVersion + self.collection = collection self.routing = routing + self.collection_version = None + if collection_version is not None: + self.Version = SemanticVersion + self.collection_version_str = collection_version + self.collection_version = self.Version(collection_version) self.base_branch = base_branch self.git_cache = git_cache or GitCache() @@ -1450,6 +1463,74 @@ class ModuleValidator(Validator): msg=msg, ) continue + + if not self.collection or self.collection_version is not None: + if self.collection: + compare_version = self.collection_version + version_of_what = "this collection (%s)" % self.collection_version_str + version_parser_error = "the version number is not a valid semantic version (https://semver.org/)" + code_prefix = 'collection' + else: + compare_version = LOOSE_ANSIBLE_VERSION + version_of_what = "Ansible (%s)" % ansible_version + version_parser_error = "the version number cannot be parsed" + code_prefix = 'ansible' + + removed_in_version = data.get('removed_in_version', None) + if removed_in_version is not None: + try: + if compare_version >= self.Version(str(removed_in_version)): + msg = "Argument '%s' in argument_spec" % arg + if context: + msg += " found in %s" % " -> ".join(context) + msg += " has a deprecated removed_in_version '%s'," % removed_in_version + msg += " i.e. the version is less than or equal to the current version of %s" % version_of_what + self.reporter.error( + path=self.object_path, + code=code_prefix + '-deprecated-version', + msg=msg, + ) + except ValueError: + msg = "Argument '%s' in argument_spec" % arg + if context: + msg += " found in %s" % " -> ".join(context) + msg += " has an invalid removed_in_version '%s'," % removed_in_version + msg += " i.e. %s" % version_parser_error + self.reporter.error( + path=self.object_path, + code=code_prefix + '-invalid-version', + msg=msg, + ) + + deprecated_aliases = data.get('deprecated_aliases', None) + if deprecated_aliases is not None: + for deprecated_alias in deprecated_aliases: + try: + if compare_version >= self.Version(str(deprecated_alias['version'])): + msg = "Argument '%s' in argument_spec" % arg + if context: + msg += " found in %s" % " -> ".join(context) + msg += " has deprecated aliases '%s' with removal in version '%s'," % ( + deprecated_alias['name'], deprecated_alias['version']) + msg += " i.e. the version is less than or equal to the current version of %s" % version_of_what + self.reporter.error( + path=self.object_path, + code=code_prefix + '-deprecated-version', + msg=msg, + ) + except ValueError: + msg = "Argument '%s' in argument_spec" % arg + if context: + msg += " found in %s" % " -> ".join(context) + msg += " has aliases '%s' with removal in invalid version '%s'," % ( + deprecated_alias['name'], deprecated_alias['version']) + msg += " i.e. %s" % version_parser_error + self.reporter.error( + path=self.object_path, + code=code_prefix + '-invalid-version', + msg=msg, + ) + aliases = data.get('aliases', []) if arg in aliases: msg = "Argument '%s' in argument_spec" % arg @@ -2131,6 +2212,9 @@ def run(): 'validating files within a collection. Ensure ' 'that ANSIBLE_COLLECTIONS_PATHS is set so the ' 'contents of the collection can be located') + parser.add_argument('--collection-version', + help='The collection\'s version number used to check ' + 'deprecations') args = parser.parse_args() @@ -2162,8 +2246,9 @@ def run(): continue if ModuleValidator.is_blacklisted(path): continue - with ModuleValidator(path, collection=args.collection, analyze_arg_spec=args.arg_spec, - base_branch=args.base_branch, git_cache=git_cache, reporter=reporter, routing=routing) as mv1: + with ModuleValidator(path, collection=args.collection, collection_version=args.collection_version, + analyze_arg_spec=args.arg_spec, base_branch=args.base_branch, + git_cache=git_cache, reporter=reporter, routing=routing) as mv1: mv1.validate() check_dirs.add(os.path.dirname(path)) @@ -2185,8 +2270,9 @@ def run(): continue if ModuleValidator.is_blacklisted(path): continue - with ModuleValidator(path, collection=args.collection, analyze_arg_spec=args.arg_spec, - base_branch=args.base_branch, git_cache=git_cache, reporter=reporter, routing=routing) as mv2: + with ModuleValidator(path, collection=args.collection, collection_version=args.collection_version, + analyze_arg_spec=args.arg_spec, base_branch=args.base_branch, + git_cache=git_cache, reporter=reporter, routing=routing) as mv2: mv2.validate() if not args.collection: diff --git a/test/lib/ansible_test/_internal/ansible_util.py b/test/lib/ansible_test/_internal/ansible_util.py index 22eef704cd..0fd44e5406 100644 --- a/test/lib/ansible_test/_internal/ansible_util.py +++ b/test/lib/ansible_test/_internal/ansible_util.py @@ -217,3 +217,36 @@ def check_pyyaml(args, version): display.warning('PyYAML will be slow due to installation without libyaml support for interpreter: %s' % python) return result + + +class CollectionDetail: + """Collection detail.""" + def __init__(self): # type: () -> None + self.version = None # type: t.Optional[str] + + +class CollectionDetailError(ApplicationError): + """An error occurred retrieving collection detail.""" + def __init__(self, reason): # type: (str) -> None + super(CollectionDetailError, self).__init__('Error collecting collection detail: %s' % reason) + self.reason = reason + + +def get_collection_detail(args, python): # type: (EnvironmentConfig, str) -> CollectionDetail + """Return collection detail.""" + collection = data_context().content.collection + directory = os.path.join(collection.root, collection.directory) + + stdout = run_command(args, [python, os.path.join(ANSIBLE_TEST_DATA_ROOT, 'collection_detail.py'), directory], capture=True, always=True)[0] + result = json.loads(stdout) + error = result.get('error') + + if error: + raise CollectionDetailError(error) + + version = result.get('version') + + detail = CollectionDetail() + detail.version = str(version) if version is not None else None + + return detail diff --git a/test/lib/ansible_test/_internal/cli.py b/test/lib/ansible_test/_internal/cli.py index f692f944f7..d1624f78b0 100644 --- a/test/lib/ansible_test/_internal/cli.py +++ b/test/lib/ansible_test/_internal/cli.py @@ -530,6 +530,10 @@ def parse_args(): sanity.add_argument('--base-branch', help=argparse.SUPPRESS) + sanity.add_argument('--enable-optional-errors', + action='store_true', + help='enable optional errors') + add_lint(sanity) add_extra_docker_options(sanity, integration=False) diff --git a/test/lib/ansible_test/_internal/config.py b/test/lib/ansible_test/_internal/config.py index 4b1d60c535..5c795b5b3d 100644 --- a/test/lib/ansible_test/_internal/config.py +++ b/test/lib/ansible_test/_internal/config.py @@ -241,6 +241,7 @@ class SanityConfig(TestConfig): self.skip_test = args.skip_test # type: t.List[str] self.list_tests = args.list_tests # type: bool self.allow_disabled = args.allow_disabled # type: bool + self.enable_optional_errors = args.enable_optional_errors # type: bool if args.base_branch: self.base_branch = args.base_branch # str diff --git a/test/lib/ansible_test/_internal/sanity/__init__.py b/test/lib/ansible_test/_internal/sanity/__init__.py index fce497881e..70524c640f 100644 --- a/test/lib/ansible_test/_internal/sanity/__init__.py +++ b/test/lib/ansible_test/_internal/sanity/__init__.py @@ -396,6 +396,11 @@ class SanityIgnoreParser: if len(error_codes) > 1: self.parse_errors.append((line_no, len(path) + len(test_name) + len(error_code) + 3, "Error code cannot contain multiple ':' characters")) continue + + if error_code in test.optional_error_codes: + self.parse_errors.append((line_no, len(path) + len(test_name) + 3, "Optional error code '%s' cannot be ignored" % ( + error_code))) + continue else: if error_codes: self.parse_errors.append((line_no, len(path) + len(test_name) + 2, "Sanity test '%s' does not support error codes" % test_name)) @@ -470,6 +475,9 @@ class SanityIgnoreProcessor: filtered = [] for message in messages: + if message.code in self.test.optional_error_codes and not self.args.enable_optional_errors: + continue + path_entry = self.ignore_entries.get(message.path) if path_entry: @@ -611,6 +619,12 @@ class SanityTest(ABC): self.name = name self.enabled = True + # Optional error codes represent errors which spontaneously occur without changes to the content under test, such as those based on the current date. + # Because these errors can be unpredictable they behave differently than normal error codes: + # * They are not reported by default. The `--enable-optional-errors` option must be used to display these errors. + # * They cannot be ignored. This is done to maintain the integrity of the ignore system. + self.optional_error_codes = set() + @property def error_code(self): # type: () -> t.Optional[str] """Error code for ansible-test matching the format used by the underlying test program, or None if the program does not use error codes.""" diff --git a/test/lib/ansible_test/_internal/sanity/pylint.py b/test/lib/ansible_test/_internal/sanity/pylint.py index fa8fa1ca65..3c9991ff30 100644 --- a/test/lib/ansible_test/_internal/sanity/pylint.py +++ b/test/lib/ansible_test/_internal/sanity/pylint.py @@ -35,6 +35,9 @@ from ..util_common import ( from ..ansible_util import ( ansible_environment, + get_collection_detail, + CollectionDetail, + CollectionDetailError, ) from ..config import ( @@ -138,6 +141,17 @@ class PylintTest(SanitySingleVersion): python = find_python(python_version) + collection_detail = None + + if data_context().content.collection: + try: + collection_detail = get_collection_detail(args, python) + + if not collection_detail.version: + display.warning('Skipping pylint collection version checks since no collection version was found.') + except CollectionDetailError as ex: + display.warning('Skipping pylint collection version checks since collection detail loading failed: %s' % ex.reason) + test_start = datetime.datetime.utcnow() for context, context_paths in sorted(contexts): @@ -145,7 +159,7 @@ class PylintTest(SanitySingleVersion): continue context_start = datetime.datetime.utcnow() - messages += self.pylint(args, context, context_paths, plugin_dir, plugin_names, python) + messages += self.pylint(args, context, context_paths, plugin_dir, plugin_names, python, collection_detail) context_end = datetime.datetime.utcnow() context_times.append('%s: %d (%s)' % (context, len(context_paths), context_end - context_start)) @@ -184,6 +198,7 @@ class PylintTest(SanitySingleVersion): plugin_dir, # type: str plugin_names, # type: t.List[str] python, # type: str + collection_detail, # type: CollectionDetail ): # type: (...) -> t.List[t.Dict[str, str]] """Run pylint using the config specified by the context on the specified paths.""" rcfile = os.path.join(SANITY_ROOT, 'pylint', 'config', context.split('/')[0] + '.cfg') @@ -216,6 +231,14 @@ class PylintTest(SanitySingleVersion): '--load-plugins', ','.join(load_plugins), ] + paths + if data_context().content.collection: + cmd.extend(['--is-collection', 'yes']) + + if collection_detail and collection_detail.version: + cmd.extend(['--collection-version', collection_detail.version]) + else: + cmd.extend(['--enable', 'ansible-deprecated-version']) + append_python_path = [plugin_dir] if data_context().content.collection: diff --git a/test/lib/ansible_test/_internal/sanity/validate_modules.py b/test/lib/ansible_test/_internal/sanity/validate_modules.py index 8cc65b9433..76d33bf259 100644 --- a/test/lib/ansible_test/_internal/sanity/validate_modules.py +++ b/test/lib/ansible_test/_internal/sanity/validate_modules.py @@ -31,6 +31,8 @@ from ..util_common import ( from ..ansible_util import ( ansible_environment, + get_collection_detail, + CollectionDetailError, ) from ..config import ( @@ -66,8 +68,10 @@ class ValidateModulesTest(SanitySingleVersion): paths = [target.path for target in targets.include] + python = find_python(python_version) + cmd = [ - find_python(python_version), + python, os.path.join(SANITY_ROOT, 'validate-modules', 'validate-modules'), '--format', 'json', '--arg-spec', @@ -75,6 +79,16 @@ class ValidateModulesTest(SanitySingleVersion): if data_context().content.collection: cmd.extend(['--collection', data_context().content.collection.directory]) + + try: + collection_detail = get_collection_detail(args, python) + + if collection_detail.version: + cmd.extend(['--collection-version', collection_detail.version]) + else: + display.warning('Skipping validate-modules collection version checks since no collection version was found.') + except CollectionDetailError as ex: + display.warning('Skipping validate-modules collection version checks since collection detail loading failed: %s' % ex.reason) else: if args.base_branch: cmd.extend([ diff --git a/test/sanity/ignore.txt b/test/sanity/ignore.txt index 2a637c93dc..6cf5396e87 100644 --- a/test/sanity/ignore.txt +++ b/test/sanity/ignore.txt @@ -467,6 +467,7 @@ test/units/module_utils/basic/test__symbolic_mode_to_octal.py future-import-boil test/units/module_utils/basic/test_deprecate_warn.py future-import-boilerplate test/units/module_utils/basic/test_deprecate_warn.py metaclass-boilerplate test/units/module_utils/basic/test_deprecate_warn.py pylint:ansible-deprecated-no-version +test/units/module_utils/basic/test_deprecate_warn.py pylint:ansible-deprecated-version test/units/module_utils/basic/test_exit_json.py future-import-boilerplate test/units/module_utils/basic/test_get_file_attributes.py future-import-boilerplate test/units/module_utils/basic/test_heuristic_log_sanitize.py future-import-boilerplate |