Add a --venv option to ansible-test. (#61422)
* Add --venv delegation to ansible-test. * Update import sanity test venv creation. * Fix import test when using --venv on Python 2.x. * Improve virtualenv setup overhead. * Hide pip noise for import sanity test. * Raise verbosity on venv info messages. * Get rid of base branch noise for collections. * Add missing --requirements check.
This commit is contained in:
parent
8ebed4002f
commit
830f995ed4
9 changed files with 275 additions and 38 deletions
|
@ -643,6 +643,10 @@ def add_environments(parser, tox_version=False, tox_only=False):
|
|||
action='store_true',
|
||||
help='run from the local environment')
|
||||
|
||||
environments.add_argument('--venv',
|
||||
action='store_true',
|
||||
help='run from ansible-test managed virtual environments')
|
||||
|
||||
if data_context().content.is_ansible:
|
||||
if tox_version:
|
||||
environments.add_argument('--tox',
|
||||
|
|
|
@ -44,6 +44,7 @@ class EnvironmentConfig(CommonConfig):
|
|||
super(EnvironmentConfig, self).__init__(args, command)
|
||||
|
||||
self.local = args.local is True
|
||||
self.venv = args.venv
|
||||
|
||||
if args.tox is True or args.tox is False or args.tox is None:
|
||||
self.tox = args.tox is True
|
||||
|
@ -87,7 +88,7 @@ class EnvironmentConfig(CommonConfig):
|
|||
self.python_version = self.python or actual_major_minor
|
||||
self.python_interpreter = args.python_interpreter
|
||||
|
||||
self.delegate = self.tox or self.docker or self.remote
|
||||
self.delegate = self.tox or self.docker or self.remote or self.venv
|
||||
self.delegate_args = [] # type: t.List[str]
|
||||
|
||||
if self.delegate:
|
||||
|
|
|
@ -7,6 +7,8 @@ import re
|
|||
import sys
|
||||
import tempfile
|
||||
|
||||
from . import types as t
|
||||
|
||||
from .executor import (
|
||||
SUPPORTED_PYTHON_VERSIONS,
|
||||
HTTPTESTER_HOSTS,
|
||||
|
@ -46,6 +48,7 @@ from .util import (
|
|||
display,
|
||||
ANSIBLE_BIN_PATH,
|
||||
ANSIBLE_TEST_DATA_ROOT,
|
||||
tempdir,
|
||||
)
|
||||
|
||||
from .util_common import (
|
||||
|
@ -81,6 +84,10 @@ from .payload import (
|
|||
create_payload,
|
||||
)
|
||||
|
||||
from .venv import (
|
||||
create_virtual_environment,
|
||||
)
|
||||
|
||||
|
||||
def check_delegation_args(args):
|
||||
"""
|
||||
|
@ -124,6 +131,10 @@ def delegate_command(args, exclude, require, integration_targets):
|
|||
:type integration_targets: tuple[IntegrationTarget]
|
||||
:rtype: bool
|
||||
"""
|
||||
if args.venv:
|
||||
delegate_venv(args, exclude, require, integration_targets)
|
||||
return True
|
||||
|
||||
if args.tox:
|
||||
delegate_tox(args, exclude, require, integration_targets)
|
||||
return True
|
||||
|
@ -204,6 +215,53 @@ def delegate_tox(args, exclude, require, integration_targets):
|
|||
run_command(args, tox + cmd, env=env)
|
||||
|
||||
|
||||
def delegate_venv(args, # type: EnvironmentConfig
|
||||
exclude, # type: t.List[str]
|
||||
require, # type: t.List[str]
|
||||
integration_targets, # type: t.Tuple[IntegrationTarget, ...]
|
||||
): # type: (...) -> None
|
||||
"""Delegate ansible-test execution to a virtual environment using venv or virtualenv."""
|
||||
if args.python:
|
||||
versions = (args.python_version,)
|
||||
else:
|
||||
versions = SUPPORTED_PYTHON_VERSIONS
|
||||
|
||||
if args.httptester:
|
||||
needs_httptester = sorted(target.name for target in integration_targets if 'needs/httptester/' in target.aliases)
|
||||
|
||||
if needs_httptester:
|
||||
display.warning('Use --docker or --remote to enable httptester for tests marked "needs/httptester": %s' % ', '.join(needs_httptester))
|
||||
|
||||
venvs = dict((version, os.path.join(ResultType.TMP.path, 'delegation', 'python%s' % version)) for version in versions)
|
||||
venvs = dict((version, path) for version, path in venvs.items() if create_virtual_environment(args, version, path))
|
||||
|
||||
if not venvs:
|
||||
raise ApplicationError('No usable virtual environment support found.')
|
||||
|
||||
options = {
|
||||
'--venv': 0,
|
||||
}
|
||||
|
||||
with tempdir() as inject_path:
|
||||
for version, path in venvs.items():
|
||||
os.symlink(os.path.join(path, 'bin', 'python'), os.path.join(inject_path, 'python%s' % version))
|
||||
|
||||
python_interpreter = os.path.join(inject_path, 'python%s' % args.python_version)
|
||||
|
||||
cmd = generate_command(args, python_interpreter, ANSIBLE_BIN_PATH, data_context().content.root, options, exclude, require)
|
||||
|
||||
if isinstance(args, TestConfig):
|
||||
if args.coverage and not args.coverage_label:
|
||||
cmd += ['--coverage-label', 'venv']
|
||||
|
||||
env = common_environment()
|
||||
env.update(
|
||||
PATH=inject_path + os.pathsep + env['PATH'],
|
||||
)
|
||||
|
||||
run_command(args, cmd, env=env)
|
||||
|
||||
|
||||
def delegate_docker(args, exclude, require, integration_targets):
|
||||
"""
|
||||
:type args: EnvironmentConfig
|
||||
|
|
|
@ -63,6 +63,7 @@ from .util import (
|
|||
get_ansible_version,
|
||||
tempdir,
|
||||
open_zipfile,
|
||||
SUPPORTED_PYTHON_VERSIONS,
|
||||
)
|
||||
|
||||
from .util_common import (
|
||||
|
@ -139,19 +140,6 @@ from .data import (
|
|||
data_context,
|
||||
)
|
||||
|
||||
REMOTE_ONLY_PYTHON_VERSIONS = (
|
||||
'2.6',
|
||||
)
|
||||
|
||||
SUPPORTED_PYTHON_VERSIONS = (
|
||||
'2.6',
|
||||
'2.7',
|
||||
'3.5',
|
||||
'3.6',
|
||||
'3.7',
|
||||
'3.8',
|
||||
)
|
||||
|
||||
HTTPTESTER_HOSTS = (
|
||||
'ansible.http.tests',
|
||||
'sni1.ansible.http.tests',
|
||||
|
|
|
@ -11,6 +11,7 @@ from ..sanity import (
|
|||
SanityMessage,
|
||||
SanityFailure,
|
||||
SanitySuccess,
|
||||
SanitySkipped,
|
||||
SANITY_ROOT,
|
||||
)
|
||||
|
||||
|
@ -22,10 +23,11 @@ from ..util import (
|
|||
SubprocessError,
|
||||
remove_tree,
|
||||
display,
|
||||
find_python,
|
||||
parse_to_list_of_dict,
|
||||
is_subdir,
|
||||
ANSIBLE_LIB_ROOT,
|
||||
generate_pip_command,
|
||||
find_python,
|
||||
)
|
||||
|
||||
from ..util_common import (
|
||||
|
@ -51,6 +53,10 @@ from ..coverage_util import (
|
|||
coverage_context,
|
||||
)
|
||||
|
||||
from ..venv import (
|
||||
create_virtual_environment,
|
||||
)
|
||||
|
||||
from ..data import (
|
||||
data_context,
|
||||
)
|
||||
|
@ -70,6 +76,14 @@ class ImportTest(SanityMultipleVersion):
|
|||
:type python_version: str
|
||||
:rtype: TestResult
|
||||
"""
|
||||
capture_pip = args.verbosity < 2
|
||||
|
||||
if python_version.startswith('2.') and args.requirements:
|
||||
# hack to make sure that virtualenv is available under Python 2.x
|
||||
# on Python 3.x we can use the built-in venv
|
||||
pip = generate_pip_command(find_python(python_version))
|
||||
run_command(args, generate_pip_install(pip, 'sanity.import', packages=['virtualenv']), capture=capture_pip)
|
||||
|
||||
settings = self.load_processor(args, python_version)
|
||||
|
||||
paths = [target.path for target in targets.include]
|
||||
|
@ -84,14 +98,9 @@ class ImportTest(SanityMultipleVersion):
|
|||
|
||||
remove_tree(virtual_environment_path)
|
||||
|
||||
python = find_python(python_version)
|
||||
|
||||
cmd = [python, '-m', 'virtualenv', virtual_environment_path, '--python', python, '--no-setuptools', '--no-wheel']
|
||||
|
||||
if not args.coverage:
|
||||
cmd.append('--no-pip')
|
||||
|
||||
run_command(args, cmd, capture=True)
|
||||
if not create_virtual_environment(args, python_version, virtual_environment_path):
|
||||
display.warning("Skipping sanity test '%s' on Python %s due to missing virtual environment support." % (self.name, python_version))
|
||||
return SanitySkipped(self.name, python_version)
|
||||
|
||||
# add the importer to our virtual environment so it can be accessed through the coverage injector
|
||||
importer_path = os.path.join(virtual_environment_bin, 'importer.py')
|
||||
|
@ -132,12 +141,16 @@ class ImportTest(SanityMultipleVersion):
|
|||
SANITY_MINIMAL_DIR=os.path.relpath(virtual_environment_path, data_context().content.root) + os.path.sep,
|
||||
)
|
||||
|
||||
virtualenv_python = os.path.join(virtual_environment_bin, 'python')
|
||||
virtualenv_pip = generate_pip_command(virtualenv_python)
|
||||
|
||||
# make sure coverage is available in the virtual environment if needed
|
||||
if args.coverage:
|
||||
run_command(args, generate_pip_install(['pip'], 'sanity.import', packages=['setuptools']), env=env)
|
||||
run_command(args, generate_pip_install(['pip'], 'sanity.import', packages=['coverage']), env=env)
|
||||
run_command(args, ['pip', 'uninstall', '--disable-pip-version-check', '-y', 'setuptools'], env=env)
|
||||
run_command(args, ['pip', 'uninstall', '--disable-pip-version-check', '-y', 'pip'], env=env)
|
||||
run_command(args, generate_pip_install(virtualenv_pip, 'sanity.import', packages=['setuptools']), env=env, capture=capture_pip)
|
||||
run_command(args, generate_pip_install(virtualenv_pip, 'sanity.import', packages=['coverage']), env=env, capture=capture_pip)
|
||||
|
||||
run_command(args, virtualenv_pip + ['uninstall', '--disable-pip-version-check', '-y', 'setuptools'], env=env, capture=capture_pip)
|
||||
run_command(args, virtualenv_pip + ['uninstall', '--disable-pip-version-check', '-y', 'pip'], env=env, capture=capture_pip)
|
||||
|
||||
cmd = ['importer.py']
|
||||
|
||||
|
@ -147,8 +160,6 @@ class ImportTest(SanityMultipleVersion):
|
|||
|
||||
results = []
|
||||
|
||||
virtualenv_python = os.path.join(virtual_environment_bin, 'python')
|
||||
|
||||
try:
|
||||
with coverage_context(args):
|
||||
stdout, stderr = intercept_command(args, cmd, self.name, env, capture=True, data=data, python_version=python_version,
|
||||
|
|
|
@ -82,13 +82,13 @@ class ValidateModulesTest(SanitySingleVersion):
|
|||
|
||||
if data_context().content.collection:
|
||||
cmd.extend(['--collection', data_context().content.collection.directory])
|
||||
|
||||
if args.base_branch:
|
||||
cmd.extend([
|
||||
'--base-branch', args.base_branch,
|
||||
])
|
||||
else:
|
||||
display.warning('Cannot perform module comparison against the base branch. Base branch not detected when running locally.')
|
||||
if args.base_branch:
|
||||
cmd.extend([
|
||||
'--base-branch', args.base_branch,
|
||||
])
|
||||
else:
|
||||
display.warning('Cannot perform module comparison against the base branch. Base branch not detected when running locally.')
|
||||
|
||||
try:
|
||||
stdout, stderr = run_command(args, cmd, env=env, capture=True)
|
||||
|
|
|
@ -11,6 +11,7 @@ from ..util import (
|
|||
get_available_python_versions,
|
||||
is_subdir,
|
||||
SubprocessError,
|
||||
REMOTE_ONLY_PYTHON_VERSIONS,
|
||||
)
|
||||
|
||||
from ..util_common import (
|
||||
|
@ -45,7 +46,6 @@ from ..executor import (
|
|||
Delegate,
|
||||
get_changes_filter,
|
||||
install_command_requirements,
|
||||
REMOTE_ONLY_PYTHON_VERSIONS,
|
||||
SUPPORTED_PYTHON_VERSIONS,
|
||||
)
|
||||
|
||||
|
|
|
@ -99,6 +99,19 @@ ENCODING = 'utf-8'
|
|||
|
||||
Text = type(u'')
|
||||
|
||||
REMOTE_ONLY_PYTHON_VERSIONS = (
|
||||
'2.6',
|
||||
)
|
||||
|
||||
SUPPORTED_PYTHON_VERSIONS = (
|
||||
'2.6',
|
||||
'2.7',
|
||||
'3.5',
|
||||
'3.6',
|
||||
'3.7',
|
||||
'3.8',
|
||||
)
|
||||
|
||||
|
||||
def to_optional_bytes(value, errors='strict'): # type: (t.Optional[t.AnyStr], str) -> t.Optional[bytes]
|
||||
"""Return the given value as bytes encoded using UTF-8 if not already bytes, or None if the value is None."""
|
||||
|
@ -301,7 +314,15 @@ def get_ansible_version(): # type: () -> str
|
|||
|
||||
def get_available_python_versions(versions): # type: (t.List[str]) -> t.Dict[str, str]
|
||||
"""Return a dictionary indicating which of the requested Python versions are available."""
|
||||
return dict((version, path) for version, path in ((version, find_python(version, required=False)) for version in versions) if path)
|
||||
try:
|
||||
return get_available_python_versions.result
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
get_available_python_versions.result = dict((version, path) for version, path in
|
||||
((version, find_python(version, required=False)) for version in versions) if path)
|
||||
|
||||
return get_available_python_versions.result
|
||||
|
||||
|
||||
def generate_pip_command(python):
|
||||
|
@ -893,7 +914,7 @@ def load_module(path, name): # type: (str, str) -> None
|
|||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def tempdir():
|
||||
def tempdir(): # type: () -> str
|
||||
"""Creates a temporary directory that is deleted outside the context scope."""
|
||||
temp_path = tempfile.mkdtemp()
|
||||
yield temp_path
|
||||
|
|
154
test/lib/ansible_test/_internal/venv.py
Normal file
154
test/lib/ansible_test/_internal/venv.py
Normal file
|
@ -0,0 +1,154 @@
|
|||
"""Virtual environment management."""
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
import os
|
||||
|
||||
from . import types as t
|
||||
|
||||
from .config import (
|
||||
EnvironmentConfig,
|
||||
)
|
||||
|
||||
from .util import (
|
||||
find_python,
|
||||
SubprocessError,
|
||||
get_available_python_versions,
|
||||
SUPPORTED_PYTHON_VERSIONS,
|
||||
display,
|
||||
)
|
||||
|
||||
from .util_common import (
|
||||
run_command,
|
||||
)
|
||||
|
||||
|
||||
def create_virtual_environment(args, # type: EnvironmentConfig
|
||||
version, # type: str
|
||||
path, # type: str
|
||||
system_site_packages=False, # type: bool
|
||||
pip=True, # type: bool
|
||||
): # type: (...) -> bool
|
||||
"""Create a virtual environment using venv or virtualenv for the requested Python version."""
|
||||
if os.path.isdir(path):
|
||||
display.info('Using existing Python %s virtual environment: %s' % (version, path), verbosity=1)
|
||||
return True
|
||||
|
||||
python = find_python(version, required=False)
|
||||
python_version = tuple(int(v) for v in version.split('.'))
|
||||
|
||||
if not python:
|
||||
# the requested python version could not be found
|
||||
return False
|
||||
|
||||
if python_version >= (3, 0):
|
||||
# use the built-in 'venv' module on Python 3.x
|
||||
if run_venv(args, python, system_site_packages, pip, path):
|
||||
display.info('Created Python %s virtual environment using "venv": %s' % (version, path), verbosity=1)
|
||||
return True
|
||||
|
||||
# something went wrong, this shouldn't happen
|
||||
return False
|
||||
|
||||
# use the installed 'virtualenv' module on the Python requested version
|
||||
if run_virtualenv(args, python, python, system_site_packages, pip, path):
|
||||
display.info('Created Python %s virtual environment using "virtualenv": %s' % (version, path), verbosity=1)
|
||||
return True
|
||||
|
||||
available_pythons = get_available_python_versions(SUPPORTED_PYTHON_VERSIONS)
|
||||
|
||||
for available_python_version, available_python_interpreter in sorted(available_pythons.items()):
|
||||
virtualenv_version = get_virtualenv_version(args, available_python_interpreter)
|
||||
|
||||
if not virtualenv_version:
|
||||
# virtualenv not available for this Python or we were unable to detect the version
|
||||
continue
|
||||
|
||||
if python_version == (2, 6) and virtualenv_version >= (16, 0, 0):
|
||||
# virtualenv 16.0.0 dropped python 2.6 support: https://virtualenv.pypa.io/en/latest/changes/#v16-0-0-2018-05-16
|
||||
continue
|
||||
|
||||
# try using 'virtualenv' from another Python to setup the desired version
|
||||
if run_virtualenv(args, available_python_interpreter, python, system_site_packages, pip, path):
|
||||
display.info('Created Python %s virtual environment using "virtualenv" on Python %s: %s' % (version, available_python_version, path), verbosity=1)
|
||||
return True
|
||||
|
||||
# no suitable 'virtualenv' available
|
||||
return False
|
||||
|
||||
|
||||
def run_venv(args, # type: EnvironmentConfig
|
||||
run_python, # type: str
|
||||
system_site_packages, # type: bool
|
||||
pip, # type: bool
|
||||
path, # type: str
|
||||
): # type: (...) -> bool
|
||||
"""Create a virtual environment using the 'venv' module. Not available on Python 2.x."""
|
||||
cmd = [run_python, '-m', 'venv']
|
||||
|
||||
if system_site_packages:
|
||||
cmd.append('--system-site-packages')
|
||||
|
||||
if not pip:
|
||||
cmd.append('--without-pip')
|
||||
|
||||
cmd.append(path)
|
||||
|
||||
try:
|
||||
run_command(args, cmd, capture=True)
|
||||
except SubprocessError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def run_virtualenv(args, # type: EnvironmentConfig
|
||||
run_python, # type: str
|
||||
env_python, # type: str
|
||||
system_site_packages, # type: bool
|
||||
pip, # type: bool
|
||||
path, # type: str
|
||||
): # type: (...) -> bool
|
||||
"""Create a virtual environment using the 'virtualenv' module."""
|
||||
cmd = [run_python, '-m', 'virtualenv', '--python', env_python]
|
||||
|
||||
if system_site_packages:
|
||||
cmd.append('--system-site-packages')
|
||||
|
||||
if not pip:
|
||||
cmd.append('--no-pip')
|
||||
|
||||
cmd.append(path)
|
||||
|
||||
try:
|
||||
run_command(args, cmd, capture=True)
|
||||
except SubprocessError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_virtualenv_version(args, python): # type: (EnvironmentConfig, str) -> t.Optional[t.Tuple[int, ...]]
|
||||
"""Get the virtualenv version for the given python intepreter, if available."""
|
||||
try:
|
||||
return get_virtualenv_version.result
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
get_virtualenv_version.result = None
|
||||
|
||||
cmd = [python, '-m', 'virtualenv', '--version']
|
||||
|
||||
try:
|
||||
stdout = run_command(args, cmd, capture=True)[0]
|
||||
except SubprocessError:
|
||||
stdout = ''
|
||||
|
||||
if stdout:
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
get_virtualenv_version.result = tuple(int(v) for v in stdout.strip().split('.'))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
return get_virtualenv_version.result
|
Loading…
Reference in a new issue