ansible-test - Improve help for unsupported cwd. (#76866)

* ansible-test - Improve help for unsupported cwd.

* The `--help` option is now available when an unsupported cwd is in use.
* The `--help` output now shows the same instructions about cwd as would be shown in error messages if the cwd is unsupported.
* Add `--version` support to show the ansible-core version.
* The explanation about cwd usage has been improved to explain more clearly what is required.

Resolves https://github.com/ansible/ansible/issues/64523
Resolves https://github.com/ansible/ansible/issues/67551
pull/76869/head
Matt Clay 2 years ago committed by GitHub
parent 07bcd13e6f
commit de5f60e374
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,5 @@
minor_changes:
- ansible-test - The ``--help`` option is now available when an unsupported cwd is in use.
- ansible-test - The ``--help`` output now shows the same instructions about cwd as would be shown in error messages if the cwd is unsupported.
- ansible-test - Add ``--version`` support to show the ansible-core version.
- ansible-test - The explanation about cwd usage has been improved to explain more clearly what is required.

@ -4,7 +4,14 @@ set -eux -o pipefail
cd "${WORK_DIR}"
if ansible-test --help 1>stdout 2>stderr; then
# some options should succeed even in an unsupported directory
ansible-test --help
ansible-test --version
# the --help option should show the current working directory when it is unsupported
ansible-test --help 2>&1 | grep '^Current working directory: '
if ansible-test sanity 1>stdout 2>stderr; then
echo "ansible-test did not fail"
exit 1
fi

@ -68,6 +68,7 @@ def main(cli_args=None): # type: (t.Optional[t.List[str]]) -> None
target_names = None
try:
data_context().check_layout()
args.func(config)
except PrimeContainers:
pass

@ -14,23 +14,26 @@ from .commands import (
do_commands,
)
from .epilog import (
get_epilog,
)
from .compat import (
HostSettings,
convert_legacy_args,
)
from ..util import (
get_ansible_version,
)
def parse_args(argv=None): # type: (t.Optional[t.List[str]]) -> argparse.Namespace
"""Parse command line arguments."""
completer = CompositeActionCompletionFinder()
if completer.enabled:
epilog = 'Tab completion available using the "argcomplete" python package.'
else:
epilog = 'Install the "argcomplete" python package to enable tab completion.'
parser = argparse.ArgumentParser(prog='ansible-test', epilog=epilog)
parser = argparse.ArgumentParser(prog='ansible-test', epilog=get_epilog(completer), formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--version', action='version', version=f'%(prog)s version {get_ansible_version()}')
do_commands(parser, completer)

@ -59,6 +59,10 @@ from .converters import (
key_value_type,
)
from .epilog import (
get_epilog,
)
from ..ci import (
get_ci_provider,
)
@ -98,6 +102,8 @@ def add_environments(
if not get_ci_provider().supports_core_ci_auth():
sections.append('Remote provisioning options have been hidden since no Ansible Core CI API key was found.')
sections.append(get_epilog(completer))
parser.formatter_class = argparse.RawDescriptionHelpFormatter
parser.epilog = '\n\n'.join(sections)

@ -0,0 +1,23 @@
"""Argument parsing epilog generation."""
from __future__ import annotations
from .argparsing import (
CompositeActionCompletionFinder,
)
from ..data import (
data_context,
)
def get_epilog(completer: CompositeActionCompletionFinder) -> str:
"""Generate and return the epilog to use for help output."""
if completer.enabled:
epilog = 'Tab completion available using the "argcomplete" python package.'
else:
epilog = 'Install the "argcomplete" python package to enable tab completion.'
if data_context().content.unsupported:
epilog += '\n\n' + data_context().explain_working_directory()
return epilog

@ -34,11 +34,19 @@ from .provider.source.installed import (
InstalledSource,
)
from .provider.source.unsupported import (
UnsupportedSource,
)
from .provider.layout import (
ContentLayout,
LayoutProvider,
)
from .provider.layout.unsupported import (
UnsupportedLayout,
)
class DataContext:
"""Data context providing details about the current execution environment for ansible-test."""
@ -109,14 +117,20 @@ class DataContext:
walk, # type: bool
): # type: (...) -> ContentLayout
"""Create a content layout using the given providers and root path."""
layout_provider = find_path_provider(LayoutProvider, layout_providers, root, walk)
try:
layout_provider = find_path_provider(LayoutProvider, layout_providers, root, walk)
except ProviderNotFoundForPath:
layout_provider = UnsupportedLayout(root)
try:
# Begin the search for the source provider at the layout provider root.
# This intentionally ignores version control within subdirectories of the layout root, a condition which was previously an error.
# Doing so allows support for older git versions for which it is difficult to distinguish between a super project and a sub project.
# It also provides a better user experience, since the solution for the user would effectively be the same -- to remove the nested version control.
source_provider = find_path_provider(SourceProvider, source_providers, layout_provider.root, walk)
if isinstance(layout_provider, UnsupportedLayout):
source_provider = UnsupportedSource(layout_provider.root)
else:
source_provider = find_path_provider(SourceProvider, source_providers, layout_provider.root, walk)
except ProviderNotFoundForPath:
source_provider = UnversionedSource(layout_provider.root)
@ -161,6 +175,42 @@ class DataContext:
"""Register the given payload callback."""
self.payload_callbacks.append(callback)
def check_layout(self) -> None:
"""Report an error if the layout is unsupported."""
if self.content.unsupported:
raise ApplicationError(self.explain_working_directory())
@staticmethod
def explain_working_directory() -> str:
"""Return a message explaining the working directory requirements."""
blocks = [
'The current working directory must be within the source tree being tested.',
'',
]
if ANSIBLE_SOURCE_ROOT:
blocks.append(f'Testing Ansible: {ANSIBLE_SOURCE_ROOT}/')
blocks.append('')
cwd = os.getcwd()
blocks.append('Testing an Ansible collection: {...}/ansible_collections/{namespace}/{collection}/')
blocks.append('Example #1: community.general -> ~/code/ansible_collections/community/general/')
blocks.append('Example #2: ansible.util -> ~/.ansible/collections/ansible_collections/ansible/util/')
blocks.append('')
blocks.append(f'Current working directory: {cwd}/')
if os.path.basename(os.path.dirname(cwd)) == 'ansible_collections':
blocks.append(f'Expected parent directory: {os.path.dirname(cwd)}/{{namespace}}/{{collection}}/')
elif os.path.basename(cwd) == 'ansible_collections':
blocks.append(f'Expected parent directory: {cwd}/{{namespace}}/{{collection}}/')
else:
blocks.append('No "ansible_collections" parent directory was found.')
message = '\n'.join(blocks)
return message
@cache
def data_context(): # type: () -> DataContext
@ -173,21 +223,7 @@ def data_context(): # type: () -> DataContext
for provider_type in provider_types:
import_plugins('provider/%s' % provider_type)
try:
context = DataContext()
except ProviderNotFoundForPath:
options = [
' - an Ansible collection: {...}/ansible_collections/{namespace}/{collection}/',
]
if ANSIBLE_SOURCE_ROOT:
options.insert(0, ' - the Ansible source: %s/' % ANSIBLE_SOURCE_ROOT)
raise ApplicationError('''The current working directory must be at or below:
%s
Current working directory: %s''' % ('\n'.join(options), os.getcwd()))
context = DataContext()
return context

@ -91,6 +91,7 @@ class ContentLayout(Layout):
unit_module_path, # type: str
unit_module_utils_path, # type: str
unit_messages, # type: t.Optional[LayoutMessages]
unsupported=False, # type: bool
): # type: (...) -> None
super().__init__(root, paths)
@ -108,6 +109,7 @@ class ContentLayout(Layout):
self.unit_module_path = unit_module_path
self.unit_module_utils_path = unit_module_utils_path
self.unit_messages = unit_messages
self.unsupported = unsupported
self.is_ansible = root == ANSIBLE_SOURCE_ROOT

@ -0,0 +1,42 @@
"""Layout provider for an unsupported directory layout."""
from __future__ import annotations
import typing as t
from . import (
ContentLayout,
LayoutProvider,
)
class UnsupportedLayout(LayoutProvider):
"""Layout provider for an unsupported directory layout."""
sequence = 0 # disable automatic detection
@staticmethod
def is_content_root(path): # type: (str) -> bool
"""Return True if the given path is a content root for this provider."""
return False
def create(self, root, paths): # type: (str, t.List[str]) -> ContentLayout
"""Create a Layout using the given root and paths."""
plugin_paths = dict((p, p) for p in self.PLUGIN_TYPES)
return ContentLayout(root,
paths,
plugin_paths=plugin_paths,
collection=None,
test_path='',
results_path='',
sanity_path='',
sanity_messages=None,
integration_path='',
integration_targets_path='',
integration_vars_path='',
integration_messages=None,
unit_path='',
unit_module_path='',
unit_module_utils_path='',
unit_messages=None,
unsupported=True,
)

@ -0,0 +1,22 @@
"""Source provider to use when the layout is unsupported."""
from __future__ import annotations
import typing as t
from . import (
SourceProvider,
)
class UnsupportedSource(SourceProvider):
"""Source provider to use when the layout is unsupported."""
sequence = 0 # disable automatic detection
@staticmethod
def is_content_root(path): # type: (str) -> bool
"""Return True if the given path is a content root for this provider."""
return False
def get_paths(self, path): # type: (str) -> t.List[str]
"""Return the list of available content paths under the given path."""
return []
Loading…
Cancel
Save