From dc044811c3d9a396993d75737654eca76da3d7a3 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Fri, 17 Oct 2025 16:50:09 +0200 Subject: [PATCH] Add support for supplying custom SimpleRepository definitions to the CLI --- README.md | 45 +++++++++++++- simple_repository_server/__main__.py | 59 +++++++++++++++---- .../tests/unit/test_entrypoint_loading.py | 54 +++++++++++++++++ 3 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 simple_repository_server/tests/unit/test_entrypoint_loading.py diff --git a/README.md b/README.md index 12cf869..c081a3c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ usage: simple-repository-server [-h] [--port PORT] repository-url [repository-ur Run a Simple Repository Server positional arguments: - repository-url Repository URL (http/https) or local directory path + repository-url Repository URL (http/https), local directory path, or Python entrypoint (module:callable) options: -h, --help show this help message and exit @@ -75,7 +75,7 @@ mechanism to disable those features. For more control, please see the "Non CLI u ## Repository sources -The server can work with both remote repositories and local directories: +The server can work with remote repositories, local directories, and user-defined `SimpleRepository` factories: ```bash # Remote repository @@ -84,10 +84,15 @@ python -m simple_repository_server https://pypi.org/simple/ # Local directory python -m simple_repository_server /path/to/local/packages/ -# Multiple sources (priority order, local having precedence) +# Python entrypoint (callable with no args and returns a SimpleRepository instance) +python -m simple_repository_server some_package.subpackage:create_repo + +# Multiple sources (priority order, local having precedence due to it being declared first) python -m simple_repository_server /path/to/local/packages/ https://pypi.org/simple/ ``` +### Local directories + Local directories should be organised with each project in its own subdirectory using the canonical package name (lowercase, with hyphens instead of underscores): @@ -103,6 +108,40 @@ canonical package name (lowercase, with hyphens instead of underscores): If metadata files are in the local repository they will be served directly, otherwise they will be extracted on-the-fly and served. +### User defined SimpleRepository factories + +For advanced use cases, you can provide a Python entrypoint specification that returns +a `SimpleRepository` instance. This allows maximum configuration flexibility without +having to set up a custom FastAPI application. + +The entrypoint format is `module.path:callable`, where: +- The module path uses standard Python import syntax +- The callable will be invoked with no arguments, and must return a `SimpleRepository` instance + + +Example entrypoint in `mypackage/repos.py`: + +```python +from simple_repository.components.core import SimpleRepository +from simple_repository.components.http import HttpRepository +from simple_repository.components.allow_listed import AllowListedRepository + +def create_repository() -> SimpleRepository: + """Factory function that creates a custom repository configuration""" + base = HttpRepository("https://pypi.org/simple/") + # Only allow specific packages + return AllowListedRepository( + source=base, + allowed_projects=["numpy", "pandas", "scipy"] + ) +``` + +Then use it (assuming it is on the PYTHONPATH) with: + +```bash +python -m simple_repository_server mypackage.repos:create_repository +``` + ## Authentication The server automatically supports netrc-based authentication for private http repositories. diff --git a/simple_repository_server/__main__.py b/simple_repository_server/__main__.py index a163aef..c2354f5 100644 --- a/simple_repository_server/__main__.py +++ b/simple_repository_server/__main__.py @@ -7,6 +7,7 @@ import argparse from contextlib import asynccontextmanager +import importlib import logging import os from pathlib import Path @@ -54,6 +55,49 @@ def get_netrc_path() -> typing.Optional[Path]: return None +def load_repository_from_spec(spec: str, *, http_client: httpx.AsyncClient) -> SimpleRepository: + """ + Load a repository from a specification string. + + The spec can be: + - An HTTP/HTTPS URL (e.g., "https://pypi.org/simple/") + - An existing filesystem directory (e.g., "/path/to/packages") + - A Python entrypoint specification (e.g., "mymodule:create_repo") + + For entrypoint specifications: + - The format is "module.path:callable" + - The callable, invoked with no arguments, must return a SimpleRepository instance + """ + # Check if it's an HTTP URL + if is_url(spec): + return HttpRepository(url=spec, http_client=http_client) + + # Check if it's an existing filesystem path + path = Path(spec) + if path.exists() and path.is_dir(): + return LocalRepository(path) + + # Try to load as Python entrypoint + if ":" not in spec: + raise ValueError( + f"Invalid repository specification: '{spec}'. " + "Must be an HTTP URL, file path, or entrypoint (module:callable)", + ) + + module_path, attr_name = spec.rsplit(":", 1) + module = importlib.import_module(module_path) + obj = getattr(module, attr_name) + # Call it and verify the result + result = obj() + if not isinstance(result, SimpleRepository): + raise TypeError( + f"Entrypoint '{spec}' must return a SimpleRepository instance, " + f"got {type(result).__name__}", + ) + + return result + + def configure_parser(parser: argparse.ArgumentParser) -> None: parser.description = "Run a Python Package Index" @@ -66,7 +110,7 @@ def configure_parser(parser: argparse.ArgumentParser) -> None: ) parser.add_argument( "repository_url", metavar="repository-url", type=str, nargs="+", - help="Repository URL (http/https) or local directory path", + help="Repository URL (http/https), local directory path, or Python entrypoint (module:callable)", ) @@ -76,17 +120,8 @@ def create_repository( http_client: httpx.AsyncClient, ) -> SimpleRepository: base_repos: list[SimpleRepository] = [] - repo: SimpleRepository - for repo_url in repository_urls: - if is_url(repo_url): - repo = HttpRepository( - url=repo_url, - http_client=http_client, - ) - else: - repo = LocalRepository( - index_path=Path(repo_url), - ) + for repo_spec in repository_urls: + repo = load_repository_from_spec(repo_spec, http_client=http_client) base_repos.append(repo) if len(base_repos) > 1: diff --git a/simple_repository_server/tests/unit/test_entrypoint_loading.py b/simple_repository_server/tests/unit/test_entrypoint_loading.py new file mode 100644 index 0000000..5cc15a0 --- /dev/null +++ b/simple_repository_server/tests/unit/test_entrypoint_loading.py @@ -0,0 +1,54 @@ +# Copyright (C) 2023, CERN +# This software is distributed under the terms of the MIT +# licence, copied verbatim in the file "LICENSE". +# In applying this license, CERN does not waive the privileges and immunities +# granted to it by virtue of its status as Intergovernmental Organization +# or submit itself to any jurisdiction. + +from pathlib import Path +from unittest import mock + +import httpx +import pytest +from simple_repository.components.core import SimpleRepository +from simple_repository.components.http import HttpRepository +from simple_repository.components.local import LocalRepository + +from simple_repository_server.__main__ import load_repository_from_spec + +repo_dir = Path(__file__).parent + + +def _test_repo_factory() -> SimpleRepository: + # Test helper: factory function that returns a repository + return LocalRepository(index_path=repo_dir) + + +@pytest.fixture +def http_client(): + return mock.Mock(spec=httpx.AsyncClient) + + +def test_load_http_url(http_client): + """HTTP URLs should create HttpRepository""" + repo = load_repository_from_spec("https://pypi.org/simple/", http_client=http_client) + assert isinstance(repo, HttpRepository) + assert repo._source_url == "https://pypi.org/simple/" + + +def test_load_existing_path(tmp_path, http_client): + """Existing directory paths should create LocalRepository""" + test_dir = tmp_path / "packages" + test_dir.mkdir() + + repo = load_repository_from_spec(str(test_dir), http_client=http_client) + assert isinstance(repo, LocalRepository) + assert repo._index_path == test_dir + + +def test_load_entrypoint(http_client): + """Entrypoint spec should load and call the factory""" + spec = "simple_repository_server.tests.unit.test_entrypoint_loading:_test_repo_factory" + repo = load_repository_from_spec(spec, http_client=http_client) + assert isinstance(repo, LocalRepository) + assert repo._index_path == repo_dir