From 4e85319b1914c3caf61c98dda382614308f7e43f Mon Sep 17 00:00:00 2001 From: Pedro Brochado Date: Fri, 6 Feb 2026 19:42:00 -0300 Subject: [PATCH 1/2] Use podman container for OpenAPI schema generation Replace the on-host installation approach with a podman container running pulp/pulp-minimal. Plugins are pip-installed from git URLs inside the container, then pulpcore-manager openapi generates the schemas. Using pulp-minimal makes it easier to run because it has all required plugin's system dependencies. Also adds `pulp-docs openapi` Click subcommand and replaces the argparse __main__ CLI. Assisted-by: Claude Code (claude-opus-4-6) Closes: #178 --- .pre-commit-config.yaml | 6 +- src/pulp_docs/cli.py | 25 ++++ src/pulp_docs/openapi.py | 242 ++++++++++++++++++------------- tests/test_openapi_generation.py | 114 +++++++++++++-- 4 files changed, 269 insertions(+), 118 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d036de1..310fb28 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,11 +13,11 @@ repos: - repo: "https://github.com/astral-sh/ruff-pre-commit" rev: "v0.11.10" hooks: - # Run the linter. - - id: "ruff-check" - args: ["--show-fixes"] # Run the formatter. - id: "ruff-format" + # Run the linter. + - id: "ruff-check" + args: ["--show-fixes", "--fix"] - repo: "https://github.com/adrienverge/yamllint" rev: "v1.37.1" diff --git a/src/pulp_docs/cli.py b/src/pulp_docs/cli.py index b6d624d..ddd004a 100644 --- a/src/pulp_docs/cli.py +++ b/src/pulp_docs/cli.py @@ -130,8 +130,33 @@ def fetch(dest, config_file, path_exclude): asyncio.run(clone_repositories(missing_repos, dest_path)) +@click.command() +@click.argument("output_dir", type=click.Path(file_okay=False)) +@click.option( + "--dry-run/--no-dry-run", + default=False, + help="Show podman commands without executing them.", +) +@click.option( + "-l", + "--plugin-list", + type=str, + default="", + help="Comma-separated list of plugins to generate schemas for. Uses all if omitted.", +) +def openapi(output_dir, dry_run, plugin_list): + """Generate OpenAPI JSON schemas using a podman container.""" + from pulp_docs.openapi import main as openapi_main + + dest = Path(output_dir) + filter_list = [p.strip() for p in plugin_list.split(",") if p.strip()] if plugin_list else [] + exit_code = openapi_main(dest, filter_list, dry_run) + raise SystemExit(exit_code) + + main = mkdocs_cli main.add_command(fetch) +main.add_command(openapi) def get_default_mkdocs() -> Path | None: diff --git a/src/pulp_docs/openapi.py b/src/pulp_docs/openapi.py index 671ca5f..9f75b4d 100644 --- a/src/pulp_docs/openapi.py +++ b/src/pulp_docs/openapi.py @@ -2,143 +2,177 @@ Module for generating open-api json files for selected Pulp plugins. """ -import argparse +from __future__ import annotations + import os import shutil import subprocess -import tempfile +import sys +from contextlib import contextmanager from pathlib import Path -from typing import Optional +from typing import NamedTuple, Optional from pulp_docs.cli import get_default_mkdocs -from pulp_docs.plugin import ComponentLoader, ComponentSpec, default_lookup_paths +from pulp_docs.plugin import ComponentLoader, default_lookup_paths -BASE_TMPDIR_NAME = "pulpdocs_tmp" -CURRENT_DIR = Path(__file__).parent.absolute() +CONTAINER_IMAGE = "quay.io/pulp/pulp-minimal:stable" +CONTAINER_NAME_PREFIX = "pulpdocs-openapi" +CONTAINER_OUTPUT_PATH = "/output" -def main(output_dir: Path, filter_list: Optional[list[str]] = None, dry_run: bool = False): +def main(output_dir: Path, filter_list: Optional[list[str]] = None, dry_run: bool = False) -> int: """Creates openapi json files for found plugins in the output_dir. Optionally filter the found plugins with a filter list. """ - def select_component_fn(comp: ComponentSpec) -> bool: - name = comp.component_name - return (bool(filter_list) and name in filter_list) or name == "pulpcore" + try: + openapi_plugins = get_plugins(filter_list or []) + openapi = OpenAPIGenerator(plugins=openapi_plugins, dry_run=dry_run) + openapi.generate(output_dir=output_dir) + except Exception as e: + print(e, file=sys.stderr) + return 1 + return 0 + +def get_plugins(filter_list: list[str]) -> list[OpenApiPlugin]: mkdocs_config = get_default_mkdocs() lookup_paths = default_lookup_paths() - component_loader = ComponentLoader(lookup_paths, mkdocs_config=mkdocs_config) - all_specs = component_loader.load_all().all_specs - selected = list(filter(select_component_fn, all_specs)) - openapi = OpenAPIGenerator(plugins=selected, dry_run=dry_run) - openapi.generate(target_dir=output_dir) + load_result = ComponentLoader(lookup_paths, mkdocs_config=mkdocs_config).load_all() + all_specs = load_result.all_specs + + if filter_list: + selected = [p for p in all_specs if p.component_name in filter_list] + else: + selected = all_specs + + return [OpenApiPlugin(git_url=spec.git_url, plugin_label=spec.label) for spec in selected] + + +class OpenApiPlugin(NamedTuple): + git_url: str + plugin_label: str class OpenAPIGenerator: - """ - Responsible for setting up a python environment with the required - Pulp packages to generate openapi schemas for all registered plugins. + """Generate openapi schemas for all registered plugins. Args: - plugin_remotes: A list of git remote urls of the required Pulp packages. + plugins: A list of OpenApiPlugin with git URLs and labels. dry_run: Whether it should execute the commands or just show them. + image: The container image to use. """ - def __init__(self, plugins: list[ComponentSpec], dry_run=False): - self.pulpcore = next(filter(lambda p: p.component_name == "pulpcore", plugins)) - self.plugins = plugins + [self.pulpcore] + def __init__( + self, + plugins: list[OpenApiPlugin], + dry_run: bool = False, + image: str = CONTAINER_IMAGE, + ): + self.plugins = plugins + self.git_urls = list({p.git_url for p in plugins}) self.dry_run = dry_run + self.image = image - # setup working tmpdir - self.tmpdir = Path(tempfile.gettempdir()) / BASE_TMPDIR_NAME / "openapi" - self.venv_path = os.path.join(self.tmpdir, "venv") - - shutil.rmtree(self.tmpdir, ignore_errors=True) - os.makedirs(self.tmpdir, exist_ok=True) - - def generate(self, target_dir: Path): + def generate(self, output_dir: Path): """Generate openapi json files at target directory.""" + self._check_podman() + output_dir.mkdir(parents=True, exist_ok=True) + container = self._init_container(output_dir) + with container.run(): + self._install_plugins(container) + self._generate_schemas(container) + + def _init_container(self, output_dir: Path): + abs_target = str(output_dir.resolve()) + return PodmanContainer( + image=self.image, + volumes={abs_target: CONTAINER_OUTPUT_PATH}, + env={"PULP_CONTENT_ORIGIN": "NONE"}, + dry_run=self.dry_run, + ) + + def _install_plugins(self, container: PodmanContainer): + if not self.git_urls: + return + pip_args = [f"git+{url}" for url in self.git_urls] + container.exec("pip", "install", *pip_args) + + def _check_podman(self): + if not self.dry_run and not shutil.which("podman"): + raise RuntimeError("podman is required but was not found on PATH. ") + + def _generate_schemas(self, container: PodmanContainer): for plugin in self.plugins: - self.setup_venv(plugin) - outfile = str(target_dir / f"{plugin.label}-api.json") - self.run_python( + outfile = f"{CONTAINER_OUTPUT_PATH}/{plugin.plugin_label}-api.json" + container.exec( "pulpcore-manager", "openapi", "--component", - plugin.label, + plugin.plugin_label, "--file", outfile, ) - def setup_venv(self, plugin: ComponentSpec): - """ - Creates virtualenv with plugin. - """ - create_venv_cmd = ("python", "-m", "venv", self.venv_path) - # setuptools provides distutils for python >=3.12. - install_cmd = ["pip", "install", f"git+{plugin.git_url}", "setuptools"] - - if self.dry_run is True: - print(" ".join(create_venv_cmd)) - else: - shutil.rmtree(self.venv_path, ignore_errors=True) - subprocess.run(create_venv_cmd, check=True) - - self.run_python(*install_cmd) - - def run_python(self, *cmd: str) -> str: - """Run a binary command from within the tmp venv. - - Basically: $tmp-venv/bin/{first-arg} {remaining-args} - """ - cmd_bin = os.path.join(self.venv_path, f"bin/{cmd[0]}") - final_cmd = [cmd_bin] + list(cmd[1:]) - if self.dry_run is True: - cmd_str = " ".join(final_cmd) - print(cmd_str) - return cmd_str - - os.environ["PULP_CONTENT_ORIGIN"] = "NONE" - result = subprocess.run(final_cmd, check=True) - return result.stdout.decode() if result.stdout else "" - - -def parse_args(): - parser = argparse.ArgumentParser( - "pulp-docs openapi generation", - description="Creates a venv for each plugin and generate its openapi-json to output_dir.", - ) - parser.add_argument( - "output_dir", help="The directory where the {plugin}-api.json will be stored." - ) - parser.add_argument( - "--dry-run", - action="store_true", # default False - help="Dont run the commands, only output how they are constructed.", - ) - parser.add_argument( - "-l", - "--plugin-list", - type=str, - help="List of plugins that should be used. Use all if omitted.", - ) - args = parser.parse_args() - - # validation - if not os.path.isdir(args.output_dir): - raise TypeError("Must provide an existing directory.") - return args - - -if __name__ == "__main__": - args = parse_args() - dry_run = args.dry_run - dest = Path(args.output_dir) - - filter_list = [] - if args.plugin_list: - filter_list = [str(p) for p in args.plugin_list.split(",") if p] - - main(dest, filter_list, dry_run) + +class PodmanContainer: + """Manage a podman container lifecycle (create, start, exec, remove). + + Use the `run` context manager to ensure cleanup:: + + container = PodmanContainer(image="myimage", volumes={"/host": "/container"}) + with container.run(): + container.exec("pip", "install", "some-package") + container.exec("my-command", "--flag", "value") + """ + + def __init__( + self, + image: str, + volumes: Optional[dict[str, str]] = None, + env: Optional[dict[str, str]] = None, + name: Optional[str] = None, + dry_run: bool = False, + ): + self.image = image + self.volumes = volumes or {} + self.env = env or {} + self.name = name or f"{CONTAINER_NAME_PREFIX}-{os.getpid()}" + self.dry_run = dry_run + + @contextmanager + def run(self): + """Start the container and remove it on exit.""" + self._create() + self._start() + try: + yield self + finally: + self._remove() + + def exec(self, *cmd: str): + """Run a command inside the container.""" + self._run_podman("exec", self.name, *cmd) + + def _create(self): + cmd = ["create", "--name", self.name] + for key, value in self.env.items(): + cmd.extend(["-e", f"{key}={value}"]) + for host_path, container_path in self.volumes.items(): + cmd.extend(["-v", f"{host_path}:{container_path}:Z"]) + cmd.extend([self.image, "sleep", "infinity"]) + self._run_podman(*cmd) + + def _start(self): + self._run_podman("start", self.name) + + def _remove(self): + self._run_podman("rm", "--force", self.name) + + def _run_podman(self, *args: str): + cmd = ["podman", *args] + if self.dry_run: + print(" ".join(cmd)) + return + subprocess.run(cmd, check=True) diff --git a/tests/test_openapi_generation.py b/tests/test_openapi_generation.py index b4d0990..40192a2 100644 --- a/tests/test_openapi_generation.py +++ b/tests/test_openapi_generation.py @@ -1,31 +1,123 @@ import json +import shutil from pathlib import Path +import pytest +from click.testing import CliRunner + +from pulp_docs.cli import main +from pulp_docs.openapi import OpenAPIGenerator, OpenApiPlugin, PodmanContainer, get_plugins from pulp_docs.openapi import main as openapi_main +PODMAN_AVAILABLE = shutil.which("podman") is not None + + +class TestPodmanContainer: + def test_dry_run_lifecycle(self, tmp_path, capsys): + c = PodmanContainer( + image="myimage", + volumes={str(tmp_path): "/output"}, + env={"FOO": "BAR"}, + dry_run=True, + ) + with c.run(): + c.exec("echo", "hello") + + output = capsys.readouterr().out + assert "podman create" in output + assert "podman start" in output + assert "podman exec" in output + assert "echo hello" in output + assert "podman rm --force" in output + assert "FOO=BAR" in output + assert f"{tmp_path}:/output:Z" in output + + +class TestGetPlugins: + def test_no_filter_returns_all_plugins(self): + all_plugins = get_plugins([]) + assert len(all_plugins) > 0 + + def test_filter_returns_subset(self): + all_plugins = get_plugins([]) + filtered = get_plugins(["pulp_rpm"]) + assert 0 < len(filtered) < len(all_plugins) + + +class TestOpenAPIGeneratorClass: + def test_deduplicate_git_urls(self): + """pulpcore and pulp_file share the same git URL.""" + plugins = [ + OpenApiPlugin(git_url="https://github.com/pulp/pulpcore", plugin_label="core"), + OpenApiPlugin(git_url="https://github.com/pulp/pulpcore", plugin_label="file"), + OpenApiPlugin(git_url="https://github.com/pulp/pulp_rpm", plugin_label="rpm"), + ] + gen = OpenAPIGenerator(plugins, dry_run=True) + assert len(gen.git_urls) == 2 + assert "https://github.com/pulp/pulpcore" in gen.git_urls + assert "https://github.com/pulp/pulp_rpm" in gen.git_urls + + def test_podman_not_found(self, tmp_path, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda x: None) + gen = OpenAPIGenerator([OpenApiPlugin("https://github.com/pulp/pulpcore", "core")]) + with pytest.raises(RuntimeError, match="podman is required"): + gen.generate(output_dir=tmp_path) -class TestOpenApiGeneration: - def test_dry_run(self, tmp_path: Path, monkeypatch): + def test_generate_core_only(self, tmp_path: Path): + core = OpenApiPlugin(git_url="https://github.com/pulp/pulpcore", plugin_label="core") + plugins = [core] + generator = OpenAPIGenerator(plugins) + generator.generate(output_dir=tmp_path) + files = list(tmp_path.iterdir()) + assert len(files) == 1 + + spec_file = files[0] + assert spec_file.name == "core-api.json" + + content = json.loads(spec_file.read_text()) + assert "core" in content["info"]["x-pulp-app-versions"] + + +class TestOpenapiMainFunction: + def test_dry_run_commands(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): output_dir = tmp_path / "openapi" filter_list = ["pulp_rpm", "pulp_file"] - openapi_main(output_dir=output_dir, filter_list=filter_list, dry_run=True) + exit_code = openapi_main(output_dir=output_dir, filter_list=filter_list, dry_run=True) + output = capsys.readouterr().out + + assert exit_code == 0 + assert "podman create" in output + assert "podman start" in output + assert "podman rm --force" in output - def test_sample_generation(self, tmp_path: Path, monkeypatch): + assert "pip install" in output + assert "pulpcore-manager openapi --component file" in output + assert "pulpcore-manager openapi --component rpm" in output + assert str(output_dir.resolve()) in output + + def test_sample_generation(self, tmp_path: Path): output_dir = tmp_path / "openapi" output_dir.mkdir() assert len(list(output_dir.glob("*.json"))) == 0 - with monkeypatch.context() as m: - m.setenv("TMPDIR", str(tmp_path)) - filter_list = ["pulp_rpm", "pulp_file"] - openapi_main(output_dir=output_dir, filter_list=filter_list) + filter_list = ["pulp_rpm", "pulp_file"] + openapi_main(output_dir=output_dir, filter_list=filter_list) - output_paths = [f for f in output_dir.glob("*.json")] + output_paths = list(output_dir.glob("*.json")) output_ls = [f.name for f in output_paths] output_labels = [f.rpartition("-")[0] for f in output_ls] - assert len(output_ls) == 3 - assert {"core-api.json", "rpm-api.json", "file-api.json"} == set(output_ls) + assert len(output_ls) == 2 + assert {"rpm-api.json", "file-api.json"} == set(output_ls) for label, path in zip(output_labels, output_paths): openapi_data = json.loads(path.read_text()) assert label in openapi_data["info"]["x-pulp-app-versions"].keys() + + +class TestClickCLI: + def test_openapi_help(self): + runner = CliRunner() + result = runner.invoke(main, ["openapi", "--help"]) + assert result.exit_code == 0 + assert "Generate OpenAPI" in result.output + assert "OUTPUT_DIR" in result.output From 7e7525d64964ccecf25332a123d227b90c1db570 Mon Sep 17 00:00:00 2001 From: Pedro Brochado Date: Thu, 5 Mar 2026 17:35:59 -0300 Subject: [PATCH 2/2] Removed openapi CLI support in favor of internal use only --- src/pulp_docs/cli.py | 82 ++++++++++------- src/pulp_docs/openapi.py | 76 ++++++++-------- src/pulp_docs/plugin.py | 1 + tests/test_openapi_generation.py | 151 ++++++++++--------------------- tests/test_podman_runner.py | 39 ++++++++ 5 files changed, 176 insertions(+), 173 deletions(-) create mode 100644 tests/test_podman_runner.py diff --git a/src/pulp_docs/cli.py b/src/pulp_docs/cli.py index ddd004a..688983a 100644 --- a/src/pulp_docs/cli.py +++ b/src/pulp_docs/cli.py @@ -7,7 +7,7 @@ from mkdocs.__main__ import cli as mkdocs_cli from pulp_docs.context import ctx_blog, ctx_docstrings, ctx_draft, ctx_dryrun, ctx_path -from pulp_docs.plugin import ComponentLoader, default_lookup_paths +from pulp_docs.plugin import ComponentLoader, ComponentSpec, default_lookup_paths def blog_callback(ctx: click.Context, param: click.Parameter, value: bool) -> bool: @@ -94,6 +94,53 @@ async def clone_repository(repo_url: str) -> None: ) +def fetch_repositories( + dest: Path, + config_file: Path | None = None, + component_filter: list[str] | None = None, + fetch_all: bool = False, +) -> list[ComponentSpec]: + """Fetch missing repositories and return component specs. + + Args: + dest: Destination directory for cloned repositories + config_file: Path to mkdocs.yml config file (defaults to bundled config) + component_filter: Optional list of component names to fetch (fetches all if None) + fetch_all: If True, fetch all components from all_specs (not just missing) + + Returns: + List of ComponentSpec objects for the fetched/available components + """ + if config_file is None: + config_file = get_default_mkdocs() + + lookup_paths = default_lookup_paths() + component_loader = ComponentLoader(lookup_paths, mkdocs_config=config_file) + load_result = component_loader.load_all() + + # Determine which components to fetch + if fetch_all: + to_fetch = load_result.all_specs + else: + to_fetch = load_result.missing + + if component_filter: + to_fetch = [c for c in to_fetch if c.component_name in component_filter] + + repos_to_fetch = {comp.git_url for comp in to_fetch} + + if repos_to_fetch: + dest.mkdir(parents=True, exist_ok=True) + asyncio.run(clone_repositories(repos_to_fetch, dest)) + + # Return specs for requested components + specs = load_result.all_specs + if component_filter: + specs = [s for s in specs if s.component_name in component_filter] + + return specs + + @click.command() @click.option( "--dest", @@ -121,42 +168,11 @@ async def clone_repository(repo_url: str) -> None: def fetch(dest, config_file, path_exclude): """Fetch repositories to destination dir.""" dest_path = Path(dest) - lookup_paths = default_lookup_paths() - component_loader = ComponentLoader(lookup_paths, mkdocs_config=config_file) - missing_comps = component_loader.load_all().missing - missing_repos = {comp.git_url for comp in missing_comps} - if not dest_path.exists(): - dest_path.mkdir(parents=True) - asyncio.run(clone_repositories(missing_repos, dest_path)) - - -@click.command() -@click.argument("output_dir", type=click.Path(file_okay=False)) -@click.option( - "--dry-run/--no-dry-run", - default=False, - help="Show podman commands without executing them.", -) -@click.option( - "-l", - "--plugin-list", - type=str, - default="", - help="Comma-separated list of plugins to generate schemas for. Uses all if omitted.", -) -def openapi(output_dir, dry_run, plugin_list): - """Generate OpenAPI JSON schemas using a podman container.""" - from pulp_docs.openapi import main as openapi_main - - dest = Path(output_dir) - filter_list = [p.strip() for p in plugin_list.split(",") if p.strip()] if plugin_list else [] - exit_code = openapi_main(dest, filter_list, dry_run) - raise SystemExit(exit_code) + fetch_repositories(dest_path, config_file) main = mkdocs_cli main.add_command(fetch) -main.add_command(openapi) def get_default_mkdocs() -> Path | None: diff --git a/src/pulp_docs/openapi.py b/src/pulp_docs/openapi.py index 9f75b4d..683f41b 100644 --- a/src/pulp_docs/openapi.py +++ b/src/pulp_docs/openapi.py @@ -7,51 +7,37 @@ import os import shutil import subprocess -import sys from contextlib import contextmanager from pathlib import Path from typing import NamedTuple, Optional -from pulp_docs.cli import get_default_mkdocs -from pulp_docs.plugin import ComponentLoader, default_lookup_paths - CONTAINER_IMAGE = "quay.io/pulp/pulp-minimal:stable" CONTAINER_NAME_PREFIX = "pulpdocs-openapi" CONTAINER_OUTPUT_PATH = "/output" -def main(output_dir: Path, filter_list: Optional[list[str]] = None, dry_run: bool = False) -> int: - """Creates openapi json files for found plugins in the output_dir. - - Optionally filter the found plugins with a filter list. - """ +class PodmanError(Exception): + """Raised when a podman command fails.""" - try: - openapi_plugins = get_plugins(filter_list or []) - openapi = OpenAPIGenerator(plugins=openapi_plugins, dry_run=dry_run) - openapi.generate(output_dir=output_dir) - except Exception as e: - print(e, file=sys.stderr) - return 1 - return 0 + pass -def get_plugins(filter_list: list[str]) -> list[OpenApiPlugin]: - mkdocs_config = get_default_mkdocs() - lookup_paths = default_lookup_paths() - load_result = ComponentLoader(lookup_paths, mkdocs_config=mkdocs_config).load_all() - all_specs = load_result.all_specs +class PluginInstallError(Exception): + """Raised when plugin installation fails.""" - if filter_list: - selected = [p for p in all_specs if p.component_name in filter_list] - else: - selected = all_specs - - return [OpenApiPlugin(git_url=spec.git_url, plugin_label=spec.label) for spec in selected] + def __init__(self, repository_paths: list[Path]): + plugins_str = ", ".join(str(path.name) for path in repository_paths) + message = ( + "Failed to install plugins.\n" + "Are all these components Pulp projects with REST APIs?\n" + f"Trying with: {plugins_str}" + ) + super().__init__(message) + self.repository_paths = repository_paths class OpenApiPlugin(NamedTuple): - git_url: str + repository_path: Path plugin_label: str @@ -59,7 +45,7 @@ class OpenAPIGenerator: """Generate openapi schemas for all registered plugins. Args: - plugins: A list of OpenApiPlugin with git URLs and labels. + plugins: A list of OpenApiPlugin with repository paths and labels. dry_run: Whether it should execute the commands or just show them. image: The container image to use. """ @@ -71,12 +57,14 @@ def __init__( image: str = CONTAINER_IMAGE, ): self.plugins = plugins - self.git_urls = list({p.git_url for p in plugins}) + self.repository_paths = list({p.repository_path for p in plugins if p.repository_path}) self.dry_run = dry_run self.image = image def generate(self, output_dir: Path): """Generate openapi json files at target directory.""" + if not self.repository_paths: + return # nothing to do here self._check_podman() output_dir.mkdir(parents=True, exist_ok=True) container = self._init_container(output_dir) @@ -86,18 +74,26 @@ def generate(self, output_dir: Path): def _init_container(self, output_dir: Path): abs_target = str(output_dir.resolve()) + volumes = {abs_target: CONTAINER_OUTPUT_PATH} + + # Mount repository paths as volumes using repository names + for repo_path in self.repository_paths: + container_repo_path = f"/repos/{repo_path.name}" + volumes[str(repo_path.resolve())] = container_repo_path + return PodmanContainer( image=self.image, - volumes={abs_target: CONTAINER_OUTPUT_PATH}, + volumes=volumes, env={"PULP_CONTENT_ORIGIN": "NONE"}, dry_run=self.dry_run, ) def _install_plugins(self, container: PodmanContainer): - if not self.git_urls: - return - pip_args = [f"git+{url}" for url in self.git_urls] - container.exec("pip", "install", *pip_args) + pip_args = [f"/repos/{repo_path.name}" for repo_path in self.repository_paths] + try: + container.exec("pip", "install", *pip_args) + except PodmanError as e: + raise PluginInstallError(self.repository_paths) from e def _check_podman(self): if not self.dry_run and not shutil.which("podman"): @@ -175,4 +171,10 @@ def _run_podman(self, *args: str): if self.dry_run: print(" ".join(cmd)) return - subprocess.run(cmd, check=True) + try: + subprocess.run(cmd, check=True) + except subprocess.CalledProcessError as e: + cmd_str = " ".join(cmd) + raise PodmanError( + f"Podman command failed: {cmd_str}\nReturn code: {e.returncode}" + ) from e diff --git a/src/pulp_docs/plugin.py b/src/pulp_docs/plugin.py index b179c4a..b8be119 100644 --- a/src/pulp_docs/plugin.py +++ b/src/pulp_docs/plugin.py @@ -454,6 +454,7 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None: mkdocs_file = self.mkdocs_yml_dir / "mkdocs.yml" log_pulp_config(mkdocs_file, lookup_paths, self.loaded_comps, config.site_dir) + # Configure mkdocs plugins mkdocstrings_config = config.plugins["mkdocstrings"].config components_var = [] for component in self.loaded_comps: diff --git a/tests/test_openapi_generation.py b/tests/test_openapi_generation.py index 40192a2..8ba9acf 100644 --- a/tests/test_openapi_generation.py +++ b/tests/test_openapi_generation.py @@ -3,121 +3,66 @@ from pathlib import Path import pytest -from click.testing import CliRunner -from pulp_docs.cli import main -from pulp_docs.openapi import OpenAPIGenerator, OpenApiPlugin, PodmanContainer, get_plugins -from pulp_docs.openapi import main as openapi_main +from pulp_docs.cli import fetch_repositories +from pulp_docs.openapi import OpenAPIGenerator, OpenApiPlugin -PODMAN_AVAILABLE = shutil.which("podman") is not None - -class TestPodmanContainer: - def test_dry_run_lifecycle(self, tmp_path, capsys): - c = PodmanContainer( - image="myimage", - volumes={str(tmp_path): "/output"}, - env={"FOO": "BAR"}, - dry_run=True, - ) - with c.run(): - c.exec("echo", "hello") - - output = capsys.readouterr().out - assert "podman create" in output - assert "podman start" in output - assert "podman exec" in output - assert "echo hello" in output - assert "podman rm --force" in output - assert "FOO=BAR" in output - assert f"{tmp_path}:/output:Z" in output - - -class TestGetPlugins: - def test_no_filter_returns_all_plugins(self): - all_plugins = get_plugins([]) - assert len(all_plugins) > 0 - - def test_filter_returns_subset(self): - all_plugins = get_plugins([]) - filtered = get_plugins(["pulp_rpm"]) - assert 0 < len(filtered) < len(all_plugins) +@pytest.fixture(scope="session") +def repositories(tmp_path_factory: pytest.TempPathFactory) -> dict[str, OpenApiPlugin]: + """Fetch all component repositories for testing.""" + dest_path = tmp_path_factory.mktemp("repos") + specs = fetch_repositories(dest_path, fetch_all=True) + result = {} + for spec in specs: + if not spec.rest_api: + continue + repo_path = dest_path / spec.repository_name + if repo_path.exists(): + result[spec.component_name] = OpenApiPlugin( + repository_path=repo_path, plugin_label=spec.label + ) + return result class TestOpenAPIGeneratorClass: - def test_deduplicate_git_urls(self): - """pulpcore and pulp_file share the same git URL.""" - plugins = [ - OpenApiPlugin(git_url="https://github.com/pulp/pulpcore", plugin_label="core"), - OpenApiPlugin(git_url="https://github.com/pulp/pulpcore", plugin_label="file"), - OpenApiPlugin(git_url="https://github.com/pulp/pulp_rpm", plugin_label="rpm"), - ] + def test_deduplicate_repository_paths(self, repositories: dict[str, OpenApiPlugin]): + """pulpcore and pulp_file share the same repository path.""" + filter = ["rpm", "file", "core"] + plugins = [p for p in repositories.values() if p.plugin_label in filter] gen = OpenAPIGenerator(plugins, dry_run=True) - assert len(gen.git_urls) == 2 - assert "https://github.com/pulp/pulpcore" in gen.git_urls - assert "https://github.com/pulp/pulp_rpm" in gen.git_urls + # pulpcore and pulp_file share the same repository + assert len(gen.repository_paths) == 2 - def test_podman_not_found(self, tmp_path, monkeypatch): - monkeypatch.setattr(shutil, "which", lambda x: None) - gen = OpenAPIGenerator([OpenApiPlugin("https://github.com/pulp/pulpcore", "core")]) + def test_podman_not_found(self, tmp_path, monkeypatch, repositories: dict[str, OpenApiPlugin]): + monkeypatch.setattr(shutil, "which", lambda _: None) + core_plugin = repositories["pulpcore"] + gen = OpenAPIGenerator([core_plugin]) with pytest.raises(RuntimeError, match="podman is required"): gen.generate(output_dir=tmp_path) - def test_generate_core_only(self, tmp_path: Path): - core = OpenApiPlugin(git_url="https://github.com/pulp/pulpcore", plugin_label="core") - plugins = [core] + @pytest.mark.parametrize( + "filter", + [ + pytest.param(None, id="all"), + pytest.param(["pulp_rpm", "pulp_container", "pulp_python"], id="partial"), + ], + ) + def test_generate(self, tmp_path: Path, repositories: dict[str, OpenApiPlugin], filter): + """Generate schemas for plugins (all or filtered subset).""" + if filter is None: + plugins = list(repositories.values()) + else: + plugins = [repositories[name] for name in filter if name in repositories] + generator = OpenAPIGenerator(plugins) generator.generate(output_dir=tmp_path) - files = list(tmp_path.iterdir()) - assert len(files) == 1 - - spec_file = files[0] - assert spec_file.name == "core-api.json" - - content = json.loads(spec_file.read_text()) - assert "core" in content["info"]["x-pulp-app-versions"] - - -class TestOpenapiMainFunction: - def test_dry_run_commands(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): - output_dir = tmp_path / "openapi" - filter_list = ["pulp_rpm", "pulp_file"] - exit_code = openapi_main(output_dir=output_dir, filter_list=filter_list, dry_run=True) - output = capsys.readouterr().out - - assert exit_code == 0 - assert "podman create" in output - assert "podman start" in output - assert "podman rm --force" in output - - assert "pip install" in output - assert "pulpcore-manager openapi --component file" in output - assert "pulpcore-manager openapi --component rpm" in output - assert str(output_dir.resolve()) in output - - def test_sample_generation(self, tmp_path: Path): - output_dir = tmp_path / "openapi" - output_dir.mkdir() - assert len(list(output_dir.glob("*.json"))) == 0 - - filter_list = ["pulp_rpm", "pulp_file"] - openapi_main(output_dir=output_dir, filter_list=filter_list) - - output_paths = list(output_dir.glob("*.json")) - output_ls = [f.name for f in output_paths] - output_labels = [f.rpartition("-")[0] for f in output_ls] - assert len(output_ls) == 2 - assert {"rpm-api.json", "file-api.json"} == set(output_ls) - - for label, path in zip(output_labels, output_paths): - openapi_data = json.loads(path.read_text()) - assert label in openapi_data["info"]["x-pulp-app-versions"].keys() + files = list(tmp_path.glob("*.json")) + assert len(files) == len(plugins) + for plugin in plugins: + schema_file = tmp_path / f"{plugin.plugin_label}-api.json" + assert schema_file.exists() -class TestClickCLI: - def test_openapi_help(self): - runner = CliRunner() - result = runner.invoke(main, ["openapi", "--help"]) - assert result.exit_code == 0 - assert "Generate OpenAPI" in result.output - assert "OUTPUT_DIR" in result.output + content = json.loads(schema_file.read_text()) + assert plugin.plugin_label in content["info"]["x-pulp-app-versions"] diff --git a/tests/test_podman_runner.py b/tests/test_podman_runner.py new file mode 100644 index 0000000..8ef4058 --- /dev/null +++ b/tests/test_podman_runner.py @@ -0,0 +1,39 @@ +from pathlib import Path + +from pulp_docs.openapi import PodmanContainer + + +class TestPodmanContainer: + def test_dry_run_lifecycle(self, tmp_path, capsys): + """Test dry run mode prints all commands.""" + c = PodmanContainer( + image="myimage", + volumes={str(tmp_path): "/output"}, + env={"FOO": "BAR"}, + dry_run=True, + ) + with c.run(): + c.exec("echo", "hello") + + output = capsys.readouterr().out + assert "podman create" in output + assert "podman start" in output + assert "podman exec" in output + assert "echo hello" in output + assert "podman rm --force" in output + assert "FOO=BAR" in output + assert f"{tmp_path}:/output:Z" in output + + def test_volume_mount_write(self, tmp_path: Path): + """Test that files written inside container are accessible on host.""" + container = PodmanContainer( + image="quay.io/pulp/pulp-minimal:stable", + volumes={str(tmp_path): "/output"}, + ) + + with container.run(): + container.exec("bash", "-c", "echo 'test content' > /output/test.txt") + + test_file = tmp_path / "test.txt" + assert test_file.exists() + assert test_file.read_text() == "test content\n"