Skip to content

Commit 3dc21a8

Browse files
feat(consume): report the resolved fixture release tarball and page URLs (#1239)
* feat(consume): report the resolved fixture release tarball and page URLs * feat(consume): exit early with consume cache (no dummy test session) * fix(consume): fix piping fill to consume (`--input=stdin`) * style(consume): fix typehints for fixture_source.path w/non-stdin case * style(consume): remove unintended comment Co-authored-by: spencer <spencer.taylor-brown@ethereum.org> * style(consume): fix cache exit message * fix(consume): fix index generation if index.json is missing * style(consume): move FixtureSource up after defaults --------- Co-authored-by: spencer <spencer.taylor-brown@ethereum.org>
1 parent a39c427 commit 3dc21a8

File tree

7 files changed

+156
-58
lines changed

7 files changed

+156
-58
lines changed

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Test fixtures for use by clients are available for each release on the [Github r
1515
- ✨ Blockchain and Blockchain-Engine tests that were generated from a state test now have `blockchain_test_from_state_test` or `blockchain_test_engine_from_state_test` as part of their test IDs ([#1220](https://github.com/ethereum/execution-spec-tests/pull/1220)).
1616
- 🔀 Refactor `ethereum_test_fixtures` and `ethereum_clis` to create `FixtureConsumer` and `FixtureConsumerTool` classes which abstract away the consumption process used by `consume direct` ([#935](https://github.com/ethereum/execution-spec-tests/pull/935)).
1717
- ✨ Allow `consume direct --collect-only` without specifying a fixture consumer binary on the command-line ([#1237](https://github.com/ethereum/execution-spec-tests/pull/1237)).
18+
- ✨ Report the (resolved) fixture tarball URL and local fixture cache directory when `consume`'s `--input` flag is a release spec or URL [#1239](https://github.com/ethereum/execution-spec-tests/pull/1239).
1819
- ✨ EOF Container validation tests (`eof_test`) now generate container deployment state tests, by wrapping the EOF container in an init-container and sending a deploy transaction ([#783](https://github.com/ethereum/execution-spec-tests/pull/783), [#1233](https://github.com/ethereum/execution-spec-tests/pull/1233)).
1920

2021
### 📋 Misc

src/cli/pytest_commands/consume.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,4 @@ def hive() -> None:
130130
def cache(pytest_args: List[str], **kwargs) -> None:
131131
"""Consume command to cache test fixtures."""
132132
args = handle_consume_command_flags(pytest_args, is_hive=False)
133-
args += ["src/pytest_plugins/consume/test_cache.py"]
134133
sys.exit(pytest.main(args))

src/pytest_plugins/consume/consume.py

Lines changed: 119 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import sys
44
import tarfile
5+
from dataclasses import dataclass
56
from io import BytesIO
67
from pathlib import Path
7-
from typing import List, Literal, Union
8+
from typing import List, Tuple
89
from urllib.parse import urlparse
910

1011
import platformdirs
@@ -16,16 +17,14 @@
1617
from ethereum_test_fixtures.consume import TestCases
1718
from ethereum_test_tools.utility.versioning import get_current_commit_hash_or_tag
1819

19-
from .releases import ReleaseTag, get_release_url
20+
from .releases import ReleaseTag, get_release_page_url, get_release_url
2021

2122
CACHED_DOWNLOADS_DIRECTORY = (
2223
Path(platformdirs.user_cache_dir("ethereum-execution-spec-tests")) / "cached_downloads"
2324
)
2425

25-
FixturesSource = Union[Path, Literal["stdin"]]
2626

27-
28-
def default_input_directory() -> str:
27+
def default_input() -> str:
2928
"""
3029
Directory (default) to consume generated test fixtures from. Defined as a
3130
function to allow for easier testing.
@@ -41,31 +40,91 @@ def default_html_report_file_path() -> str:
4140
return ".meta/report_consume.html"
4241

4342

43+
@dataclass
44+
class FixturesSource:
45+
"""Represents the source of test fixtures."""
46+
47+
input_option: str
48+
path: Path
49+
url: str = ""
50+
release_page: str = ""
51+
is_local: bool = True
52+
is_stdin: bool = False
53+
was_cached: bool = False
54+
55+
@classmethod
56+
def from_input(cls, input_source: str) -> "FixturesSource":
57+
"""Determine the fixture source type and return an instance."""
58+
if input_source == "stdin":
59+
return cls(input_option=input_source, path=Path(), is_local=False, is_stdin=True)
60+
if is_url(input_source):
61+
return cls.from_url(input_source)
62+
if ReleaseTag.is_release_string(input_source):
63+
return cls.from_release_spec(input_source)
64+
return cls.validate_local_path(Path(input_source))
65+
66+
@classmethod
67+
def from_url(cls, url: str) -> "FixturesSource":
68+
"""Create a fixture source from a direct URL."""
69+
release_page = get_release_page_url(url)
70+
was_cached, path = download_and_extract(url, CACHED_DOWNLOADS_DIRECTORY)
71+
return cls(
72+
input_option=url,
73+
path=path,
74+
url=url,
75+
release_page=release_page,
76+
is_local=False,
77+
was_cached=was_cached,
78+
)
79+
80+
@classmethod
81+
def from_release_spec(cls, spec: str) -> "FixturesSource":
82+
"""Create a fixture source from a release spec (e.g., develop@latest)."""
83+
url = get_release_url(spec)
84+
release_page = get_release_page_url(url)
85+
was_cached, path = download_and_extract(url, CACHED_DOWNLOADS_DIRECTORY)
86+
return cls(
87+
input_option=spec,
88+
path=path,
89+
url=url,
90+
release_page=release_page,
91+
is_local=False,
92+
was_cached=was_cached,
93+
)
94+
95+
@staticmethod
96+
def validate_local_path(path: Path) -> "FixturesSource":
97+
"""Validate that a local fixture path exists and contains JSON files."""
98+
if not path.exists():
99+
pytest.exit(f"Specified fixture directory '{path}' does not exist.")
100+
if not any(path.glob("**/*.json")):
101+
pytest.exit(f"Specified fixture directory '{path}' does not contain any JSON files.")
102+
return FixturesSource(input_option=str(path), path=path)
103+
104+
44105
def is_url(string: str) -> bool:
45106
"""Check if a string is a remote URL."""
46107
result = urlparse(string)
47108
return all([result.scheme, result.netloc])
48109

49110

50-
def download_and_extract(url: str, base_directory: Path) -> Path:
111+
def download_and_extract(url: str, base_directory: Path) -> Tuple[bool, Path]:
51112
"""Download the URL and extract it locally if it hasn't already been downloaded."""
52113
parsed_url = urlparse(url)
53114
filename = Path(parsed_url.path).name
54115
version = Path(parsed_url.path).parts[-2]
55116
extract_to = base_directory / version / filename.removesuffix(".tar.gz")
56-
57-
if extract_to.exists():
58-
# skip download if the archive has already been downloaded
59-
return extract_to / "fixtures"
117+
already_cached = extract_to.exists()
118+
if already_cached:
119+
return already_cached, extract_to / "fixtures"
60120

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

65-
with tarfile.open(fileobj=BytesIO(response.content), mode="r:gz") as tar: # noqa: SC200
125+
with tarfile.open(fileobj=BytesIO(response.content), mode="r:gz") as tar:
66126
tar.extractall(path=extract_to)
67-
68-
return extract_to / "fixtures"
127+
return already_cached, extract_to / "fixtures"
69128

70129

71130
def pytest_addoption(parser): # noqa: D103
@@ -82,7 +141,7 @@ def pytest_addoption(parser): # noqa: D103
82141
" fixtures.tar.gz archive, a release name and version in the form of `NAME@v1.2.3` "
83142
"(`stable` and `develop` are valid release names, and `latest` is a valid version), "
84143
"or the special keyword 'stdin'. "
85-
f"Defaults to the following local directory: '{default_input_directory()}'."
144+
f"Defaults to the following local directory: '{default_input()}'."
86145
),
87146
)
88147
consume_group.addoption(
@@ -126,47 +185,53 @@ def pytest_configure(config): # noqa: D103
126185
called before the pytest-html plugin's pytest_configure to ensure that
127186
it uses the modified `htmlpath` option.
128187
"""
129-
fixtures_source = config.getoption("fixtures_source")
130-
if "cache" in sys.argv and not config.getoption("fixtures_source"):
188+
if config.option.fixtures_source is None:
189+
# NOTE: Setting the default value here is necessary for correct stdin/piping behavior.
190+
config.fixtures_source = FixturesSource(
191+
input_option=default_input(), path=Path(default_input())
192+
)
193+
else:
194+
# NOTE: Setting `type=FixturesSource.from_input` in pytest_addoption() causes the option to
195+
# be evaluated twice which breaks the result of `was_cached`; the work-around is to call it
196+
# manually here.
197+
config.fixtures_source = FixturesSource.from_input(config.option.fixtures_source)
198+
config.fixture_source_flags = ["--input", config.fixtures_source.input_option]
199+
200+
if "cache" in sys.argv and not config.fixtures_source:
131201
pytest.exit("The --input flag is required when using the cache command.")
132-
config.fixture_source_flags = ["--input", fixtures_source]
133202

134-
if fixtures_source is None:
135-
config.fixture_source_flags = []
136-
fixtures_source = default_input_directory()
137-
elif fixtures_source == "stdin":
138-
config.test_cases = TestCases.from_stream(sys.stdin)
139-
config.fixtures_real_source = "stdin"
140-
config.fixtures_source = "stdin"
141-
return
142-
elif ReleaseTag.is_release_string(fixtures_source):
143-
fixtures_source = get_release_url(fixtures_source)
144-
145-
config.fixtures_real_source = fixtures_source
146-
if is_url(fixtures_source):
147-
cached_downloads_directory = Path(config.getoption("fixture_cache_folder"))
148-
cached_downloads_directory.mkdir(parents=True, exist_ok=True)
149-
fixtures_source = download_and_extract(fixtures_source, cached_downloads_directory)
150-
151-
fixtures_source = Path(fixtures_source)
152-
config.fixtures_source = fixtures_source
153-
if not fixtures_source.exists():
154-
pytest.exit(f"Specified fixture directory '{fixtures_source}' does not exist.")
155-
if not any(fixtures_source.glob("**/*.json")):
203+
if "cache" in sys.argv:
204+
reason = ""
205+
if config.fixtures_source.was_cached:
206+
reason += "Fixtures already cached."
207+
elif not config.fixtures_source.is_local:
208+
reason += "Fixtures downloaded and cached."
209+
reason += (
210+
f"\nPath: {config.fixtures_source.path}\n"
211+
f"Input: {config.fixtures_source.url or config.fixtures_source.path}\n"
212+
f"Release page: {config.fixtures_source.release_page or 'None'}"
213+
)
156214
pytest.exit(
157-
f"Specified fixture directory '{fixtures_source}' does not contain any JSON files."
215+
returncode=0,
216+
reason=reason,
158217
)
159218

160-
index_file = fixtures_source / ".meta" / "index.json"
219+
if config.fixtures_source.is_stdin:
220+
config.test_cases = TestCases.from_stream(sys.stdin)
221+
return
222+
index_file = config.fixtures_source.path / ".meta" / "index.json"
161223
index_file.parent.mkdir(parents=True, exist_ok=True)
162224
if not index_file.exists():
163225
rich.print(f"Generating index file [bold cyan]{index_file}[/]...")
164226
generate_fixtures_index(
165-
fixtures_source, quiet_mode=False, force_flag=False, disable_infer_format=False
227+
config.fixtures_source.path,
228+
quiet_mode=False,
229+
force_flag=False,
230+
disable_infer_format=False,
166231
)
167232
config.test_cases = TestCases.from_index_file(index_file)
168233

169-
if config.option.collectonly or "cache" in sys.argv:
234+
if config.option.collectonly:
170235
return
171236
if not config.getoption("disable_html") and config.getoption("htmlpath") is None:
172237
# generate an html report by default, unless explicitly disabled
@@ -178,10 +243,17 @@ def pytest_html_report_title(report):
178243
report.title = "Consume Test Report"
179244

180245

181-
def pytest_report_header(config): # noqa: D103
182-
consume_version = f"consume commit: {get_current_commit_hash_or_tag()}"
183-
fixtures_real_source = f"fixtures: {config.fixtures_real_source}"
184-
return [consume_version, fixtures_real_source]
246+
def pytest_report_header(config):
247+
"""Add the consume version and fixtures source to the report header."""
248+
source = config.fixtures_source
249+
lines = [
250+
f"consume ref: {get_current_commit_hash_or_tag()}",
251+
f"fixtures: {source.path}",
252+
]
253+
if not source.is_local and not source.is_stdin:
254+
lines.append(f"fixtures url: {source.url}")
255+
lines.append(f"fixtures release: {source.release_page}")
256+
return lines
185257

186258

187259
@pytest.fixture(scope="session")

src/pytest_plugins/consume/direct/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def fixture_path(test_case: TestCaseIndexFile | TestCaseStream, fixtures_source:
118118
119119
If the fixture source is stdin, the fixture is written to a temporary json file.
120120
"""
121-
if fixtures_source == "stdin":
121+
if fixtures_source.is_stdin:
122122
assert isinstance(test_case, TestCaseStream)
123123
temp_dir = tempfile.TemporaryDirectory()
124124
fixture_path = Path(temp_dir.name) / f"{test_case.id.replace('/', '_')}.json"
@@ -129,7 +129,7 @@ def fixture_path(test_case: TestCaseIndexFile | TestCaseStream, fixtures_source:
129129
temp_dir.cleanup()
130130
else:
131131
assert isinstance(test_case, TestCaseIndexFile)
132-
yield fixtures_source / test_case.json_path
132+
yield fixtures_source.path / test_case.json_path
133133

134134

135135
@pytest.fixture(scope="function")

src/pytest_plugins/consume/hive_simulators/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,12 +266,12 @@ def fixture(
266266
input from disk (fixture directory with index file).
267267
"""
268268
fixture: BaseFixture
269-
if fixtures_source == "stdin":
269+
if fixtures_source.is_stdin:
270270
assert isinstance(test_case, TestCaseStream), "Expected a stream test case"
271271
fixture = test_case.fixture
272272
else:
273273
assert isinstance(test_case, TestCaseIndexFile), "Expected an index file test case"
274-
fixtures_file_path = Path(fixtures_source) / test_case.json_path
274+
fixtures_file_path = fixtures_source.path / test_case.json_path
275275
fixtures: Fixtures = fixture_file_loader[fixtures_file_path]
276276
fixture = fixtures[test_case.id]
277277
assert isinstance(fixture, fixture_format), (

src/pytest_plugins/consume/releases.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,38 @@ def get_release_url_from_release_information(
195195
raise NoSuchReleaseError(release_string)
196196

197197

198+
def get_release_page_url(release_string: str) -> str:
199+
"""
200+
Return the GitHub Release page URL for a specific release descriptor.
201+
202+
This function can handle:
203+
- A standard release string (e.g., "eip7692@latest").
204+
- A direct asset download link (e.g.,
205+
"https://github.com/ethereum/execution-spec-tests/releases/download/v4.0.0/fixtures_eip7692.tar.gz").
206+
"""
207+
release_information = get_release_information()
208+
209+
# Case 1: If it's a direct GitHub Releases download link,
210+
# find which release in `release_information` has an asset with this exact URL.
211+
if release_string.startswith(
212+
"https://github.com/ethereum/execution-spec-tests/releases/download/"
213+
):
214+
for release in release_information:
215+
for asset in release.assets.root:
216+
if asset.url == release_string:
217+
return release.url # The HTML page for this release
218+
raise NoSuchReleaseError(f"No release found for asset URL: {release_string}")
219+
220+
# Case 2: Otherwise, treat it as a release descriptor (e.g., "eip7692@latest")
221+
release_descriptor = ReleaseTag.from_string(release_string)
222+
for release in release_information:
223+
if release_descriptor in release:
224+
return release.url
225+
226+
# If nothing matched, raise
227+
raise NoSuchReleaseError(release_string)
228+
229+
198230
def get_release_information() -> List[ReleaseInformation]:
199231
"""
200232
Get the release information.

src/pytest_plugins/consume/test_cache.py

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)