Skip to content
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ It is expected that as new features appear in the underlying ``simple-repository
which make general sense to enable by default will be introduced into the CLI without providing a
mechanism to disable those features. For more control, please see the "Non CLI usage" section.

## Authentication

The server automatically supports netrc-based authentication for private http repositories.
If a `.netrc` file exists in your home directory or is specified via the `NETRC` environment
variable, the server will use those credentials when accessing HTTP repositories.

## Non CLI usage

This project provides a number of tools in order to build a repository service using FastAPI.
Expand Down
37 changes: 35 additions & 2 deletions simple_repository_server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import argparse
from contextlib import asynccontextmanager
import logging
import os
from pathlib import Path
import typing
from urllib.parse import urlparse
Expand All @@ -29,6 +30,30 @@ def is_url(url: str) -> bool:
return urlparse(url).scheme in ("http", "https")


def get_netrc_path() -> typing.Optional[Path]:
"""
Get the netrc file path if it exists and is a regular file.
Checks NETRC environment variable first, then ~/.netrc.
Returns None if no valid netrc file is found.

If NETRC is explicitly set but points to a non-existent or invalid file,
returns None.
"""
netrc_env = os.environ.get('NETRC')
if netrc_env:
netrc_path = Path(netrc_env)
if netrc_path.exists() and netrc_path.is_file():
return netrc_path
# If NETRC is explicitly set but invalid, don't fall back to ~/.netrc
return None

default_netrc = Path.home() / '.netrc'
if default_netrc.exists() and default_netrc.is_file():
return default_netrc

return None


def configure_parser(parser: argparse.ArgumentParser) -> None:
parser.description = "Run a Python Package Index"

Expand All @@ -39,6 +64,7 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:

def create_repository(
repository_urls: list[str],
*,
http_client: httpx.AsyncClient,
) -> SimpleRepository:
base_repos: list[SimpleRepository] = []
Expand All @@ -65,8 +91,15 @@ def create_repository(
def create_app(repository_urls: list[str]) -> fastapi.FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI) -> typing.AsyncIterator[None]:
async with httpx.AsyncClient() as http_client:
repo = create_repository(repository_urls, http_client)
# Configure httpx client with netrc support if netrc file exists
netrc_path = get_netrc_path()
auth: typing.Optional[httpx.Auth] = None
if netrc_path:
logging.info(f"Using netrc authentication from: {netrc_path}")
auth = httpx.NetRCAuth(file=str(netrc_path))

async with httpx.AsyncClient(auth=auth) as http_client:
repo = create_repository(repository_urls, http_client=http_client)
app.include_router(simple.build_router(repo, http_client=http_client))
yield

Expand Down
89 changes: 89 additions & 0 deletions simple_repository_server/tests/test_netrc_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from pathlib import Path
import textwrap
from unittest import mock

from fastapi.testclient import TestClient
import httpx
import pytest

from simple_repository_server.__main__ import create_app, get_netrc_path


@pytest.fixture
def netrc_file(tmp_path: Path) -> Path:
"""Create a temporary netrc file for testing."""
netrc = tmp_path / 'my-netrc'
netrc.write_text(
textwrap.dedent("""\n
machine gitlab.example.com
login deploy-token-123
password glpat-xxxxxxxxxxxxxxxxxxxx
"""),
)
return netrc


@pytest.fixture
def tmp_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
homedir = tmp_path / 'my-home'
homedir.mkdir()
monkeypatch.setattr(Path, 'home', lambda: homedir)
return homedir


def test_get_netrc__path_not_in_home(tmp_home: Path, netrc_file: Path):
"""Test get_netrc_path returns None when no netrc file exists."""
result = get_netrc_path()
assert result is None


def test_get_netrc__path_in_home(tmp_home: Path, netrc_file: Path):
"""Test get_netrc_path returns None when no netrc file exists."""
home_netrc = tmp_home / '.netrc'
netrc_file.rename(home_netrc)
result = get_netrc_path()
assert result == home_netrc


def test_get_netrc__netrc_env_var(netrc_file: Path, monkeypatch: pytest.MonkeyPatch):
"""Test get_netrc_path uses NETRC environment variable when file exists."""
monkeypatch.setenv('NETRC', str(netrc_file))
result = get_netrc_path()
assert result == netrc_file


def test_get_netrc__netrc_env_var_nonexistent(tmp_home: Path, netrc_file: Path, monkeypatch: pytest.MonkeyPatch):
"""Test get_netrc_path returns None when NETRC points to non-existent file (no fallback)."""
# Create ~/.netrc in home directory
home_netrc = tmp_home / '.netrc'
netrc_file.rename(home_netrc)

# Set NETRC to non-existent file
monkeypatch.setenv('NETRC', str(tmp_home / 'doesnt_exist'))
result = get_netrc_path()

# Should return None, NOT fall back to ~/.netrc
assert result is None


def test_create_app__with_netrc(netrc_file: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv('NETRC', str(netrc_file))
with mock.patch(
'simple_repository_server.__main__.create_repository',
) as mock_create_repository:
app = create_app(["https://gitlab.example.com/simple/"])

# Create a test client which will trigger the lifespan context
with TestClient(app):
pass

# Verify create_repository was called
assert mock_create_repository.called
args, kwargs = mock_create_repository.call_args

http_client = kwargs['http_client']

# Verify it's an AsyncClient with NetRCAuth
assert isinstance(http_client, httpx.AsyncClient)
assert http_client._auth is not None
assert isinstance(http_client._auth, httpx.NetRCAuth)