diff --git a/pyproject.toml b/pyproject.toml index 2989a8232ba8468612c1460f16fe4924e1b07085..cb1368981115ec3261b160265974e51b8f71eb73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ testing = {file = [ [project.entry-points."swh.cli.subcommands"] "swh.core.db" = "swh.core.cli.db" +"swh.core.backend" = "swh.core.cli.backend" [project.entry-points.pytest11] "pytest_swh_core" = "swh.core.pytest_plugin" diff --git a/swh/core/cli/backend.py b/swh/core/cli/backend.py new file mode 100755 index 0000000000000000000000000000000000000000..55d289e2ba6d0d259d00fd848fccd00ee3f1cb77 --- /dev/null +++ b/swh/core/cli/backend.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# Copyright (C) 2024 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import logging +from os import get_terminal_size + +import click + +from swh.core.cli import CONTEXT_SETTINGS +from swh.core.cli import swh as swh_cli_group + +logger = logging.getLogger(__name__) + + +@swh_cli_group.group(name="backend", context_settings=CONTEXT_SETTINGS) +@click.pass_context +def backend(ctx): + """Software Heritage backend generic tools.""" + pass + + +@backend.command(name="list", context_settings=CONTEXT_SETTINGS) +@click.argument("package", required=True) +@click.argument("cls", required=False, default=None) +@click.pass_context +def bk_list(ctx, package, cls): + """Show registered backends for the given package + + With their documentation, if any. Example:: + + \b + $ swh backend list vault + + memory Stub vault backend, for use in the CLI. + postgresql Backend for the Software Heritage Vault. + remote Client to the Software Heritage vault cache. + + If 'cls' is given, display the full docstring for the corresponding + backend. + + Example:: + + \b + $ swh backend list vault memory + + vault:memory + + Stub vault backend, for use in the CLI. + + """ + from swh.core.config import get_swh_backend_module, list_swh_backends + + if cls is None: + items = [] + for backend in list_swh_backends(package): + _, BackendCls = get_swh_backend_module(package, backend) + msg = BackendCls.__doc__ + if msg is None: + msg = "" + msg = msg.strip() + if "\n" in msg: + firstline = msg.splitlines()[0] + else: + firstline = msg + items.append((backend, firstline, msg)) + if not items: + click.secho( + f"No backend found for package '{package}'", + fg="red", + bold=True, + err=True, + ) + raise click.Abort() + + max_name = max(len(name) for name, _, _ in items) + try: + width = get_terminal_size().columns + except OSError: + width = 78 + for name, firstline, msg in items: + click.echo( + click.style( + f"{name:<{max_name + 1}}", + fg="green", + bold=True, + ), + nl=False, + ) + firstline = firstline[: width - max_name - 1] + click.echo(firstline) + else: + try: + _, BackendCls = get_swh_backend_module(package, cls) + except ValueError: + BackendCls = None + + if BackendCls is None: + click.secho( + f"No backend '{cls}' found for package '{package}'", + fg="red", + bold=True, + err=True, + ) + raise click.Abort() + + click.echo( + click.style( + package, + fg="green", + bold=True, + ) + + ":" + + click.style( + cls, + fg="yellow", + bold=True, + ) + + "\n", + ) + click.echo(BackendCls.__doc__.strip()) diff --git a/swh/core/config.py b/swh/core/config.py index c895d323c32c7bfe59c54fb5023dbd7550ec4cb3..16f3590cf9d7b7450d30465c6a56673aa331f6a8 100644 --- a/swh/core/config.py +++ b/swh/core/config.py @@ -373,6 +373,13 @@ def get_swh_backend_from_fullmodule( return None, None +def list_swh_backends(package: str) -> List[str]: + if package.startswith("swh."): + package = package[4:] + entry_points = get_entry_points(group=f"swh.{package}.classes") + return [ep.name for ep in entry_points] + + def list_db_config_entries(cfg) -> Generator[Tuple[str, str, dict, str], None, None]: """List all the db config entries in the given config structure diff --git a/swh/core/db/tests/conftest.py b/swh/core/db/tests/conftest.py index d99bf6921d48b7f6c1223d0437606ed0472fbe41..1b51223e7ef9433bd2385da7b1ecbbf83c107e09 100644 --- a/swh/core/db/tests/conftest.py +++ b/swh/core/db/tests/conftest.py @@ -3,9 +3,7 @@ # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information -from importlib import import_module import os -from pathlib import Path from click.testing import CliRunner from hypothesis import HealthCheck @@ -38,67 +36,3 @@ postgresql_proc = factories.postgresql_proc( @pytest.fixture def cli_runner(): return CliRunner() - - -@pytest.fixture() -def mock_import_module(request, mocker, datadir): - mock = mocker.MagicMock - - def import_module_mocker(name, package=None): - if not name.startswith("swh.test"): - return import_module(name, package) - - m = request.node.get_closest_marker("init_version") - if m: - version = m.kwargs.get("version", 1) - else: - version = 3 - if name.startswith("swh."): - name = name[4:] - modpath = name.split(".") - - def get_datastore(*args, **kw): - return mock(current_version=version) - - return mock( - __name__=name.split(".")[-1], - __file__=os.path.join(datadir, *modpath, "__init__.py"), - get_datastore=get_datastore, - ) - - return mocker.patch("swh.core.db.db_utils.import_module", import_module_mocker) - - -@pytest.fixture() -def mock_get_entry_points(request, mocker, datadir, mock_import_module): - mock = mocker.MagicMock - - def get_entry_points_mocker(group): - m = request.node.get_closest_marker("init_version") - if m: - version = m.kwargs.get("version", 1) - else: - version = 3 - - class EntryPoints(dict): - def __iter__(self): - return iter(self.values()) - - entrypoints = EntryPoints() - for entry in (Path(datadir) / "test").iterdir(): - if entry.is_dir(): - ep = mock( - module=f"swh.test.{entry.name}", - load=lambda: mock(current_version=version), - ) - # needed to overwrite the Mock's name argument, see - # https://docs.python.org/3/library/unittest.mock.html#mock-names-and-the-name-attribute - ep.name = entry.name - entrypoints[entry.name] = ep - return entrypoints - - return mocker.patch("swh.core.config.get_entry_points", get_entry_points_mocker) - - -# for bw compat -mock_get_swh_backend_module = mock_get_entry_points diff --git a/swh/core/pytest_plugin.py b/swh/core/pytest_plugin.py index 9503c8222e589dd456298e9b5274ae5584d006ce..7171d815f8996bc2398bb5a4ac0c62060ced75cf 100644 --- a/swh/core/pytest_plugin.py +++ b/swh/core/pytest_plugin.py @@ -5,8 +5,10 @@ from collections import deque from functools import partial +from importlib import import_module import logging from os import path +from pathlib import Path import re from typing import Dict, List, Optional from urllib.parse import unquote, urlparse @@ -418,3 +420,73 @@ def clean_scopes(): scope._global_scope = None scope._isolation_scope.set(None) scope._current_scope.set(None) + + +@pytest.fixture() +def mock_import_module(request, mocker, datadir): + mock = mocker.MagicMock + + def import_module_mocker(name, package=None): + if not name.startswith("swh.test"): + return import_module(name, package) + + m = request.node.get_closest_marker("init_version") + if m: + version = m.kwargs.get("version", 1) + else: + version = 3 + if name.startswith("swh."): + name = name[4:] + modpath = name.split(".") + + def get_datastore(*args, **kw): + return mock(current_version=version) + + return mock( + __name__=name.split(".")[-1], + __file__=str(Path(datadir, *modpath, "__init__.py")), + get_datastore=get_datastore, + ) + + return mocker.patch("swh.core.db.db_utils.import_module", import_module_mocker) + + +@pytest.fixture() +def mock_get_entry_points(request, mocker, datadir, mock_import_module): + mock = mocker.MagicMock + + def get_entry_points_mocker(group): + m = request.node.get_closest_marker("init_version") + if m: + version = m.kwargs.get("version", 1) + else: + version = 3 + + class EntryPoints(dict): + def __iter__(self): + return iter(self.values()) + + package = group[4:-8] # remove 'swh.' and '.classes' + entrypoints = EntryPoints() + pkgdir = Path(datadir) / package + if pkgdir.is_dir(): + for entry in pkgdir.iterdir(): + if not entry.name.startswith("_") and entry.is_dir(): + ep = mock( + module=f"swh.{package}.{entry.name}", + load=lambda: mock( + current_version=version, + __doc__="A mockup backend for tests", + ), + ) + # needed to overwrite the Mock's name argument, see + # https://docs.python.org/3/library/unittest.mock.html#mock-names-and-the-name-attribute + ep.name = entry.name + entrypoints[entry.name] = ep + return entrypoints + + return mocker.patch("swh.core.config.get_entry_points", get_entry_points_mocker) + + +# for bw compat +mock_get_swh_backend_module = mock_get_entry_points diff --git a/swh/core/tests/data/test/__init__.py b/swh/core/tests/data/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/swh/core/tests/data/test/__toto__/.placeholder b/swh/core/tests/data/test/__toto__/.placeholder new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/swh/core/tests/data/test/backend1/__init__.py b/swh/core/tests/data/test/backend1/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/swh/core/tests/test_cli_backend.py b/swh/core/tests/test_cli_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..5e5e449cac699d40328bd00c24df7976288e55ae --- /dev/null +++ b/swh/core/tests/test_cli_backend.py @@ -0,0 +1,56 @@ +# Copyright (C) 2024 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + + +from click.testing import CliRunner +import pytest + +from swh.core.tests.test_cli import assert_result + + +@pytest.fixture +def swhmain(swhmain): + from swh.core.cli.backend import backend as swhbackend + + swhmain.add_command(swhbackend) + return swhmain + + +def test_backend_list_ok(swhmain, mock_get_entry_points): + runner = CliRunner() + result = runner.invoke(swhmain, ["backend", "list", "test"]) + assert_result(result) + assert result.output.strip() == "backend1 A mockup backend for tests" + + +def test_backend_list_empty(swhmain, mock_get_entry_points): + runner = CliRunner() + result = runner.invoke(swhmain, ["backend", "list", "wrong_package"]) + assert result.exit_code == 1 + assert "No backend found for package 'wrong_package'" in result.output.strip() + + +def test_backend_list_cls_ok(swhmain, mock_get_entry_points): + runner = CliRunner() + result = runner.invoke(swhmain, ["backend", "list", "test", "backend1"]) + assert_result(result) + assert result.output.strip() == "test:backend1\n\nA mockup backend for tests" + + +def test_backend_list_cls_no_package(swhmain, mock_get_entry_points): + runner = CliRunner() + result = runner.invoke(swhmain, ["backend", "list", "wrong_package", "backend1"]) + assert result.exit_code == 1 + assert ( + "No backend 'backend1' found for package 'wrong_package'" + in result.output.strip() + ) + + +def test_backend_list_cls_no_backend(swhmain, mock_get_entry_points): + runner = CliRunner() + result = runner.invoke(swhmain, ["backend", "list", "test", "backend2"]) + assert result.exit_code == 1 + assert "No backend 'backend2' found for package 'test'" in result.output.strip()