diff --git a/changelogs/fragments/ansible-test-help-cwd.yml b/changelogs/fragments/ansible-test-help-cwd.yml new file mode 100644 index 00000000000..ea2c19ce41c --- /dev/null +++ b/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. diff --git a/test/integration/targets/ansible-test/collection-tests/unsupported-directory.sh b/test/integration/targets/ansible-test/collection-tests/unsupported-directory.sh index 713bd5d637b..b1b9508a75f 100755 --- a/test/integration/targets/ansible-test/collection-tests/unsupported-directory.sh +++ b/test/integration/targets/ansible-test/collection-tests/unsupported-directory.sh @@ -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 diff --git a/test/lib/ansible_test/_internal/__init__.py b/test/lib/ansible_test/_internal/__init__.py index bb60eca4e09..3400d163571 100644 --- a/test/lib/ansible_test/_internal/__init__.py +++ b/test/lib/ansible_test/_internal/__init__.py @@ -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 diff --git a/test/lib/ansible_test/_internal/cli/__init__.py b/test/lib/ansible_test/_internal/cli/__init__.py index f1a81471679..64280e820bc 100644 --- a/test/lib/ansible_test/_internal/cli/__init__.py +++ b/test/lib/ansible_test/_internal/cli/__init__.py @@ -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) diff --git a/test/lib/ansible_test/_internal/cli/environments.py b/test/lib/ansible_test/_internal/cli/environments.py index 58ce8a42cd8..1b815c45871 100644 --- a/test/lib/ansible_test/_internal/cli/environments.py +++ b/test/lib/ansible_test/_internal/cli/environments.py @@ -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) diff --git a/test/lib/ansible_test/_internal/cli/epilog.py b/test/lib/ansible_test/_internal/cli/epilog.py new file mode 100644 index 00000000000..3800ff1c0c0 --- /dev/null +++ b/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 diff --git a/test/lib/ansible_test/_internal/data.py b/test/lib/ansible_test/_internal/data.py index c3b2187ca24..a9b0806ed74 100644 --- a/test/lib/ansible_test/_internal/data.py +++ b/test/lib/ansible_test/_internal/data.py @@ -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 diff --git a/test/lib/ansible_test/_internal/provider/layout/__init__.py b/test/lib/ansible_test/_internal/provider/layout/__init__.py index 147fcbd56fe..594df42261e 100644 --- a/test/lib/ansible_test/_internal/provider/layout/__init__.py +++ b/test/lib/ansible_test/_internal/provider/layout/__init__.py @@ -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 diff --git a/test/lib/ansible_test/_internal/provider/layout/unsupported.py b/test/lib/ansible_test/_internal/provider/layout/unsupported.py new file mode 100644 index 00000000000..80a9129198b --- /dev/null +++ b/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, + ) diff --git a/test/lib/ansible_test/_internal/provider/source/unsupported.py b/test/lib/ansible_test/_internal/provider/source/unsupported.py new file mode 100644 index 00000000000..ff5562c62c6 --- /dev/null +++ b/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 []