Skip to content

feat(consume): add support for ethereum/{tests,legacytests} tarball urls #1306

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Mar 17, 2025
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
17 changes: 16 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,31 @@

Test fixtures for use by clients are available for each release on the [Github releases page](https://github.com/ethereum/execution-spec-tests/releases).

**Key:** ✨ = New, 🐞 = Fixed, 🔀 = Changed.
**Key:** ✨ = New, 🐞 = Fixed, 🔀 = Changed. 💥 = Breaking

## 🔜 [Unreleased]

**Note**: Although not a breaking change, `consume` users should delete the cache directory (typically located at `~/.cache/ethereum-execution-spec-tests`) used to store downloaded fixture release tarballs. This release adds support for [ethereum/tests](https://github.com/ethereum/tests) and [ethereum/legacytests](https://github.com/ethereum/legacytests) fixture release downloads and the structure of the cache directory has been updated to accommodate this change.

To try this feature:

```shell
consume direct --input=https://github.com/ethereum/tests/releases/download/v17.0/fixtures_blockchain_tests.tgz
```

To determine the cache directory location, see the `--cache-folder` entry from the command:

```shell
consume cache --help
```

### 💥 Breaking Change

### 🛠️ Framework

#### `consume`

- ✨ Add support for [ethereum/tests](https://github.com/ethereum/tests) and [ethereum/legacytests](https://github.com/ethereum/legacytests) release tarball download via URL to the `--input` flag of `consume` commands ([#1306](https://github.com/ethereum/execution-spec-tests/pull/1306)).
- ✨ Add support for Nethermind's `nethtest` command to `consume direct` ([#1250](https://github.com/ethereum/execution-spec-tests/pull/1250)).
- ✨ Allow filtering of test cases by fork via pytest marks (e.g., `-m "Cancun or Prague"`) ([#1304](https://github.com/ethereum/execution-spec-tests/pull/1304), [#1318](https://github.com/ethereum/execution-spec-tests/pull/1318)).
- ✨ Allow filtering of test cases by fixture format via pytest marks (e.g., `-m blockchain_test`) ([#1314](https://github.com/ethereum/execution-spec-tests/pull/1314)).
Expand Down
77 changes: 56 additions & 21 deletions src/pytest_plugins/consume/consume.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,58 @@ def default_html_report_file_path() -> str:
return ".meta/report_consume.html"


class FixtureDownloader:
"""Handles downloading and extracting fixture archives."""

def __init__(self, url: str, base_directory: Path): # noqa: D107
self.url = url
self.base_directory = base_directory
self.parsed_url = urlparse(url)
self.org_repo = self.extract_github_repo()
self.version = Path(self.parsed_url.path).parts[-2]
self.archive_name = self.strip_archive_extension(Path(self.parsed_url.path).name)
self.extract_to = base_directory / self.org_repo / self.version / self.archive_name

def download_and_extract(self) -> Tuple[bool, Path]:
"""Download the URL and extract it locally if it hasn't already been downloaded."""
if self.extract_to.exists():
return True, self.detect_extracted_directory()

return False, self.fetch_and_extract()

def extract_github_repo(self) -> str:
"""Extract <username>/<repo> from GitHub URLs, otherwise return 'other'."""
parts = self.parsed_url.path.strip("/").split("/")
return (
f"{parts[0]}/{parts[1]}"
if self.parsed_url.netloc == "github.com" and len(parts) >= 2
else "other"
)

@staticmethod
def strip_archive_extension(filename: str) -> str:
"""Remove .tar.gz or .tgz extensions from filename."""
return filename.removesuffix(".tar.gz").removesuffix(".tgz")

def fetch_and_extract(self) -> Path:
"""Download and extract an archive from the given URL."""
self.extract_to.mkdir(parents=True, exist_ok=False)
response = requests.get(self.url)
response.raise_for_status()

with tarfile.open(fileobj=BytesIO(response.content), mode="r:gz") as tar:
tar.extractall(path=self.extract_to)

return self.detect_extracted_directory()

def detect_extracted_directory(self) -> Path:
"""
Detect a single top-level dir within the extracted archive, otherwise return extract_to.
""" # noqa: D200
extracted_dirs = [d for d in self.extract_to.iterdir() if d.is_dir()]
return extracted_dirs[0] if len(extracted_dirs) == 1 else self.extract_to


@dataclass
class FixturesSource:
"""Represents the source of test fixtures."""
Expand Down Expand Up @@ -70,7 +122,8 @@ def from_input(cls, input_source: str) -> "FixturesSource":
def from_url(cls, url: str) -> "FixturesSource":
"""Create a fixture source from a direct URL."""
release_page = get_release_page_url(url)
was_cached, path = download_and_extract(url, CACHED_DOWNLOADS_DIRECTORY)
downloader = FixtureDownloader(url, CACHED_DOWNLOADS_DIRECTORY)
was_cached, path = downloader.download_and_extract()
return cls(
input_option=url,
path=path,
Expand All @@ -85,7 +138,8 @@ def from_release_spec(cls, spec: str) -> "FixturesSource":
"""Create a fixture source from a release spec (e.g., develop@latest)."""
url = get_release_url(spec)
release_page = get_release_page_url(url)
was_cached, path = download_and_extract(url, CACHED_DOWNLOADS_DIRECTORY)
downloader = FixtureDownloader(url, CACHED_DOWNLOADS_DIRECTORY)
was_cached, path = downloader.download_and_extract()
return cls(
input_option=spec,
path=path,
Expand All @@ -111,25 +165,6 @@ def is_url(string: str) -> bool:
return all([result.scheme, result.netloc])


def download_and_extract(url: str, base_directory: Path) -> Tuple[bool, Path]:
"""Download the URL and extract it locally if it hasn't already been downloaded."""
parsed_url = urlparse(url)
filename = Path(parsed_url.path).name
version = Path(parsed_url.path).parts[-2]
extract_to = base_directory / version / filename.removesuffix(".tar.gz")
already_cached = extract_to.exists()
if already_cached:
return already_cached, extract_to / "fixtures"

extract_to.mkdir(parents=True, exist_ok=False)
response = requests.get(url)
response.raise_for_status()

with tarfile.open(fileobj=BytesIO(response.content), mode="r:gz") as tar:
tar.extractall(path=extract_to)
return already_cached, extract_to / "fixtures"


class SimLimitBehavior:
"""Represents options derived from the `--sim.limit` argument."""

Expand Down
41 changes: 21 additions & 20 deletions src/pytest_plugins/consume/releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import os
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
Expand All @@ -11,13 +12,12 @@
import requests
from pydantic import BaseModel, Field, RootModel

RELEASE_INFORMATION_URL = "https://api.github.com/repos/ethereum/execution-spec-tests/releases"


CACHED_RELEASE_INFORMATION_FILE = (
Path(platformdirs.user_cache_dir("ethereum-execution-spec-tests")) / "release_information.json"
)

SUPPORTED_REPOS = ["ethereum/execution-spec-tests", "ethereum/tests", "ethereum/legacytests"]


class NoSuchReleaseError(Exception):
"""Raised when a release does not exist."""
Expand Down Expand Up @@ -154,19 +154,20 @@ def download_release_information(destination_file: Path | None) -> List[ReleaseI
crucial for finding older version or latest releases.
"""
all_releases = []
current_url: str | None = RELEASE_INFORMATION_URL
max_pages = 2
while current_url and max_pages > 0:
max_pages -= 1
response = requests.get(current_url)
response.raise_for_status()
all_releases.extend(response.json())
current_url = None
if "link" in response.headers:
for link in requests.utils.parse_header_links(response.headers["link"]):
if link["rel"] == "next":
current_url = link["url"]
break
for repo in SUPPORTED_REPOS:
current_url: str | None = f"https://api.github.com/repos/{repo}/releases"
max_pages = 2
while current_url and max_pages > 0:
max_pages -= 1
response = requests.get(current_url)
response.raise_for_status()
all_releases.extend(response.json())
current_url = None
if "link" in response.headers:
for link in requests.utils.parse_header_links(response.headers["link"]):
if link["rel"] == "next":
current_url = link["url"]
break

if destination_file:
destination_file.parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -200,17 +201,17 @@ def get_release_page_url(release_string: str) -> str:
Return the GitHub Release page URL for a specific release descriptor.

This function can handle:
- A standard release string (e.g., "eip7692@latest").
- A standard release string (e.g., "eip7692@latest") - from execution-spec-tests only.
- A direct asset download link (e.g.,
"https://github.com/ethereum/execution-spec-tests/releases/download/v4.0.0/fixtures_eip7692.tar.gz").
"""
release_information = get_release_information()

# Case 1: If it's a direct GitHub Releases download link,
# find which release in `release_information` has an asset with this exact URL.
if release_string.startswith(
"https://github.com/ethereum/execution-spec-tests/releases/download/"
):
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:
Expand Down
1 change: 1 addition & 0 deletions whitelist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -856,3 +856,4 @@ ize
nectos
fibonacci
CPython
legacytests