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
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@

from .releases import (
ReleaseTag,
get_release_page_url,
get_release_url,
is_release_url,
is_url,
resolve_release,
)

CACHED_DOWNLOADS_DIRECTORY = (
Expand Down Expand Up @@ -264,8 +263,11 @@ def from_release_spec(
"""
if cache_folder is None:
cache_folder = CACHED_DOWNLOADS_DIRECTORY
url = get_release_url(spec)
release_page = get_release_page_url(url)
# Resolve the spec once; the download URL and the release page
# both derive from the same release information.
release = resolve_release(spec)
url = release.get_asset(ReleaseTag.from_string(spec)).url
release_page = release.url

destination_folder = extract_to or FixtureDownloader.get_cache_path(
url, cache_folder
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Procedures to consume fixtures from Github releases."""

import json
import logging
import os
import re
from dataclasses import dataclass
Expand All @@ -11,7 +12,9 @@

import platformdirs
import requests
from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, Field, RootModel, ValidationError

logger = logging.getLogger(__name__)

CACHED_RELEASE_INFORMATION_FILE = (
Path(platformdirs.user_cache_dir("ethereum-execution-spec-tests"))
Expand Down Expand Up @@ -237,7 +240,15 @@ def download_release_information(
pagination links up to `max_pages` pages, so resolution sees the 200
most recent releases per repo. Older releases fall outside this
window and cannot be resolved.

Authenticate with `GITHUB_TOKEN` when set: authenticated requests
get 5000 requests/hour instead of the unauthenticated 60/hour per
IP address.
"""
headers = {}
github_token = os.environ.get("GITHUB_TOKEN")
if github_token:
headers["Authorization"] = f"Bearer {github_token}"
all_releases = []
for repo in SUPPORTED_REPOS:
current_url: str | None = (
Expand All @@ -246,7 +257,7 @@ def download_release_information(
max_pages = 2
while current_url and max_pages > 0:
max_pages -= 1
response = requests.get(current_url)
response = requests.get(current_url, headers=headers)
response.raise_for_status()
all_releases.extend(response.json())
current_url = None
Expand All @@ -260,8 +271,14 @@ def download_release_information(

if destination_file:
destination_file.parent.mkdir(parents=True, exist_ok=True)
with open(destination_file, "w") as file:
# Write via a temporary file so a concurrent reader never sees a
# partially-written cache.
temporary_file = destination_file.with_name(
destination_file.name + ".tmp"
)
with open(temporary_file, "w") as file:
json.dump(all_releases, file)
temporary_file.replace(destination_file)
return parse_release_information(all_releases)


Expand Down Expand Up @@ -310,6 +327,26 @@ def sort_key(
return max(matches, key=sort_key)


def resolves_pinned_release(
release_string: str,
release_information: List[ReleaseInformation],
) -> bool:
"""
Check whether the release information resolves a pinned version.

A release descriptor with an explicit version refers to an immutable
git tag: once it resolves, a refresh of the release information
cannot change the result.
"""
if ReleaseTag.from_string(release_string).version is None:
return False
try:
find_release(release_string, release_information)
except NoSuchReleaseError:
return False
return True


def get_release_url_from_release_information(
release_string: str, release_information: List[ReleaseInformation]
) -> str:
Expand All @@ -318,63 +355,72 @@ def get_release_url_from_release_information(
return release.get_asset(ReleaseTag.from_string(release_string)).url


def get_release_page_url(release_string: str) -> str:
def resolve_release(release_string: str) -> ReleaseInformation:
"""
Return the GitHub Release page URL for a specific release descriptor.

This function can handle:
- A release string (e.g., "tests@latest" or "bal-devnet@v7.0.0") from
any repo in `SUPPORTED_REPOS`.
- A direct asset download link (e.g.,
"https://github.com/ethereum/execution-specs/releases/
download/tests%40v20.0.0/fixtures.tar.gz").
Resolve a release descriptor string to its release information.

Refresh the cached release information beforehand as needed (see
`get_release_information`).
"""
release_information = get_release_information()
return find_release(
release_string, get_release_information(release_string)
)

# Case 1: If it's a direct GitHub Releases download link, find which
# release in `release_information` has an asset with this exact URL.
repo_pattern = "|".join(re.escape(repo) for repo in SUPPORTED_REPOS)
regex_pattern = rf"https://github\.com/({repo_pattern})/releases/download/"
if re.match(regex_pattern, release_string):
for release in release_information:
for asset in release.assets.root:
if asset.url == release_string:
return release.url # The HTML page for this release
raise NoSuchReleaseError(
f"No release found for asset URL: {release_string}"
)

# Case 2: Otherwise, treat it as a release descriptor (e.g.,
# "tests@latest")
return find_release(release_string, release_information).url
def get_release_page_url(release_string: str) -> str:
"""Get the GitHub release page URL for a release descriptor."""
return resolve_release(release_string).url


def get_release_information() -> List[ReleaseInformation]:
def get_release_information(
release_string: str | None = None,
) -> List[ReleaseInformation]:
"""
Get the release information.

First check if the cached release information file exists. If it does, but
it is older than 4 hours, delete the file, unless running inside a CI
environment or a Docker container. Then download the release information
from the Github API and save it to the cache file.
Get the release information, refreshing the cache file as needed.

Return the cached release information if the cache file is fresh
(younger than 4 hours; any age when running inside a CI environment
or a Docker container). A stale cache is also used without
refreshing when `release_string` pins an exact version that the
cache already resolves: release tags are immutable, so the cached
entry cannot be outdated. Otherwise re-download the release
information, keeping the stale cache as a fallback in case the
GitHub API is unavailable (e.g. rate-limited).
"""
cached_information: List[ReleaseInformation] | None = None
if CACHED_RELEASE_INFORMATION_FILE.exists():
last_modified = CACHED_RELEASE_INFORMATION_FILE.stat().st_mtime
if (
datetime.now().timestamp() - last_modified
) < 4 * 60 * 60 or is_docker_or_ci():
return parse_release_information_from_file(
try:
cached_information = parse_release_information_from_file(
CACHED_RELEASE_INFORMATION_FILE
)
CACHED_RELEASE_INFORMATION_FILE.unlink()
if not CACHED_RELEASE_INFORMATION_FILE.exists():
except (json.JSONDecodeError, ValidationError):
logger.warning(
"Ignoring corrupt release information cache at "
f"{CACHED_RELEASE_INFORMATION_FILE}."
)
else:
last_modified = CACHED_RELEASE_INFORMATION_FILE.stat().st_mtime
cache_age = datetime.now().timestamp() - last_modified
if cache_age < 4 * 60 * 60 or is_docker_or_ci():
return cached_information
if release_string is not None and resolves_pinned_release(
release_string, cached_information
):
return cached_information
try:
return download_release_information(CACHED_RELEASE_INFORMATION_FILE)
return parse_release_information_from_file(CACHED_RELEASE_INFORMATION_FILE)
except requests.RequestException as error:
if cached_information is None:
raise
logger.warning(
f"Could not refresh release information from the GitHub API "
f"({error}); falling back to the stale cache at "
f"{CACHED_RELEASE_INFORMATION_FILE}."
)
return cached_information


def get_release_url(release_string: str) -> str:
"""Get the URL for a specific release."""
release_information = get_release_information()
return get_release_url_from_release_information(
release_string, release_information
)
"""Get the asset download URL for a release descriptor."""
release = resolve_release(release_string)
return release.get_asset(ReleaseTag.from_string(release_string)).url
Original file line number Diff line number Diff line change
Expand Up @@ -39,34 +39,36 @@ def test_fixtures_source_from_release_spec_makes_api_calls(self) -> None:
test_spec = "tests@latest"

with patch(
"execution_testing.cli.pytest_commands.plugins.consume.consume.get_release_url"
) as mock_get_url:
mock_get_url.return_value = "https://github.com/ethereum/execution-specs/releases/download/tests%40v20.0.0/fixtures.tar.gz"
"execution_testing.cli.pytest_commands.plugins.consume.consume.resolve_release"
) as mock_resolve:
mock_release = MagicMock()
mock_release.url = "https://github.com/ethereum/execution-specs/releases/tag/tests%40v20.0.0"
mock_release.get_asset.return_value.url = "https://github.com/ethereum/execution-specs/releases/download/tests%40v20.0.0/fixtures.tar.gz"
mock_resolve.return_value = mock_release
with patch(
"execution_testing.cli.pytest_commands.plugins.consume.consume.get_release_page_url"
) as mock_get_page:
mock_get_page.return_value = "https://github.com/ethereum/execution-specs/releases/tag/tests%40v20.0.0"
with patch(
"execution_testing.cli.pytest_commands.plugins.consume.consume.FixtureDownloader"
) as mock_downloader:
mock_instance = MagicMock()
mock_instance.download_and_extract.return_value = (
False,
Path("/tmp/test"),
)
mock_downloader.return_value = mock_instance

source = FixturesSource.from_release_spec(test_spec)

# Verify API calls were made and release page is set
mock_get_url.assert_called_once_with(test_spec)
mock_get_page.assert_called_once_with(
"https://github.com/ethereum/execution-specs/releases/download/tests%40v20.0.0/fixtures.tar.gz"
)
assert (
source.release_page
== "https://github.com/ethereum/execution-specs/releases/tag/tests%40v20.0.0"
)
"execution_testing.cli.pytest_commands.plugins.consume.consume.FixtureDownloader"
) as mock_downloader:
mock_instance = MagicMock()
mock_instance.download_and_extract.return_value = (
False,
Path("/tmp/test"),
)
mock_downloader.return_value = mock_instance

source = FixturesSource.from_release_spec(test_spec)

# The spec is resolved exactly once; the download URL and
# the release page both derive from the same release
# information.
mock_resolve.assert_called_once_with(test_spec)
assert (
source.url
== "https://github.com/ethereum/execution-specs/releases/download/tests%40v20.0.0/fixtures.tar.gz"
)
assert (
source.release_page
== "https://github.com/ethereum/execution-specs/releases/tag/tests%40v20.0.0"
)

def test_fixtures_source_from_regular_url_no_release_page(self) -> None:
"""Test that regular URLs (non-GitHub) don't have release page."""
Expand Down
Loading
Loading