Update collection loader for Python 3.10 (#76225)

* Implement find_spec and exec_module to remove reliance on deprecated methods in the collection loader

ci_complete

* Move module execution to exec_module

Remove extra sys.modules handling

Use default module initialization by returning None from loader.create_module

Refactor

ci_complete

* Remove ansible-test's copy of the collection loader

ci_complete

* Fix metaclass for Python 2.x

ci_complete

* Fix Py2/Py3 syntax compatibility

* Refactor

ci_complete

* update collection_loader comments

ci_complete

* simplify find_module

ci_complete

* Fix Py2 compatibility - don't get loader from nonexistent spec

Remove unnecessary PY3 checking

* Refactor common code in load_module and exec_module

ci_complete

* tidy diff

ci_complete

* Include collection_loader in target paths for 'compile' sanity test

* add changelog

* Add "return None" instead of doing it implicitly

Remove get_filename

short-circuit exec_module if it's a redirect

ci_complete
pull/76426/head
Sloane Hertel 3 years ago committed by GitHub
parent 1d1597ffd9
commit b50f16db91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,4 @@
bugfixes:
- >-
collection_loader - Implement 'find_spec' and 'exec_module' to override deprecated importlib methods
'find_module' and 'load_module' when applicable (https://github.com/ansible/ansible/issues/74660).

@ -1,13 +1,8 @@
# (c) 2019 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# CAUTION: There are two implementations of the collection loader.
# They must be kept functionally identical, although their implementations may differ.
#
# 1) The controller implementation resides in the "lib/ansible/utils/collection_loader/" directory.
# It must function on all Python versions supported on the controller.
# 2) The ansible-test implementation resides in the "test/lib/ansible_test/_util/target/legacy_collection_loader/" directory.
# It must function on all Python versions supported on managed hosts which are not supported by the controller.
# CAUTION: This implementation of the collection loader is used by ansible-test.
# Because of this, it must be compatible with all Python versions supported on the controller or remote.
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

@ -1,18 +1,14 @@
# (c) 2019 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# CAUTION: There are two implementations of the collection loader.
# They must be kept functionally identical, although their implementations may differ.
#
# 1) The controller implementation resides in the "lib/ansible/utils/collection_loader/" directory.
# It must function on all Python versions supported on the controller.
# 2) The ansible-test implementation resides in the "test/lib/ansible_test/_util/target/legacy_collection_loader/" directory.
# It must function on all Python versions supported on managed hosts which are not supported by the controller.
# CAUTION: This implementation of the collection loader is used by ansible-test.
# Because of this, it must be compatible with all Python versions supported on the controller or remote.
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
from ansible.module_utils.common.text.converters import to_text
from ansible.module_utils.six import add_metaclass
class _EventSource:
@ -102,5 +98,6 @@ class _AnsibleCollectionConfig(type):
# concrete class of our metaclass type that defines the class properties we want
class AnsibleCollectionConfig(metaclass=_AnsibleCollectionConfig):
@add_metaclass(_AnsibleCollectionConfig)
class AnsibleCollectionConfig(object):
pass

@ -1,13 +1,8 @@
# (c) 2019 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# CAUTION: There are two implementations of the collection loader.
# They must be kept functionally identical, although their implementations may differ.
#
# 1) The controller implementation resides in the "lib/ansible/utils/collection_loader/" directory.
# It must function on all Python versions supported on the controller.
# 2) The ansible-test implementation resides in the "test/lib/ansible_test/_util/target/legacy_collection_loader/" directory.
# It must function on all Python versions supported on managed hosts which are not supported by the controller.
# CAUTION: This implementation of the collection loader is used by ansible-test.
# Because of this, it must be compatible with all Python versions supported on the controller or remote.
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
@ -43,6 +38,11 @@ except ImportError:
# 2.7 has a global reload function instead...
reload_module = reload # pylint:disable=undefined-variable
try:
from importlib.util import spec_from_loader
except ImportError:
pass
# NB: this supports import sanity test providing a different impl
try:
from ._collection_meta import _meta_yml_to_dict
@ -184,9 +184,7 @@ class _AnsibleCollectionFinder:
return
reload_module(m)
def find_module(self, fullname, path=None):
# Figure out what's being asked for, and delegate to a special-purpose loader
def _get_loader(self, fullname, path=None):
split_name = fullname.split('.')
toplevel_pkg = split_name[0]
module_to_find = split_name[-1]
@ -207,23 +205,40 @@ class _AnsibleCollectionFinder:
if part_count > 1 and path is None:
raise ValueError('path must be specified for subpackages (trying to find {0})'.format(fullname))
if toplevel_pkg == 'ansible':
# something under the ansible package, delegate to our internal loader in case of redirections
initialize_loader = _AnsibleInternalRedirectLoader
elif part_count == 1:
initialize_loader = _AnsibleCollectionRootPkgLoader
elif part_count == 2: # ns pkg eg, ansible_collections, ansible_collections.somens
initialize_loader = _AnsibleCollectionNSPkgLoader
elif part_count == 3: # collection pkg eg, ansible_collections.somens.somecoll
initialize_loader = _AnsibleCollectionPkgLoader
else:
# anything below the collection
initialize_loader = _AnsibleCollectionLoader
# NB: actual "find"ing is delegated to the constructors on the various loaders; they'll ImportError if not found
try:
if toplevel_pkg == 'ansible':
# something under the ansible package, delegate to our internal loader in case of redirections
return _AnsibleInternalRedirectLoader(fullname=fullname, path_list=path)
if part_count == 1:
return _AnsibleCollectionRootPkgLoader(fullname=fullname, path_list=path)
if part_count == 2: # ns pkg eg, ansible_collections, ansible_collections.somens
return _AnsibleCollectionNSPkgLoader(fullname=fullname, path_list=path)
elif part_count == 3: # collection pkg eg, ansible_collections.somens.somecoll
return _AnsibleCollectionPkgLoader(fullname=fullname, path_list=path)
# anything below the collection
return _AnsibleCollectionLoader(fullname=fullname, path_list=path)
return initialize_loader(fullname=fullname, path_list=path)
except ImportError:
# TODO: log attempt to load context
return None
def find_module(self, fullname, path=None):
# Figure out what's being asked for, and delegate to a special-purpose loader
return self._get_loader(fullname, path)
def find_spec(self, fullname, path, target=None):
loader = self._get_loader(fullname, path)
if loader:
spec = spec_from_loader(fullname, loader)
if spec is not None and hasattr(loader, '_subpackage_search_paths'):
spec.submodule_search_locations = loader._subpackage_search_paths
return spec
else:
return None
# Implements a path_hook finder for iter_modules (since it's only path based). This finder does not need to actually
# function as a finder in most cases, since our meta_path finder is consulted first for *almost* everything, except
@ -251,14 +266,13 @@ class _AnsiblePathHookFinder:
_filefinder_path_hook = _get_filefinder_path_hook()
def find_module(self, fullname, path=None):
# we ignore the passed in path here- use what we got from the path hook init
def _get_finder(self, fullname):
split_name = fullname.split('.')
toplevel_pkg = split_name[0]
if toplevel_pkg == 'ansible_collections':
# collections content? delegate to the collection finder
return self._collection_finder.find_module(fullname, path=[self._pathctx])
return self._collection_finder
else:
# Something else; we'd normally restrict this to `ansible` descendent modules so that any weird loader
# behavior that arbitrary Python modules have can be serviced by those loaders. In some dev/test
@ -277,13 +291,31 @@ class _AnsiblePathHookFinder:
# might not be in some other situation...
return None
spec = self._file_finder.find_spec(fullname)
if not spec:
return None
return spec.loader
return self._file_finder
# call py2's internal loader
return pkgutil.ImpImporter(self._pathctx)
def find_module(self, fullname, path=None):
# we ignore the passed in path here- use what we got from the path hook init
finder = self._get_finder(fullname)
if finder is not None:
return finder.find_module(fullname, path=[self._pathctx])
else:
return None
def find_spec(self, fullname, target=None):
split_name = fullname.split('.')
toplevel_pkg = split_name[0]
finder = self._get_finder(fullname)
if finder is not None:
if toplevel_pkg == 'ansible_collections':
return finder.find_spec(fullname, path=[self._pathctx])
else:
# call py2's internal loader
return pkgutil.ImpImporter(self._pathctx).find_module(fullname)
return finder.find_spec(fullname)
else:
return None
def iter_modules(self, prefix):
# NB: this currently represents only what's on disk, and does not handle package redirection
@ -377,6 +409,23 @@ class _AnsibleCollectionPkgLoaderBase:
return module_path, has_code, package_path
def exec_module(self, module):
# short-circuit redirect; avoid reinitializing existing modules
if self._redirect_module:
return
# execute the module's code in its namespace
code_obj = self.get_code(self._fullname)
if code_obj is not None: # things like NS packages that can't have code on disk will return None
exec(code_obj, module.__dict__)
def create_module(self, spec):
# short-circuit redirect; we've already imported the redirected module, so just alias it and return it
if self._redirect_module:
return self._redirect_module
else:
return None
def load_module(self, fullname):
# short-circuit redirect; we've already imported the redirected module, so just alias it and return it
if self._redirect_module:
@ -531,12 +580,10 @@ class _AnsibleCollectionPkgLoader(_AnsibleCollectionPkgLoaderBase):
# only search within the first collection we found
self._subpackage_search_paths = [self._subpackage_search_paths[0]]
def load_module(self, fullname):
def _load_module(self, module):
if not _meta_yml_to_dict:
raise ValueError('ansible.utils.collection_loader._meta_yml_to_dict is not set')
module = super(_AnsibleCollectionPkgLoader, self).load_module(fullname)
module._collection_meta = {}
# TODO: load collection metadata, cache in __loader__ state
@ -566,6 +613,17 @@ class _AnsibleCollectionPkgLoader(_AnsibleCollectionPkgLoaderBase):
return module
def exec_module(self, module):
super(_AnsibleCollectionPkgLoader, self).exec_module(module)
self._load_module(module)
def create_module(self, spec):
return None
def load_module(self, fullname):
module = super(_AnsibleCollectionPkgLoader, self).load_module(fullname)
return self._load_module(module)
def _canonicalize_meta(self, meta_dict):
# TODO: rewrite import keys and all redirect targets that start with .. (current namespace) and . (current collection)
# OR we could do it all on the fly?
@ -676,6 +734,17 @@ class _AnsibleInternalRedirectLoader:
if not self._redirect:
raise ImportError('not redirected, go ask path_hook')
def exec_module(self, module):
# should never see this
if not self._redirect:
raise ValueError('no redirect found for {0}'.format(module.__spec__.name))
# Replace the module with the redirect
sys.modules[module.__spec__.name] = import_module(self._redirect)
def create_module(self, spec):
return None
def load_module(self, fullname):
# since we're delegating to other loaders, this should only be called for internal redirects where we answered
# find_module with this loader, in which case we'll just directly import the redirection target, insert it into

@ -1,13 +1,8 @@
# (c) 2019 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# CAUTION: There are two implementations of the collection loader.
# They must be kept functionally identical, although their implementations may differ.
#
# 1) The controller implementation resides in the "lib/ansible/utils/collection_loader/" directory.
# It must function on all Python versions supported on the controller.
# 2) The ansible-test implementation resides in the "test/lib/ansible_test/_util/target/legacy_collection_loader/" directory.
# It must function on all Python versions supported on managed hosts which are not supported by the controller.
# CAUTION: This implementation of the collection loader is used by ansible-test.
# Because of this, it must be compatible with all Python versions supported on the controller or remote.
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

@ -260,7 +260,6 @@ class ModuleUtilFinder(ast.NodeVisitor):
('^hacking/build_library/build_ansible/', 'build_ansible/'),
('^lib/ansible/', 'ansible/'),
('^test/lib/ansible_test/_util/controller/sanity/validate-modules/', 'validate_modules/'),
('^test/lib/ansible_test/_util/target/legacy_collection_loader/', 'legacy_collection_loader/'),
('^test/units/', 'test/units/'),
('^test/lib/ansible_test/_internal/', 'ansible_test/_internal/'),
('^test/integration/targets/.*/ansible_collections/(?P<ns>[^/]*)/(?P<col>[^/]*)/', r'ansible_collections/\g<ns>/\g<col>/'),

@ -785,7 +785,9 @@ class SanityTest(metaclass=abc.ABCMeta):
# utility code that runs in target environments and requires support for remote-only Python versions
is_subdir(target.path, 'test/lib/ansible_test/_util/target/') or
# integration test support modules/module_utils continue to require support for remote-only Python versions
re.search('^test/support/integration/.*/(modules|module_utils)/', target.path)
re.search('^test/support/integration/.*/(modules|module_utils)/', target.path) or
# collection loader requires support for remote-only Python versions
re.search('^lib/ansible/utils/collection_loader/', target.path)
))
)]

