From dc5ede500cb818f5e1f99652d135b3944e8163e1 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Thu, 18 Sep 2025 07:34:51 +0200 Subject: [PATCH 1/3] Enable netrc based authentication by default, as per uv and pip --- README.md | 5 ++ simple_repository_server/__main__.py | 34 ++++++++- .../tests/test_netrc_auth.py | 75 +++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 simple_repository_server/tests/test_netrc_auth.py diff --git a/README.md b/README.md index 1ea2dcf..1ae3e72 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,11 @@ 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 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. diff --git a/simple_repository_server/__main__.py b/simple_repository_server/__main__.py index 5684189..b9f9524 100644 --- a/simple_repository_server/__main__.py +++ b/simple_repository_server/__main__.py @@ -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 @@ -29,6 +30,27 @@ 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. + Checks NETRC environment variable first, then ~/.netrc. + Returns None if no netrc file is found. + """ + # Check if NETRC environment variable is set + netrc_env = os.environ.get('NETRC') + if netrc_env: + netrc_path = Path(netrc_env) + if netrc_path.exists(): + return netrc_path + + # Check for default ~/.netrc file + default_netrc = Path.home() / '.netrc' + if default_netrc.exists(): + return default_netrc + + return None + + def configure_parser(parser: argparse.ArgumentParser) -> None: parser.description = "Run a Python Package Index" @@ -39,6 +61,7 @@ def configure_parser(parser: argparse.ArgumentParser) -> None: def create_repository( repository_urls: list[str], + *, http_client: httpx.AsyncClient, ) -> SimpleRepository: base_repos: list[SimpleRepository] = [] @@ -65,8 +88,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 + client_kwargs = {} + netrc_path = get_netrc_path() + if netrc_path: + logging.info(f"Using netrc authentication from: {netrc_path}") + client_kwargs["auth"] = httpx.NetRCAuth(file=str(netrc_path)) + + async with httpx.AsyncClient(**client_kwargs) as http_client: + repo = create_repository(repository_urls, http_client=http_client) app.include_router(simple.build_router(repo, http_client=http_client)) yield diff --git a/simple_repository_server/tests/test_netrc_auth.py b/simple_repository_server/tests/test_netrc_auth.py new file mode 100644 index 0000000..65a7107 --- /dev/null +++ b/simple_repository_server/tests/test_netrc_auth.py @@ -0,0 +1,75 @@ +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 returns None when no netrc file exists.""" + monkeypatch.setenv('NETRC', str(netrc_file)) + result = get_netrc_path() + assert result == netrc_file + + +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) From 1eea8aec05aca5f7fd55de5f38fe3454151b9e8d Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Thu, 18 Sep 2025 07:44:20 +0200 Subject: [PATCH 2/3] Don't fall back to ~/.netrc if the NETRC env var specified doesn't exist, thus allowing you to disable netrc by setting a non-existent path --- README.md | 5 +++-- simple_repository_server/__main__.py | 15 +++++++++------ .../tests/test_netrc_auth.py | 16 +++++++++++++++- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1ae3e72..361e044 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,9 @@ mechanism to disable those features. For more control, please see the "Non CLI u ## Authentication -The server automatically supports netrc-based authentication for private 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. - +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 diff --git a/simple_repository_server/__main__.py b/simple_repository_server/__main__.py index b9f9524..ad111ae 100644 --- a/simple_repository_server/__main__.py +++ b/simple_repository_server/__main__.py @@ -32,20 +32,23 @@ def is_url(url: str) -> bool: def get_netrc_path() -> typing.Optional[Path]: """ - Get the netrc file path if it exists. + Get the netrc file path if it exists and is a regular file. Checks NETRC environment variable first, then ~/.netrc. - Returns None if no netrc file is found. + 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. """ - # Check if NETRC environment variable is set netrc_env = os.environ.get('NETRC') if netrc_env: netrc_path = Path(netrc_env) - if netrc_path.exists(): + 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 - # Check for default ~/.netrc file default_netrc = Path.home() / '.netrc' - if default_netrc.exists(): + if default_netrc.exists() and default_netrc.is_file(): return default_netrc return None diff --git a/simple_repository_server/tests/test_netrc_auth.py b/simple_repository_server/tests/test_netrc_auth.py index 65a7107..9f3e0e0 100644 --- a/simple_repository_server/tests/test_netrc_auth.py +++ b/simple_repository_server/tests/test_netrc_auth.py @@ -46,12 +46,26 @@ def test_get_netrc__path_in_home(tmp_home: Path, netrc_file: Path): def test_get_netrc__netrc_env_var(netrc_file: Path, monkeypatch: pytest.MonkeyPatch): - """Test get_netrc_path returns None when no netrc file exists.""" + """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( From bffa622dab9e6c6959418654c01939ff211a17d7 Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Thu, 18 Sep 2025 07:49:47 +0200 Subject: [PATCH 3/3] Simplify the auth invocation, fixing the type issue --- simple_repository_server/__main__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/simple_repository_server/__main__.py b/simple_repository_server/__main__.py index ad111ae..05b8729 100644 --- a/simple_repository_server/__main__.py +++ b/simple_repository_server/__main__.py @@ -92,13 +92,13 @@ def create_app(repository_urls: list[str]) -> fastapi.FastAPI: @asynccontextmanager async def lifespan(app: FastAPI) -> typing.AsyncIterator[None]: # Configure httpx client with netrc support if netrc file exists - client_kwargs = {} netrc_path = get_netrc_path() + auth: typing.Optional[httpx.Auth] = None if netrc_path: logging.info(f"Using netrc authentication from: {netrc_path}") - client_kwargs["auth"] = httpx.NetRCAuth(file=str(netrc_path)) + auth = httpx.NetRCAuth(file=str(netrc_path)) - async with httpx.AsyncClient(**client_kwargs) as http_client: + 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