Skip to content

Commit

Permalink
ansible-test - Improve help for unsupported cwd. (#76866)
Browse files Browse the repository at this point in the history
* 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 #64523
Resolves #67551
  • Loading branch information
mattclay committed Jan 27, 2022
1 parent 07bcd13 commit de5f60e
Show file tree
Hide file tree
Showing 10 changed files with 171 additions and 24 deletions.
5 changes: 5 additions & 0 deletions changelogs/fragments/ansible-test-help-cwd.yml
@@ -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.
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/lib/ansible_test/_internal/__init__.py
Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions test/lib/ansible_test/_internal/cli/__init__.py
Expand Up @@ -14,23 +14,26 @@
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)

Expand Down
6 changes: 6 additions & 0 deletions test/lib/ansible_test/_internal/cli/environments.py
Expand Up @@ -59,6 +59,10 @@
key_value_type,
)

from .epilog import (
get_epilog,
)

from ..ci import (
get_ci_provider,
)
Expand Down Expand Up @@ -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)

Expand Down
23 changes: 23 additions & 0 deletions test/lib/ansible_test/_internal/cli/epilog.py
@@ -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
70 changes: 53 additions & 17 deletions test/lib/ansible_test/_internal/data.py
Expand Up @@ -34,11 +34,19 @@
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."""
Expand Down Expand Up @@ -109,14 +117,20 @@ def __create_content_layout(layout_providers, # type: t.List[t.Type[LayoutProvi
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)

Expand Down Expand Up @@ -161,6 +175,42 @@ def register_payload_callback(self, callback): # type: (t.Callable[[t.List[t.Tu
"""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
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions test/lib/ansible_test/_internal/provider/layout/__init__.py
Expand Up @@ -91,6 +91,7 @@ def __init__(self,
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)

Expand All @@ -108,6 +109,7 @@ def __init__(self,
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

Expand Down
42 changes: 42 additions & 0 deletions test/lib/ansible_test/_internal/provider/layout/unsupported.py
@@ -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,
)
22 changes: 22 additions & 0 deletions test/lib/ansible_test/_internal/provider/source/unsupported.py
@@ -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 []

0 comments on commit de5f60e

Please sign in to comment.