diff --git a/repo_autoindex/_impl/api.py b/repo_autoindex/_impl/api.py index f61d4bc..12d8df2 100644 --- a/repo_autoindex/_impl/api.py +++ b/repo_autoindex/_impl/api.py @@ -1,21 +1,28 @@ import gzip import logging from collections.abc import AsyncGenerator -from typing import Optional +from typing import Optional, Type import aiohttp -from .base import Fetcher, GeneratedIndex +from .base import Fetcher, GeneratedIndex, Repo from .yum import YumRepo +from .pulp import PulpFileRepo LOG = logging.getLogger("repo-autoindex") -REPO_TYPES = [YumRepo] +REPO_TYPES: list[Type[Repo]] = [YumRepo, PulpFileRepo] def http_fetcher(session: aiohttp.ClientSession) -> Fetcher: - async def get_content_with_session(url: str) -> str: + async def get_content_with_session(url: str) -> Optional[str]: LOG.info("Fetching: %s", url) async with session.get(url) as resp: + if resp.status == 404: + # This error status means we successfully determined that + # no content exists + return None + + # Any other error status is fatal resp.raise_for_status() # Deal with the non-ideal content negotiation diff --git a/repo_autoindex/_impl/base.py b/repo_autoindex/_impl/base.py index 12ab338..a7af5e9 100644 --- a/repo_autoindex/_impl/base.py +++ b/repo_autoindex/_impl/base.py @@ -10,6 +10,8 @@ Fetcher = Callable[[str], Awaitable[Optional[str]]] ICON_FOLDER = "📂" ICON_PACKAGE = "📦" +ICON_OPTICAL = "📀" +ICON_QCOW = "🐮" ICON_OTHER = " " diff --git a/repo_autoindex/_impl/cmd.py b/repo_autoindex/_impl/cmd.py index 94af671..9ed5ee5 100644 --- a/repo_autoindex/_impl/cmd.py +++ b/repo_autoindex/_impl/cmd.py @@ -11,12 +11,17 @@ LOG = logging.getLogger("repo-autoindex") async def dump_autoindices(args: argparse.Namespace) -> None: index_filename = args.index_filename + wrote_any = False async for index in autoindex(args.url, index_href_suffix=index_filename): os.makedirs(index.relative_dir or ".", exist_ok=True) output = os.path.join(index.relative_dir or ".", index_filename) with open(output, "w") as f: f.write(index.content) LOG.info("Wrote %s", output) + wrote_any = True + + if not wrote_any: + LOG.info("No indexable content found at %s", args.url) def argparser() -> argparse.ArgumentParser: diff --git a/repo_autoindex/_impl/pulp.py b/repo_autoindex/_impl/pulp.py new file mode 100644 index 0000000..40eed82 --- /dev/null +++ b/repo_autoindex/_impl/pulp.py @@ -0,0 +1,64 @@ +from typing import Optional, Type +from collections.abc import AsyncGenerator +import logging + +from .base import Repo, GeneratedIndex, Fetcher, IndexEntry, ICON_OPTICAL, ICON_QCOW +from .template import TemplateContext +from .tree import treeify + +LOG = logging.getLogger("repo-autoindex") + + +class PulpFileRepo(Repo): + async def render_index( + self, index_href_suffix: str + ) -> AsyncGenerator[GeneratedIndex, None]: + all_entries: list[IndexEntry] = [ + IndexEntry( + href="PULP_MANIFEST", + text="PULP_MANIFEST", + size=str(len(self.entry_point_content)), + ) + ] + + # PULP_MANIFEST is a series of lines like this: + # rhel-workstation-7.2-snapshot-2-x86_64-boot.iso,fa687b8f847b5301b6da817fdbe612558aa69c65584ec5781f3feb0c19ff8f24,379584512 + # rhel-workstation-7.3-rc-2-x86_64-dvd.iso,eab749310c95b4751ef9df7d7906ae0b8021c8e0dbc280c3efc8e967d5e60e71,4324327424 + # rhel-workstation-7.3-rc-1-x86_64-dvd.iso,e165919d6977e02e493605dda6a30d2d80c3f16ee3f4c3ab946d256b815dd5db,4323278848 + # rhel-server-7.3-rc-1-x86_64-boot.iso,f760611401fd928c2840eba85a7a80653fe2dc9dc94f3cef8ec1f3e7880d4102,427819008 + + for line in sorted(self.entry_point_content.splitlines()): + components = line.split(",") + if len(components) != 3: + LOG.warning("Ignoring bad line in PULP_MANIFEST: %s", line) + continue + entry = IndexEntry( + href=components[0], text=components[0], size=components[2] + ) + if entry.href.endswith(".iso"): + entry.icon = ICON_OPTICAL + elif entry.href.endswith(".qcow2"): + entry.icon = ICON_QCOW + all_entries.append(entry) + + ctx = TemplateContext() + nodes = [treeify(all_entries, index_href_suffix=index_href_suffix)] + while nodes: + node = nodes.pop() + yield GeneratedIndex( + content=ctx.render_index(index_entries=node.entries), + relative_dir=node.relative_dir, + ) + nodes.extend(node.children) + + @classmethod + async def probe( + cls: Type["PulpFileRepo"], fetcher: Fetcher, url: str + ) -> Optional["PulpFileRepo"]: + manifest_url = f"{url}/PULP_MANIFEST" + manifest_content = await fetcher(manifest_url) + + if manifest_content is None: + return None + + return cls(url, manifest_content, fetcher) diff --git a/tests/sample_pulp_repo/PULP_MANIFEST b/tests/sample_pulp_repo/PULP_MANIFEST new file mode 100644 index 0000000..352bd52 --- /dev/null +++ b/tests/sample_pulp_repo/PULP_MANIFEST @@ -0,0 +1,5 @@ +file1.iso,abc123,100 +file2.qcow2,abc123,200 +oops, this line ain't so valid + +file3,abc234,300 diff --git a/tests/test_cmd.py b/tests/test_cmd.py index 67f90e7..7c572bb 100644 --- a/tests/test_cmd.py +++ b/tests/test_cmd.py @@ -1,5 +1,7 @@ import pathlib import asyncio +import logging +from collections.abc import Callable, Awaitable import pytest @@ -11,30 +13,44 @@ from repo_autoindex._impl.cmd import entrypoint THIS_DIR = pathlib.Path(__file__).parent -async def test_command(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): - """Run the repo-autoindex command against a sample repo and check the generated index.""" +class CommandTester: + def __init__(self, monkeypatch: pytest.MonkeyPatch): + self.monkeypatch = monkeypatch + async def __call__(self, url: str): + entrypoint_coro = [] + + def fake_run(coro): + assert asyncio.iscoroutine(coro) + entrypoint_coro.append(coro) + + self.monkeypatch.setattr("asyncio.run", fake_run) + + app = web.Application() + app.add_routes([web.static("/", THIS_DIR)]) + + async with test_utils.TestServer(app) as server: + repo_url = server.make_url(url + "//") + self.monkeypatch.setattr("sys.argv", ["repo-autoindex", str(repo_url)]) + + entrypoint() + + assert entrypoint_coro + await entrypoint_coro[0] + + +@pytest.fixture +def tester(monkeypatch: pytest.MonkeyPatch) -> CommandTester: + return CommandTester(monkeypatch) + + +async def test_command_yum( + monkeypatch: pytest.MonkeyPatch, tester: CommandTester, tmp_path: pathlib.Path +): + """Run the repo-autoindex command against a sample yum repo and check the generated index.""" monkeypatch.chdir(tmp_path) - entrypoint_coro = [] - - def fake_run(coro): - assert asyncio.iscoroutine(coro) - entrypoint_coro.append(coro) - - monkeypatch.setattr("asyncio.run", fake_run) - - app = web.Application() - app.add_routes([web.static("/", THIS_DIR)]) - - async with test_utils.TestServer(app) as server: - repo_url = server.make_url("/sample_repo///") - monkeypatch.setattr("sys.argv", ["repo-autoindex", str(repo_url)]) - - entrypoint() - - assert entrypoint_coro - await entrypoint_coro[0] + await tester("/sample_repo") # It should have written index files reproducing the structure index_toplevel = tmp_path.joinpath("index.html") @@ -56,3 +72,43 @@ async def test_command(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): assert 'w/' in pkgs assert 'walrus-5.21-1.noarch.rpm' in w + + +async def test_command_pulp( + monkeypatch: pytest.MonkeyPatch, tester: CommandTester, tmp_path: pathlib.Path +): + """Run the repo-autoindex command against a sample pulp file repo and check the generated index.""" + monkeypatch.chdir(tmp_path) + + await tester("/sample_pulp_repo") + + # It should have written an index file + index_toplevel = tmp_path.joinpath("index.html") + assert index_toplevel.exists() + + # Simple sanity check of some expected content + toplevel = index_toplevel.read_text() + + assert 'file2.qcow2' in toplevel + + +async def test_command_no_content( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + tester: CommandTester, + caplog: pytest.LogCaptureFixture, +): + """Run the repo-autoindex command against an empty repo and verify nothing is written.""" + + caplog.set_level(logging.INFO) + + monkeypatch.chdir(tmp_path) + + await tester("/some-nonexistent-dir") + + # It should not have written any index.html + index_toplevel = tmp_path.joinpath("index.html") + assert not index_toplevel.exists() + + # It should have mentioned that there was no content + assert "No indexable content found" in caplog.text diff --git a/tests/test_http_fetcher.py b/tests/test_http_fetcher.py index 70172a5..9019cae 100644 --- a/tests/test_http_fetcher.py +++ b/tests/test_http_fetcher.py @@ -16,6 +16,7 @@ class FakeResponse: def __init__(self, body: bytes, content_type: str): self.body = body self.content_type = content_type + self.status = 200 async def __aenter__(self): return self diff --git a/tests/test_yum_render_typical.py b/tests/test_yum_render_typical.py index d12dd4e..882877a 100644 --- a/tests/test_yum_render_typical.py +++ b/tests/test_yum_render_typical.py @@ -1,6 +1,5 @@ from typing import Optional import textwrap -import pytest from repo_autoindex import autoindex from repo_autoindex._impl.base import GeneratedIndex @@ -395,7 +394,7 @@ class StaticFetcher: self.content: dict[str, str] = {} async def __call__(self, url: str) -> Optional[str]: - return self.content[url] + return self.content.get(url) async def test_typical_index():