From c15ad61d4894479d0b3a47ccde363820ba051521 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Wed, 21 Aug 2024 09:32:41 +0200 Subject: [PATCH] Make CLI startup faster, support tab completion with argcomplete (#173) * Import ansible only when needed. * Import rstcheck only when needed. * Add argcomplete marker so that completion actually works. * Add changelog fragment. * Fix tests. * Improve changelog. * Improve changelog fragment. Co-authored-by: Maxwell G * Improve code. Co-authored-by: Maxwell G * Remove no longer needed suppression. * Fix test. --------- Co-authored-by: Maxwell G --- changelogs/fragments/173-argcomplete.yml | 5 + src/antsibull_changelog/ansible.py | 91 ++- src/antsibull_changelog/cli.py | 2 + src/antsibull_changelog/rstcheck.py | 27 +- .../test_changelog_basic_collection.py | 585 ++++++++++-------- 5 files changed, 380 insertions(+), 330 deletions(-) create mode 100644 changelogs/fragments/173-argcomplete.yml diff --git a/changelogs/fragments/173-argcomplete.yml b/changelogs/fragments/173-argcomplete.yml new file mode 100644 index 00000000..4bfbbafa --- /dev/null +++ b/changelogs/fragments/173-argcomplete.yml @@ -0,0 +1,5 @@ +minor_changes: + - "If you are using `argcomplete `__ global completion, you can now tab-complete ``antsibull-changelog`` command lines. + See `Activating global completion `__ in the argcomplete README for + how to enable tab completion globally. This will also tab-complete Ansible commands such as ``ansible-playbook`` and ``ansible-test`` + (https://github.com/ansible-community/antsibull-changelog/pull/173)." diff --git a/src/antsibull_changelog/ansible.py b/src/antsibull_changelog/ansible.py index 59210d33..ee9c8ea6 100644 --- a/src/antsibull_changelog/ansible.py +++ b/src/antsibull_changelog/ansible.py @@ -10,28 +10,10 @@ from __future__ import annotations -from typing import Any +from functools import cache import packaging.version -try: - from ansible import constants as C - - HAS_ANSIBLE_CONSTANTS = True -except ImportError: - HAS_ANSIBLE_CONSTANTS = False - - -ansible_release: Any -try: - from ansible import release as ansible_release - - HAS_ANSIBLE_RELEASE = True -except ImportError: - ansible_release = None - HAS_ANSIBLE_RELEASE = False - - OBJECT_TYPES = ("role", "playbook") OTHER_PLUGIN_TYPES = ("module", "test", "filter") @@ -41,48 +23,65 @@ PLUGIN_EXCEPTIONS = (("cache", "base.py"), ("module", "async_wrapper.py")) +@cache def get_documentable_plugins() -> tuple[str, ...]: """ Retrieve plugin types that can be documented. """ - if HAS_ANSIBLE_CONSTANTS: - return C.DOCUMENTABLE_PLUGINS - return ( - "become", - "cache", - "callback", - "cliconf", - "connection", - "httpapi", - "inventory", - "lookup", - "netconf", - "shell", - "vars", - "module", - "strategy", - ) - + try: + # We import from ansible locally since importing it is rather slow + from ansible import constants as C # pylint: disable=import-outside-toplevel + return C.DOCUMENTABLE_PLUGINS + except ImportError: + return ( + "become", + "cache", + "callback", + "cliconf", + "connection", + "httpapi", + "inventory", + "lookup", + "netconf", + "shell", + "vars", + "module", + "strategy", + ) + + +@cache def get_documentable_objects() -> tuple[str, ...]: """ Retrieve object types that can be documented. """ - if not HAS_ANSIBLE_RELEASE: - return () - if packaging.version.Version( - ansible_release.__version__ - ) < packaging.version.Version("2.11.0"): + try: + # We import from ansible locally since importing it is rather slow + # pylint: disable-next=import-outside-toplevel + from ansible import release as ansible_release + + if packaging.version.Version( + ansible_release.__version__ + ) < packaging.version.Version("2.11.0"): + return () + return ("role",) + except ImportError: return () - return ("role",) +@cache def get_ansible_release() -> tuple[str, str]: """ Retrieve current version and codename of Ansible. :return: Tuple with version and codename """ - if not HAS_ANSIBLE_RELEASE: - raise ValueError("Cannot import ansible.release") - return ansible_release.__version__, ansible_release.__codename__ + try: + # We import from ansible locally since importing it is rather slow + # pylint: disable-next=import-outside-toplevel + from ansible import release as ansible_release + + return ansible_release.__version__, ansible_release.__codename__ + except ImportError as exc: + raise ValueError("Cannot import ansible.release") from exc diff --git a/src/antsibull_changelog/cli.py b/src/antsibull_changelog/cli.py index 9f1c1b17..bbffd680 100644 --- a/src/antsibull_changelog/cli.py +++ b/src/antsibull_changelog/cli.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2020, Ansible Project +# PYTHON_ARGCOMPLETE_OK + """ Entrypoint to the antsibull-changelog script. """ diff --git a/src/antsibull_changelog/rstcheck.py b/src/antsibull_changelog/rstcheck.py index 5dad5333..21f83fc4 100644 --- a/src/antsibull_changelog/rstcheck.py +++ b/src/antsibull_changelog/rstcheck.py @@ -14,17 +14,6 @@ import pathlib import tempfile -# rstcheck >= 6.0.0 depends on rstcheck-core -try: - import rstcheck_core.checker - import rstcheck_core.config - - HAS_RSTCHECK_CORE = True -except ImportError: - HAS_RSTCHECK_CORE = False - import docutils.utils - import rstcheck - def check_rst_content( content: str, filename: str | None = None @@ -35,7 +24,12 @@ def check_rst_content( The entries in the return list are tuples with line number, column number, and error/warning message. """ - if HAS_RSTCHECK_CORE: + # rstcheck >= 6.0.0 depends on rstcheck-core + try: + # We import from rstcheck_core locally since importing it is rather slow + import rstcheck_core.checker # pylint: disable=import-outside-toplevel + import rstcheck_core.config # pylint: disable=import-outside-toplevel + filename = os.path.basename(filename or "file.rst") or "file.rst" with tempfile.TemporaryDirectory() as tempdir: rst_path = os.path.join(tempdir, filename) @@ -50,11 +44,14 @@ def check_rst_content( return [ (result["line_number"], 0, result["message"]) for result in core_results ] - else: - results = rstcheck.check( # pylint: disable=no-member,used-before-assignment + except ImportError: + # We import from rstcheck_core locally since importing it is rather slow + import docutils.utils # pylint: disable=import-outside-toplevel + import rstcheck # pylint: disable=import-outside-toplevel + + results = rstcheck.check( # pylint: disable=no-member content, filename=filename, - # pylint: disable-next=used-before-assignment report_level=docutils.utils.Reporter.WARNING_LEVEL, ) return [(result[0], 0, result[1]) for result in results] diff --git a/tests/functional/test_changelog_basic_collection.py b/tests/functional/test_changelog_basic_collection.py index 0fa9fb7e..b3747711 100644 --- a/tests/functional/test_changelog_basic_collection.py +++ b/tests/functional/test_changelog_basic_collection.py @@ -15,7 +15,7 @@ from fixtures import collection_changelog # noqa: F401; pylint: disable=unused-variable from fixtures import create_plugin -import antsibull_changelog.ansible # noqa: F401; pylint: disable=unused-variable +import antsibull_changelog.plugins # noqa: F401; pylint: disable=unused-variable from antsibull_changelog import constants as C from antsibull_changelog.config import TextFormat @@ -1951,266 +1951,313 @@ def test_changelog_release_simple_no_galaxy( # pylint: disable=redefined-outer- } -class FakeAnsibleRelease: - def __init__(self, version: str, codename: str): - self.__version__ = version - self.__codename__ = codename - - -@mock.patch("antsibull_changelog.ansible.HAS_ANSIBLE_RELEASE", True) -@mock.patch( - "antsibull_changelog.ansible.ansible_release", - FakeAnsibleRelease("2.11.0", "dummy codename"), -) def test_changelog_release_plugin_cache( # pylint: disable=redefined-outer-name collection_changelog, ): # noqa: F811 with mock.patch( - "subprocess.check_output", - collection_changelog.create_fake_subprocess_ansible_doc(FAKE_PLUGINS), + "antsibull_changelog.plugins.get_ansible_release", + return_value=("2.11.0", "dummy codename"), ): - collection_changelog.set_galaxy( - { - "version": "1.0.0", - } - ) - collection_changelog.config.title = "My Amazing Collection" - collection_changelog.set_config(collection_changelog.config) - collection_changelog.add_fragment_line( - "1.0.0.yml", "release_summary", "This is the first proper release." - ) - collection_changelog.add_plugin( - "module", - "test_module.py", - create_plugin( - DOCUMENTATION={ - "name": "test_module", - "short_description": "A test module", - "version_added": "1.0.0", - "description": ["This is a test module."], - "author": ["Someone"], - "options": {}, - }, - EXAMPLES="", - RETURN={}, - ), - ) - collection_changelog.add_plugin( - "module", - "__init__.py", - create_plugin( - DOCUMENTATION={ - "name": "bad_module", - "short_description": "Bad module", - "description": ["This should be ignored, not found as a module!."], - "author": ["badguy"], - "options": {}, - }, - EXAMPLES="# Some examples\n", - RETURN={}, - ), - subdirs=["cloud"], - ) - collection_changelog.add_plugin( - "module", - "old_module.py", - create_plugin( - DOCUMENTATION={ - "name": "old_module", - "short_description": "An old module", - "description": ["This is an old module."], - "author": ["Elder"], - "options": {}, - }, - EXAMPLES="# Some examples\n", - RETURN={}, - ), - subdirs=["cloud", "sky"], - ) - collection_changelog.add_plugin( - "module", - "bad_module2", - create_plugin( - DOCUMENTATION={ - "name": "bad_module2", - "short_description": "An bad module", - "description": ["Shold not be found either."], - "author": ["Elder"], - "options": {}, - }, - EXAMPLES="# Some examples\n", - RETURN={}, - ), - subdirs=["cloud", "sky"], - ) - collection_changelog.add_plugin( - "callback", - "test_callback.py", - create_plugin( - DOCUMENTATION={ - "name": "test_callback", - "short_description": "A not so old callback", - "version_added": "0.5.0", - "description": ["This is a relatively new callback added before."], - "author": ["Someone else"], - "options": {}, - }, - EXAMPLES="# Some examples\n", - RETURN={}, - ), - ) - collection_changelog.add_plugin( - "callback", - "test_callback2.py", - create_plugin( - DOCUMENTATION={ - "name": "test_callback2", - "short_description": "This one should not be found.", - "version_added": "2.9", - "description": ["This is a relatively new callback added before."], - "author": ["Someone else"], - "options": {}, - }, - EXAMPLES="# Some examples\n", - RETURN={}, - ), - subdirs=["dont", "find", "me"], - ) - collection_changelog.add_role( - "test_role", - { - "main": { - "short_description": "Test role", - "version_added": "1.0.0", - "options": {}, - }, - "foo": { - "short_description": "Test role foo entrypoint", - "version_added": "0.9.0", - "options": {}, - }, - }, - ) - collection_changelog.add_role( - "old_role", - { - "main": { - "short_description": "Old role", - "options": {}, - }, - }, - ) - collection_changelog.add_role( - "funky_role", - { - "funky": { - "short_description": "A funky role", - "version_added": "1.0.0", - "options": {}, - }, - }, - ) - - assert ( - collection_changelog.run_tool("release", ["-v", "--date", "2020-01-02"]) - == C.RC_SUCCESS - ) - - diff = collection_changelog.diff() - assert diff.added_dirs == [] - assert diff.added_files == [ - "CHANGELOG.rst", - "changelogs/.plugin-cache.yaml", - "changelogs/changelog.yaml", - ] - assert diff.removed_dirs == [] - assert diff.removed_files == ["changelogs/fragments/1.0.0.yml"] - assert diff.changed_files == [] - - plugin_cache = diff.parse_yaml("changelogs/.plugin-cache.yaml") - assert plugin_cache["version"] == "1.0.0" - - # Plugin cache: modules - assert sorted(plugin_cache["plugins"]["module"]) == [ - "old_module", - "test_module", - ] - assert plugin_cache["plugins"]["module"]["old_module"]["name"] == "old_module" - assert ( - plugin_cache["plugins"]["module"]["old_module"]["namespace"] == "cloud.sky" - ) - assert ( - plugin_cache["plugins"]["module"]["old_module"]["description"] - == "An old module" - ) - assert plugin_cache["plugins"]["module"]["old_module"]["version_added"] is None - assert plugin_cache["plugins"]["module"]["test_module"]["name"] == "test_module" - assert plugin_cache["plugins"]["module"]["test_module"]["namespace"] == "" - assert ( - plugin_cache["plugins"]["module"]["test_module"]["description"] - == "A test module" - ) - assert ( - plugin_cache["plugins"]["module"]["test_module"]["version_added"] == "1.0.0" - ) - - # Plugin cache: callbacks - assert sorted(plugin_cache["plugins"]["callback"]) == ["test_callback"] - assert ( - plugin_cache["plugins"]["callback"]["test_callback"]["name"] - == "test_callback" - ) - assert ( - plugin_cache["plugins"]["callback"]["test_callback"]["description"] - == "A not so old callback" - ) - assert ( - plugin_cache["plugins"]["callback"]["test_callback"]["version_added"] - == "0.5.0" - ) - assert "namespace" not in plugin_cache["plugins"]["callback"]["test_callback"] - - # Plugin cache: roles - assert sorted(plugin_cache["objects"]["role"]) == [ - "funky_role", - "old_role", - "test_role", - ] - assert plugin_cache["objects"]["role"]["funky_role"]["name"] == "funky_role" - assert plugin_cache["objects"]["role"]["funky_role"]["description"] is None - assert plugin_cache["objects"]["role"]["funky_role"]["version_added"] is None - assert "namespace" not in plugin_cache["objects"]["role"]["funky_role"] - assert plugin_cache["objects"]["role"]["old_role"]["name"] == "old_role" - assert plugin_cache["objects"]["role"]["old_role"]["description"] == "Old role" - assert plugin_cache["objects"]["role"]["old_role"]["version_added"] is None - assert "namespace" not in plugin_cache["objects"]["role"]["old_role"] - assert plugin_cache["objects"]["role"]["test_role"]["name"] == "test_role" - assert ( - plugin_cache["objects"]["role"]["test_role"]["description"] == "Test role" - ) - assert plugin_cache["objects"]["role"]["test_role"]["version_added"] == "1.0.0" - assert "namespace" not in plugin_cache["objects"]["role"]["test_role"] - - # Changelog - changelog = diff.parse_yaml("changelogs/changelog.yaml") - assert changelog["ancestor"] is None - assert sorted(changelog["releases"]) == ["1.0.0"] - assert changelog["releases"]["1.0.0"]["release_date"] == "2020-01-02" - assert changelog["releases"]["1.0.0"]["changes"] == { - "release_summary": "This is the first proper release." - } - assert changelog["releases"]["1.0.0"]["fragments"] == ["1.0.0.yml"] - assert len(changelog["releases"]["1.0.0"]["modules"]) == 1 - assert changelog["releases"]["1.0.0"]["modules"][0]["name"] == "test_module" - assert changelog["releases"]["1.0.0"]["modules"][0]["namespace"] == "" - assert ( - changelog["releases"]["1.0.0"]["modules"][0]["description"] - == "A test module." - ) - assert "version_added" not in changelog["releases"]["1.0.0"]["modules"][0] - - assert diff.file_contents["CHANGELOG.rst"].decode("utf-8") == ( - r"""=================================== + with mock.patch( + "antsibull_changelog.plugins.get_documentable_objects", + return_value=("role",), + ): + with mock.patch( + "subprocess.check_output", + collection_changelog.create_fake_subprocess_ansible_doc(FAKE_PLUGINS), + ): + collection_changelog.set_galaxy( + { + "version": "1.0.0", + } + ) + collection_changelog.config.title = "My Amazing Collection" + collection_changelog.set_config(collection_changelog.config) + collection_changelog.add_fragment_line( + "1.0.0.yml", "release_summary", "This is the first proper release." + ) + collection_changelog.add_plugin( + "module", + "test_module.py", + create_plugin( + DOCUMENTATION={ + "name": "test_module", + "short_description": "A test module", + "version_added": "1.0.0", + "description": ["This is a test module."], + "author": ["Someone"], + "options": {}, + }, + EXAMPLES="", + RETURN={}, + ), + ) + collection_changelog.add_plugin( + "module", + "__init__.py", + create_plugin( + DOCUMENTATION={ + "name": "bad_module", + "short_description": "Bad module", + "description": [ + "This should be ignored, not found as a module!." + ], + "author": ["badguy"], + "options": {}, + }, + EXAMPLES="# Some examples\n", + RETURN={}, + ), + subdirs=["cloud"], + ) + collection_changelog.add_plugin( + "module", + "old_module.py", + create_plugin( + DOCUMENTATION={ + "name": "old_module", + "short_description": "An old module", + "description": ["This is an old module."], + "author": ["Elder"], + "options": {}, + }, + EXAMPLES="# Some examples\n", + RETURN={}, + ), + subdirs=["cloud", "sky"], + ) + collection_changelog.add_plugin( + "module", + "bad_module2", + create_plugin( + DOCUMENTATION={ + "name": "bad_module2", + "short_description": "An bad module", + "description": ["Shold not be found either."], + "author": ["Elder"], + "options": {}, + }, + EXAMPLES="# Some examples\n", + RETURN={}, + ), + subdirs=["cloud", "sky"], + ) + collection_changelog.add_plugin( + "callback", + "test_callback.py", + create_plugin( + DOCUMENTATION={ + "name": "test_callback", + "short_description": "A not so old callback", + "version_added": "0.5.0", + "description": [ + "This is a relatively new callback added before." + ], + "author": ["Someone else"], + "options": {}, + }, + EXAMPLES="# Some examples\n", + RETURN={}, + ), + ) + collection_changelog.add_plugin( + "callback", + "test_callback2.py", + create_plugin( + DOCUMENTATION={ + "name": "test_callback2", + "short_description": "This one should not be found.", + "version_added": "2.9", + "description": [ + "This is a relatively new callback added before." + ], + "author": ["Someone else"], + "options": {}, + }, + EXAMPLES="# Some examples\n", + RETURN={}, + ), + subdirs=["dont", "find", "me"], + ) + collection_changelog.add_role( + "test_role", + { + "main": { + "short_description": "Test role", + "version_added": "1.0.0", + "options": {}, + }, + "foo": { + "short_description": "Test role foo entrypoint", + "version_added": "0.9.0", + "options": {}, + }, + }, + ) + collection_changelog.add_role( + "old_role", + { + "main": { + "short_description": "Old role", + "options": {}, + }, + }, + ) + collection_changelog.add_role( + "funky_role", + { + "funky": { + "short_description": "A funky role", + "version_added": "1.0.0", + "options": {}, + }, + }, + ) + + assert ( + collection_changelog.run_tool( + "release", ["-v", "--date", "2020-01-02"] + ) + == C.RC_SUCCESS + ) + + diff = collection_changelog.diff() + assert diff.added_dirs == [] + assert diff.added_files == [ + "CHANGELOG.rst", + "changelogs/.plugin-cache.yaml", + "changelogs/changelog.yaml", + ] + assert diff.removed_dirs == [] + assert diff.removed_files == ["changelogs/fragments/1.0.0.yml"] + assert diff.changed_files == [] + + plugin_cache = diff.parse_yaml("changelogs/.plugin-cache.yaml") + assert plugin_cache["version"] == "1.0.0" + + # Plugin cache: modules + assert sorted(plugin_cache["plugins"]["module"]) == [ + "old_module", + "test_module", + ] + assert ( + plugin_cache["plugins"]["module"]["old_module"]["name"] + == "old_module" + ) + assert ( + plugin_cache["plugins"]["module"]["old_module"]["namespace"] + == "cloud.sky" + ) + assert ( + plugin_cache["plugins"]["module"]["old_module"]["description"] + == "An old module" + ) + assert ( + plugin_cache["plugins"]["module"]["old_module"]["version_added"] + is None + ) + assert ( + plugin_cache["plugins"]["module"]["test_module"]["name"] + == "test_module" + ) + assert ( + plugin_cache["plugins"]["module"]["test_module"]["namespace"] == "" + ) + assert ( + plugin_cache["plugins"]["module"]["test_module"]["description"] + == "A test module" + ) + assert ( + plugin_cache["plugins"]["module"]["test_module"]["version_added"] + == "1.0.0" + ) + + # Plugin cache: callbacks + assert sorted(plugin_cache["plugins"]["callback"]) == ["test_callback"] + assert ( + plugin_cache["plugins"]["callback"]["test_callback"]["name"] + == "test_callback" + ) + assert ( + plugin_cache["plugins"]["callback"]["test_callback"]["description"] + == "A not so old callback" + ) + assert ( + plugin_cache["plugins"]["callback"]["test_callback"][ + "version_added" + ] + == "0.5.0" + ) + assert ( + "namespace" + not in plugin_cache["plugins"]["callback"]["test_callback"] + ) + + # Plugin cache: roles + assert sorted(plugin_cache["objects"]["role"]) == [ + "funky_role", + "old_role", + "test_role", + ] + assert ( + plugin_cache["objects"]["role"]["funky_role"]["name"] + == "funky_role" + ) + assert ( + plugin_cache["objects"]["role"]["funky_role"]["description"] is None + ) + assert ( + plugin_cache["objects"]["role"]["funky_role"]["version_added"] + is None + ) + assert "namespace" not in plugin_cache["objects"]["role"]["funky_role"] + assert plugin_cache["objects"]["role"]["old_role"]["name"] == "old_role" + assert ( + plugin_cache["objects"]["role"]["old_role"]["description"] + == "Old role" + ) + assert ( + plugin_cache["objects"]["role"]["old_role"]["version_added"] is None + ) + assert "namespace" not in plugin_cache["objects"]["role"]["old_role"] + assert ( + plugin_cache["objects"]["role"]["test_role"]["name"] == "test_role" + ) + assert ( + plugin_cache["objects"]["role"]["test_role"]["description"] + == "Test role" + ) + assert ( + plugin_cache["objects"]["role"]["test_role"]["version_added"] + == "1.0.0" + ) + assert "namespace" not in plugin_cache["objects"]["role"]["test_role"] + + # Changelog + changelog = diff.parse_yaml("changelogs/changelog.yaml") + assert changelog["ancestor"] is None + assert sorted(changelog["releases"]) == ["1.0.0"] + assert changelog["releases"]["1.0.0"]["release_date"] == "2020-01-02" + assert changelog["releases"]["1.0.0"]["changes"] == { + "release_summary": "This is the first proper release." + } + assert changelog["releases"]["1.0.0"]["fragments"] == ["1.0.0.yml"] + assert len(changelog["releases"]["1.0.0"]["modules"]) == 1 + assert ( + changelog["releases"]["1.0.0"]["modules"][0]["name"] + == "test_module" + ) + assert changelog["releases"]["1.0.0"]["modules"][0]["namespace"] == "" + assert ( + changelog["releases"]["1.0.0"]["modules"][0]["description"] + == "A test module." + ) + assert ( + "version_added" not in changelog["releases"]["1.0.0"]["modules"][0] + ) + + assert diff.file_contents["CHANGELOG.rst"].decode("utf-8") == ( + r"""=================================== My Amazing Collection Release Notes =================================== @@ -2234,16 +2281,16 @@ def test_changelog_release_plugin_cache( # pylint: disable=redefined-outer-name - acme.test.test_role - Test role. """ - ) - - # Force reloading plugins. This time use ansible-doc for listing plugins. - assert ( - collection_changelog.run_tool( - "generate", ["-v", "--reload-plugins", "--use-ansible-doc"] - ) - == C.RC_SUCCESS - ) - - diff = collection_changelog.diff() - diff.dump() - assert diff.unchanged + ) + + # Force reloading plugins. This time use ansible-doc for listing plugins. + assert ( + collection_changelog.run_tool( + "generate", ["-v", "--reload-plugins", "--use-ansible-doc"] + ) + == C.RC_SUCCESS + ) + + diff = collection_changelog.diff() + diff.dump() + assert diff.unchanged