Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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):

Expand All @@ -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.
Expand Down
59 changes: 47 additions & 12 deletions simple_repository_server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import argparse
from contextlib import asynccontextmanager
import importlib
import logging
import os
from pathlib import Path
Expand Down Expand Up @@ -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"

Expand All @@ -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)",
)


Expand All @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions simple_repository_server/tests/unit/test_entrypoint_loading.py
Original file line number Diff line number Diff line change
@@ -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