Skip to content
Closed
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
47 changes: 25 additions & 22 deletions e2e/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,42 +55,45 @@ export NETWORK_ISOLATION
export WHEEL_PLATFORM_TAG
export HAS_ELFDEP

# Local web server management
HTTP_SERVER_PID=""
# Background server management
_BACKGROUND_PIDS=()
on_exit() {
if [ -n "$HTTP_SERVER_PID" ]; then
echo "Stopping wheel server"
kill "$HTTP_SERVER_PID"
fi
# ${arr[@]+...} avoids "unbound variable" under set -u when empty
for pid in "${_BACKGROUND_PIDS[@]+"${_BACKGROUND_PIDS[@]}"}"; do
kill "$pid" 2>/dev/null || true
done
}
trap on_exit EXIT SIGINT SIGTERM

start_local_wheel_server() {
local serve_dir="${1:-$OUTDIR/wheels-repo}"
# Use the builtin fromager wheel-server (Starlette/uvicorn) instead of
# stdlib http.server. Binding to 127.0.0.1 is safe on all platforms
# because uv pip install always runs on the host without network
# isolation (build_environment.py hardcodes network_isolation=False).
fromager \
--wheels-repo="$serve_dir" \
wheel-server --port 9999 --address 127.0.0.1 &
HTTP_SERVER_PID=$!
export WHEEL_SERVER_URL="http://127.0.0.1:9999/simple"
# start_background_server NAME HEALTH_URL COMMAND...
start_background_server() {
local name="$1" health_url="$2"
shift 2

"$@" &
local pid=$!
_BACKGROUND_PIDS+=("$pid")

# Wait for the server to accept connections (up to 15 s).
{ set +x; } 2>/dev/null
local ready=false
for _ in $(seq 1 30); do
kill -0 "$HTTP_SERVER_PID" 2>/dev/null || break
curl -sf "http://127.0.0.1:9999/simple" >/dev/null 2>&1 && { ready=true; break; }
kill -0 "$pid" 2>/dev/null || break
curl -sf "$health_url" >/dev/null 2>&1 && { ready=true; break; }
sleep 0.5
done
set -x

if $ready; then
echo "Wheel server is ready"
echo "$name is ready"
return 0
fi
echo "ERROR: wheel server did not become ready" >&2
echo "ERROR: $name did not become ready" >&2
return 1
}

start_local_wheel_server() {
local serve_dir="${1:-$OUTDIR/wheels-repo}"
start_background_server "Wheel server" "http://127.0.0.1:9999/simple" \
fromager --wheels-repo="$serve_dir" wheel-server --port 9999 --address 127.0.0.1
export WHEEL_SERVER_URL="http://127.0.0.1:9999/simple"
}
35 changes: 35 additions & 0 deletions e2e/github_override_example/mock_api/serve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Minimal mock GitHub API server for e2e tests.

Serves static tag JSON for the stevedore-test-repo at the expected
GitHub API path.
"""

from __future__ import annotations

import pathlib
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer

TAGS_PATH = "/repos/python-wheel-build/stevedore-test-repo/tags"
TAGS_JSON = (pathlib.Path(__file__).parent / "tags.json").read_bytes()


class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path == TAGS_PATH:
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(TAGS_JSON)
else:
self.send_error(404)

def log_message(self, format: str, *args: object) -> None:
pass


if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 9998
server = HTTPServer(("127.0.0.1", port), Handler)
print(f"Mock GitHub API listening on http://127.0.0.1:{port}", flush=True)
server.serve_forever()
52 changes: 52 additions & 0 deletions e2e/github_override_example/mock_api/tags.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
[
{
"name": "5.4.1",
"zipball_url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/zipball/refs/tags/5.4.1",
"tarball_url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/tarball/refs/tags/5.4.1",
"commit": {
"sha": "27e27c116c0e8b84415d683e958780f4607ca3cc",
"url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/commits/27e27c116c0e8b84415d683e958780f4607ca3cc"
},
"node_id": "REF_kwDOO7Smz69yZWZzL3RhZ3MvNS40LjE"
},
{
"name": "5.4.0",
"zipball_url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/zipball/refs/tags/5.4.0",
"tarball_url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/tarball/refs/tags/5.4.0",
"commit": {
"sha": "016740e3cc7b0f254cb952296314d7ea7d9cdd20",
"url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/commits/016740e3cc7b0f254cb952296314d7ea7d9cdd20"
},
"node_id": "REF_kwDOO7Smz69yZWZzL3RhZ3MvNS40LjA"
},
{
"name": "5.3.0",
"zipball_url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/zipball/refs/tags/5.3.0",
"tarball_url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/tarball/refs/tags/5.3.0",
"commit": {
"sha": "51134a4dc04872df9f736e5ae52d4b665c40e364",
"url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/commits/51134a4dc04872df9f736e5ae52d4b665c40e364"
},
"node_id": "REF_kwDOO7Smz69yZWZzL3RhZ3MvNS4zLjA"
},
{
"name": "5.2.0",
"zipball_url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/zipball/refs/tags/5.2.0",
"tarball_url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/tarball/refs/tags/5.2.0",
"commit": {
"sha": "21d601f3f568f2a8b1b6447931a73554eb60703a",
"url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/commits/21d601f3f568f2a8b1b6447931a73554eb60703a"
},
"node_id": "REF_kwDOO7Smz69yZWZzL3RhZ3MvNS4yLjA"
},
{
"name": "5.1.0",
"zipball_url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/zipball/refs/tags/5.1.0",
"tarball_url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/tarball/refs/tags/5.1.0",
"commit": {
"sha": "2d99cccf9a6c04509785e76a5c8e89ae2824c939",
"url": "https://api.github.com/repos/python-wheel-build/stevedore-test-repo/commits/2d99cccf9a6c04509785e76a5c8e89ae2824c939"
},
"node_id": "REF_kwDOO7Smz69yZWZzL3RhZ3MvNS4xLjA"
}
]
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

from packaging.requirements import Requirement

from fromager import context, resolver
Expand All @@ -13,6 +15,10 @@ def get_resolver_provider(
ignore_platform: bool = False,
) -> resolver.GitHubTagProvider:
"""Return a GitHubTagProvider for the stevedore test repo on github.com."""
kwargs: dict[str, str] = {}
github_api_url = os.environ.get("GITHUB_API_URL")
if github_api_url:
kwargs["server_url"] = github_api_url
return resolver.GitHubTagProvider(
organization="python-wheel-build",
repo="stevedore-test-repo",
Expand All @@ -22,4 +28,5 @@ def get_resolver_provider(
"https://github.com/{organization}/{repo}"
"/archive/refs/tags/{tagname}.tar.gz"
),
**kwargs,
)
19 changes: 15 additions & 4 deletions e2e/test_bootstrap_cooldown_github.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
# the GitHubTagProvider, the cooldown is NOT enforced (because GitHub does not
# yet provide upload timestamps), but a warning is emitted for each candidate.
#
# The stevedore test repo (python-wheel-build/stevedore-test-repo) is used as
# a convenient GitHub-hosted package with known tags.
# A local mock server serves static tag JSON instead of hitting the real GitHub
# API, so the test is deterministic and does not consume rate-limited API calls.
#
# The stevedore test repo (python-wheel-build/stevedore-test-repo) tag structure
# is reproduced in mock_api/tags.json. The latest version is 5.4.1 (released
# 2025-02-20 on the real repo).
#
# MIN_AGE is anchored to stevedore 5.4.1 (2025-02-20), so it is large enough
# that enforcement WOULD block the resolved candidate — confirming that the
Expand All @@ -25,9 +29,16 @@ age = (date.today() - date(2025, 2, 20)).days
print(age + 1)
")

# Start a local mock GitHub API server.
MOCK_API_DIR="$SCRIPTDIR/github_override_example/mock_api"
GITHUB_MOCK_PORT=9998
start_background_server "Mock GitHub API" \
"http://127.0.0.1:${GITHUB_MOCK_PORT}/repos/python-wheel-build/stevedore-test-repo/tags" \
python3 "$MOCK_API_DIR/serve.py" "$GITHUB_MOCK_PORT"
trap 'python3 -m pip uninstall -y github_override_example >/dev/null 2>&1 || true; on_exit' EXIT
export GITHUB_API_URL="http://127.0.0.1:${GITHUB_MOCK_PORT}"

# Install the override plugin that routes stevedore through GitHubTagProvider.
# Uninstall on exit so its entry points don't leak into subsequent e2e tests.
trap 'python3 -m pip uninstall -y github_override_example >/dev/null 2>&1 || true' EXIT
pip install "$SCRIPTDIR/github_override_example"

fromager \
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ ignore = [
"UP015", # Unnecessary open mode parameters
]

[tool.ruff.lint.per-file-ignores]
"e2e/github_override_example/mock_api/serve.py" = ["N802"]

[tool.ruff.lint.isort]
known-first-party = ["fromager"]

Expand Down
11 changes: 6 additions & 5 deletions src/fromager/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1065,15 +1065,14 @@ class GitHubTagProvider(GenericProvider):
"""

provider_description: typing.ClassVar[str] = (
"GitHub tag resolver (repository: {self.organization}/{self.repo})"
"GitHub tag resolver (repository: {self.server_url} {self.organization}/{self.repo})"
)
host = "github.com:443"
api_url = "https://api.{self.host}/repos/{self.organization}/{self.repo}/tags"

def __init__(
self,
organization: str,
repo: str,
server_url: str = "https://api.github.com",
constraints: Constraints | None = None,
matcher: MatchFunction | re.Pattern | None = None,
*,
Expand All @@ -1092,11 +1091,13 @@ def __init__(
)
self.organization = organization
self.repo = repo
self.server_url = server_url.rstrip("/")
self.api_url = f"{self.server_url}/repos/{self.organization}/{self.repo}/tags"
self.override_download_url = override_download_url

@property
def cache_key(self) -> str:
key = f"{self.organization}/{self.repo}"
key = f"{self.server_url}/{self.organization}/{self.repo}"
if self.override_download_url is not None:
key = f"{key}+{self.override_download_url}"
return key
Expand All @@ -1118,7 +1119,7 @@ def _find_tags(
if github_token:
headers["Authorization"] = f"token {github_token}"

nexturl = self.api_url.format(self=self)
nexturl = self.api_url
while nexturl:
resp = session.get(nexturl, headers=headers)
resp.raise_for_status()
Expand Down
26 changes: 13 additions & 13 deletions tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,11 @@ def github_fromager_resolver() -> typing.Generator[
]:
with requests_mock.Mocker() as r:
r.get(
"https://api.github.com:443/repos/python-wheel-build/fromager",
"https://api.github.com/repos/python-wheel-build/fromager",
text=_github_fromager_repo_response,
)
r.get(
"https://api.github.com:443/repos/python-wheel-build/fromager/tags",
"https://api.github.com/repos/python-wheel-build/fromager/tags",
text=_github_fromager_tag_response,
)

Expand Down Expand Up @@ -168,7 +168,7 @@ def test_provider_cache_key_gitlab(gitlab_decile_resolver: typing.Any) -> None:

def test_provider_cache_key_github(github_fromager_resolver: typing.Any) -> None:
provider = github_fromager_resolver.provider
assert provider.cache_key == "python-wheel-build/fromager"
assert provider.cache_key == "https://api.github.com/python-wheel-build/fromager"


def test_cache_not_overly_aggressive() -> None:
Expand Down Expand Up @@ -708,11 +708,11 @@ def test_provider_ignore_platform() -> None:
def test_resolve_github() -> None:
with requests_mock.Mocker() as r:
r.get(
"https://api.github.com:443/repos/python-wheel-build/fromager",
"https://api.github.com/repos/python-wheel-build/fromager",
text=_github_fromager_repo_response,
)
r.get(
"https://api.github.com:443/repos/python-wheel-build/fromager/tags",
"https://api.github.com/repos/python-wheel-build/fromager/tags",
text=_github_fromager_tag_response,
)

Expand Down Expand Up @@ -740,11 +740,11 @@ def test_resolve_github() -> None:
def test_resolve_github_override_download_url() -> None:
with requests_mock.Mocker() as r:
r.get(
"https://api.github.com:443/repos/python-wheel-build/fromager",
"https://api.github.com/repos/python-wheel-build/fromager",
text=_github_fromager_repo_response,
)
r.get(
"https://api.github.com:443/repos/python-wheel-build/fromager/tags",
"https://api.github.com/repos/python-wheel-build/fromager/tags",
text=_github_fromager_tag_response,
)

Expand All @@ -769,11 +769,11 @@ def test_github_constraint_mismatch() -> None:
constraint.add_constraint("fromager>=1.0")
with requests_mock.Mocker() as r:
r.get(
"https://api.github.com:443/repos/python-wheel-build/fromager",
"https://api.github.com/repos/python-wheel-build/fromager",
text=_github_fromager_repo_response,
)
r.get(
"https://api.github.com:443/repos/python-wheel-build/fromager/tags",
"https://api.github.com/repos/python-wheel-build/fromager/tags",
text=_github_fromager_tag_response,
)

Expand All @@ -792,11 +792,11 @@ def test_github_constraint_match() -> None:
constraint.add_constraint("fromager<0.9")
with requests_mock.Mocker() as r:
r.get(
"https://api.github.com:443/repos/python-wheel-build/fromager",
"https://api.github.com/repos/python-wheel-build/fromager",
text=_github_fromager_repo_response,
)
r.get(
"https://api.github.com:443/repos/python-wheel-build/fromager/tags",
"https://api.github.com/repos/python-wheel-build/fromager/tags",
text=_github_fromager_tag_response,
)

Expand Down Expand Up @@ -1206,7 +1206,7 @@ def test_custom_resolver_error_message_missing_tag() -> None:
with requests_mock.Mocker() as r:
# Mock GitHub API to return empty tags (simulating missing tag)
r.get(
"https://api.github.com:443/repos/test-org/test-repo/tags",
"https://api.github.com/repos/test-org/test-repo/tags",
json=[], # Empty tags list - tag doesn't exist
)

Expand Down Expand Up @@ -1244,7 +1244,7 @@ def custom_resolver_provider(
with requests_mock.Mocker() as r:
# Mock GitHub API to return empty tags
r.get(
"https://api.github.com:443/repos/test-org/test-repo/tags",
"https://api.github.com/repos/test-org/test-repo/tags",
json=[],
)

Expand Down
Loading