@ -21,10 +21,6 @@ from ...constants import (
REMOTE_ONLY_PYTHON_VERSIONS,
)
from ...io import (
write_text_file,
)
from ...test import (
TestResult,
)
@ -40,7 +36,6 @@ from ...util import (
parse_to_list_of_dict,
is_subdir,
ANSIBLE_TEST_TOOLS_ROOT,
ANSIBLE_TEST_TARGET_ROOT,
)
from ...util_common import (
@ -214,11 +209,4 @@ def get_ansible_test_python_path(): # type: () -> str
The temporary directory created will be cached for the lifetime of the process and cleaned up at exit.
"""
python_path = create_temp_dir(prefix='ansible-test-')
ansible_test_path = os.path.join(python_path, 'ansible_test')
# legacy collection loader required by all python versions not supported by the controller
write_text_file(os.path.join(ansible_test_path, '__init__.py'), '', True)
write_text_file(os.path.join(ansible_test_path, '_internal', '__init__.py'), '', True)
os.symlink(os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'legacy_collection_loader'), os.path.join(ansible_test_path, '_internal', 'legacy_collection_loader'))
return python_path

@ -128,7 +128,6 @@ class PylintTest(SanitySingleVersion):
add_context(remaining_paths, 'validate-modules', filter_path('test/lib/ansible_test/_util/controller/sanity/validate-modules/'))
add_context(remaining_paths, 'validate-modules-unit', filter_path('test/lib/ansible_test/tests/validate-modules-unit/'))
add_context(remaining_paths, 'code-smell', filter_path('test/lib/ansible_test/_util/controller/sanity/code-smell/'))
add_context(remaining_paths, 'legacy-collection-loader', filter_path('test/lib/ansible_test/_util/target/legacy_collection_loader/'))
add_context(remaining_paths, 'ansible-test-target', filter_path('test/lib/ansible_test/_util/target/'))
add_context(remaining_paths, 'ansible-test', filter_path('test/lib/'))
add_context(remaining_paths, 'test', filter_path('test/'))

@ -318,7 +318,6 @@ def get_units_ansible_python_path(args, test_context): # type: (UnitsConfig, st
# legacy collection loader required by all python versions not supported by the controller
write_text_file(os.path.join(ansible_test_path, '__init__.py'), '', True)
write_text_file(os.path.join(ansible_test_path, '_internal', '__init__.py'), '', True)
os.symlink(os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'legacy_collection_loader'), os.path.join(ansible_test_path, '_internal', 'legacy_collection_loader'))
elif test_context == TestContext.modules:
# only non-collection ansible module tests should have access to ansible built-in modules
os.symlink(os.path.join(ANSIBLE_LIB_ROOT, 'modules'), os.path.join(ansible_path, 'modules'))

@ -1,31 +0,0 @@
# (c) 2019 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# CAUTION: There are two implementations of the collection loader.
# They must be kept functionally identical, although their implementations may differ.
#
# 1) The controller implementation resides in the "lib/ansible/utils/collection_loader/" directory.
# It must function on all Python versions supported on the controller.
# 2) The ansible-test implementation resides in the "test/lib/ansible_test/_util/target/legacy_collection_loader/" directory.
# It must function on all Python versions supported on managed hosts which are not supported by the controller.
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
# FIXME: decide what of this we want to actually be public/toplevel, put other stuff on a utility class?
from ._collection_config import AnsibleCollectionConfig
from ._collection_finder import AnsibleCollectionRef
from ansible.module_utils.common.text.converters import to_text
def resource_from_fqcr(ref):
"""
Return resource from a fully-qualified collection reference,
or from a simple resource name.
For fully-qualified collection references, this is equivalent to
``AnsibleCollectionRef.from_fqcr(ref).resource``.
:param ref: collection reference to parse
:return: the resource as a unicode string
"""
ref = to_text(ref, errors='strict')
return ref.split(u'.')[-1]

@ -1,107 +0,0 @@
# (c) 2019 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# CAUTION: There are two implementations of the collection loader.
# They must be kept functionally identical, although their implementations may differ.
#
# 1) The controller implementation resides in the "lib/ansible/utils/collection_loader/" directory.
# It must function on all Python versions supported on the controller.
# 2) The ansible-test implementation resides in the "test/lib/ansible_test/_util/target/legacy_collection_loader/" directory.
# It must function on all Python versions supported on managed hosts which are not supported by the controller.
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
from ansible.module_utils.common.text.converters import to_text
from ansible.module_utils.six import with_metaclass
class _EventSource:
def __init__(self):
self._handlers = set()
def __iadd__(self, handler):
if not callable(handler):
raise ValueError('handler must be callable')
self._handlers.add(handler)
return self
def __isub__(self, handler):
try:
self._handlers.remove(handler)
except KeyError:
pass
return self
def _on_exception(self, handler, exc, *args, **kwargs):
# if we return True, we want the caller to re-raise
return True
def fire(self, *args, **kwargs):
for h in self._handlers:
try:
h(*args, **kwargs)
except Exception as ex:
if self._on_exception(h, ex, *args, **kwargs):
raise
class _AnsibleCollectionConfig(type):
def __init__(cls, meta, name, bases):
cls._collection_finder = None
cls._default_collection = None
cls._on_collection_load = _EventSource()
@property
def collection_finder(cls):
return cls._collection_finder
@collection_finder.setter
def collection_finder(cls, value):
if cls._collection_finder:
raise ValueError('an AnsibleCollectionFinder has already been configured')
cls._collection_finder = value
@property
def collection_paths(cls):
cls._require_finder()
return [to_text(p) for p in cls._collection_finder._n_collection_paths]
@property
def default_collection(cls):
return cls._default_collection
@default_collection.setter
def default_collection(cls, value):
cls._default_collection = value
@property
def on_collection_load(cls):
return cls._on_collection_load
@on_collection_load.setter
def on_collection_load(cls, value):
if value is not cls._on_collection_load:
raise ValueError('on_collection_load is not directly settable (use +=)')
@property
def playbook_paths(cls):
cls._require_finder()
return [to_text(p) for p in cls._collection_finder._n_playbook_paths]
@playbook_paths.setter
def playbook_paths(cls, value):
cls._require_finder()
cls._collection_finder.set_playbook_paths(value)
def _require_finder(cls):
if not cls._collection_finder:
raise NotImplementedError('an AnsibleCollectionFinder has not been installed in this process')
# concrete class of our metaclass type that defines the class properties we want
class AnsibleCollectionConfig(with_metaclass(_AnsibleCollectionConfig)):
pass

@ -1,37 +0,0 @@
# (c) 2019 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# CAUTION: There are two implementations of the collection loader.
# They must be kept functionally identical, although their implementations may differ.
#
# 1) The controller implementation resides in the "lib/ansible/utils/collection_loader/" directory.
# It must function on all Python versions supported on the controller.
# 2) The ansible-test implementation resides in the "test/lib/ansible_test/_util/target/legacy_collection_loader/" directory.
# It must function on all Python versions supported on managed hosts which are not supported by the controller.
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
try:
from collections.abc import Mapping # pylint: disable=ansible-bad-import-from
except ImportError:
from collections import Mapping # pylint: disable=ansible-bad-import-from,deprecated-class
from ansible.module_utils.common.yaml import yaml_load
def _meta_yml_to_dict(yaml_string_data, content_id):
"""
Converts string YAML dictionary to a Python dictionary. This function may be monkeypatched to another implementation
by some tools (eg the import sanity test).
:param yaml_string_data: a bytes-ish YAML dictionary
:param content_id: a unique ID representing the content to allow other implementations to cache the output
:return: a Python dictionary representing the YAML dictionary content
"""
# NB: content_id is passed in, but not used by this implementation
routing_dict = yaml_load(yaml_string_data)
if not routing_dict:
routing_dict = {}
if not isinstance(routing_dict, Mapping):
raise ValueError('collection metadata must be an instance of Python Mapping')
return routing_dict

@ -3,7 +3,6 @@ from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import os
import sys
# set by ansible-test to a single directory, rather than a list of directories as supported by Ansible itself
ANSIBLE_COLLECTIONS_PATH = os.path.join(os.environ['ANSIBLE_COLLECTIONS_PATH'], 'ansible_collections')
@ -41,12 +40,8 @@ def pytest_configure():
except AttributeError:
pytest_configure.executed = True
if sys.version_info >= ANSIBLE_CONTROLLER_MIN_PYTHON_VERSION:
# noinspection PyProtectedMember
from ansible.utils.collection_loader._collection_finder import _AnsibleCollectionFinder
else:
# noinspection PyProtectedMember
from ansible_test._internal.legacy_collection_loader._collection_finder import _AnsibleCollectionFinder
# noinspection PyProtectedMember
from ansible.utils.collection_loader._collection_finder import _AnsibleCollectionFinder
# allow unit tests to import code from collections

@ -28,7 +28,6 @@ def main():
collection_full_name = os.environ.get('SANITY_COLLECTION_FULL_NAME')
collection_root = os.environ.get('ANSIBLE_COLLECTIONS_PATH')
import_type = os.environ.get('SANITY_IMPORTER_TYPE')
ansible_controller_min_python_version = tuple(int(x) for x in os.environ.get('ANSIBLE_CONTROLLER_MIN_PYTHON_VERSION', '0').split('.'))
try:
# noinspection PyCompatibility
@ -48,15 +47,9 @@ def main():
# allow importing code from collections when testing a collection
from ansible.module_utils.common.text.converters import to_bytes, to_text, to_native, text_type
if sys.version_info >= ansible_controller_min_python_version:
# noinspection PyProtectedMember
from ansible.utils.collection_loader._collection_finder import _AnsibleCollectionFinder
from ansible.utils.collection_loader import _collection_finder
else:
# noinspection PyProtectedMember
from ansible_test._internal.legacy_collection_loader._collection_finder import _AnsibleCollectionFinder
# noinspection PyProtectedMember
from ansible_test._internal.legacy_collection_loader import _collection_finder
# noinspection PyProtectedMember
from ansible.utils.collection_loader._collection_finder import _AnsibleCollectionFinder
from ansible.utils.collection_loader import _collection_finder
yaml_to_dict_cache = {}

Loading…
Cancel
Save