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 3 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}" 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" echo "ansible-test did not fail"
exit 1 exit 1
fi fi

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

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

@ -59,6 +59,10 @@ from .converters import (
key_value_type, key_value_type,
) )
from .epilog import (
get_epilog,
)
from ..ci import ( from ..ci import (
get_ci_provider, get_ci_provider,
) )
@ -98,6 +102,8 @@ def add_environments(
if not get_ci_provider().supports_core_ci_auth(): 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('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.formatter_class = argparse.RawDescriptionHelpFormatter
parser.epilog = '\n\n'.join(sections) 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, InstalledSource,
) )
from .provider.source.unsupported import (
UnsupportedSource,
)
from .provider.layout import ( from .provider.layout import (
ContentLayout, ContentLayout,
LayoutProvider, LayoutProvider,
) )
from .provider.layout.unsupported import (
UnsupportedLayout,
)
class DataContext: class DataContext:
"""Data context providing details about the current execution environment for ansible-test.""" """Data context providing details about the current execution environment for ansible-test."""
@ -109,13 +117,19 @@ class DataContext:
walk, # type: bool walk, # type: bool
): # type: (...) -> ContentLayout ): # type: (...) -> ContentLayout
"""Create a content layout using the given providers and root path.""" """Create a content layout using the given providers and root path."""
try:
layout_provider = find_path_provider(LayoutProvider, layout_providers, root, walk) layout_provider = find_path_provider(LayoutProvider, layout_providers, root, walk)
except ProviderNotFoundForPath:
layout_provider = UnsupportedLayout(root)
try: try:
# Begin the search for the source provider at the layout provider root. # 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. # 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. # 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. # It also provides a better user experience, since the solution for the user would effectively be the same -- to remove the nested version control.
if isinstance(layout_provider, UnsupportedLayout):
source_provider = UnsupportedSource(layout_provider.root)
else:
source_provider = find_path_provider(SourceProvider, source_providers, layout_provider.root, walk) source_provider = find_path_provider(SourceProvider, source_providers, layout_provider.root, walk)
except ProviderNotFoundForPath: except ProviderNotFoundForPath:
source_provider = UnversionedSource(layout_provider.root) source_provider = UnversionedSource(layout_provider.root)
@ -161,6 +175,42 @@ class DataContext:
"""Register the given payload callback.""" """Register the given payload callback."""
self.payload_callbacks.append(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 @cache
def data_context(): # type: () -> DataContext def data_context(): # type: () -> DataContext
@ -173,21 +223,7 @@ def data_context(): # type: () -> DataContext
for provider_type in provider_types: for provider_type in provider_types:
import_plugins('provider/%s' % provider_type) import_plugins('provider/%s' % provider_type)
try:
context = DataContext() 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()))
return context return context

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