Skip to content

Commit 83c7ea7

Browse files
danceratopzmarioevz
authored andcommitted
fix(fill): insist fill's output directory is empty or doesn't exist (ethereum#1608)
* tests(gentest): don't use default output fixture directory in unit test * feat(fill): refuse to fill to a non-empty output directory; add clean * test(fill): add tests for fixture output dir args/`FixtureOutput` * chore(tox): use a dedicated dir in /tmp and --clean when filling tests * docs: update changelog * Apply suggestions from code review --------- Co-authored-by: Mario Vega <marioevz@gmail.com>
1 parent d21f3f5 commit 83c7ea7

File tree

6 files changed

+373
-20
lines changed

6 files changed

+373
-20
lines changed

docs/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,20 @@ Test fixtures for use by clients are available for each release on the [Github r
88

99
### 💥 Breaking Change
1010

11+
### 💥 Important Change for `fill` Users
12+
13+
The output behavior of `fill` has changed ([#1608](https://github.com/ethereum/execution-spec-tests/pull/1608)):
14+
15+
- Before: `fill` wrote fixtures into the directory specified by the `--output` flag (default: `fixtures`). This could have many unintended consequences, including unexpected errors if old or invalid fixtures existed in the directory (for details see [#1030](https://github.com/ethereum/execution-spec-tests/issues/1030)).
16+
- Now: `fill` will exit without filling any tests if the specified directory exists and is not-empty. This may be overridden by adding the `--clean` flag, which will first remove the specified directory.
17+
1118
### 🛠️ Framework
1219

1320
#### `fill`
1421

1522
- 🔀 Refactor: Encapsulate `fill`'s fixture output options (`--output`, `--flat-output`, `--single-fixture-per-file`) into a `FixtureOutput` class ([#1471](https://github.com/ethereum/execution-spec-tests/pull/1471)).
1623
- ✨ Don't warn about a "high Transaction gas_limit" for `zkevm` tests ([#1598](https://github.com/ethereum/execution-spec-tests/pull/1598)).
24+
- 🐞 `fill` no longer writes generated fixtures into an existing, non-empty output directory; it must now be empty or `--clean` must be used to delete it first ([#1608](https://github.com/ethereum/execution-spec-tests/pull/1608)).
1725

1826
#### `consume`
1927

src/cli/gentest/tests/test_cli.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for the gentest CLI command."""
22

3+
from tempfile import TemporaryDirectory
4+
35
import pytest
46
from click.testing import CliRunner
57

@@ -111,5 +113,13 @@ def get_mock_context(self: StateTestProvider) -> dict:
111113
assert gentest_result.exit_code == 0
112114

113115
## Fill ##
114-
fill_result = runner.invoke(fill, ["-c", "pytest.ini", "--skip-evm-dump", output_file])
116+
args = [
117+
"-c",
118+
"pytest.ini",
119+
"--skip-evm-dump",
120+
"--output",
121+
TemporaryDirectory().name,
122+
output_file,
123+
]
124+
fill_result = runner.invoke(fill, args)
115125
assert fill_result.exit_code == 0, f"Fill command failed:\n{fill_result.output}"

src/pytest_plugins/filler/filler.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,19 @@ def pytest_addoption(parser: pytest.Parser):
114114
type=Path,
115115
default=Path(default_output_directory()),
116116
help=(
117-
"Directory path to store the generated test fixtures. "
117+
"Directory path to store the generated test fixtures. Must be empty if it exists. "
118118
"If the specified path ends in '.tar.gz', then the specified tarball is additionally "
119119
"created (the fixtures are still written to the specified path without the '.tar.gz' "
120120
f"suffix). Can be deleted. Default: '{default_output_directory()}'."
121121
),
122122
)
123+
test_group.addoption(
124+
"--clean",
125+
action="store_true",
126+
dest="clean",
127+
default=False,
128+
help="Clean (remove) the output directory before filling fixtures.",
129+
)
123130
test_group.addoption(
124131
"--flat-output",
125132
action="store_true",
@@ -221,6 +228,12 @@ def pytest_configure(config):
221228
# Initialize fixture output configuration
222229
config.fixture_output = FixtureOutput.from_config(config)
223230

231+
try:
232+
# Check whether the directory exists and is not empty; if --clean is set, it will delete it
233+
config.fixture_output.create_directories(is_master=not hasattr(config, "workerinput"))
234+
except ValueError as e:
235+
pytest.exit(str(e), returncode=pytest.ExitCode.USAGE_ERROR)
236+
224237
if not config.getoption("disable_html") and config.getoption("htmlpath") is None:
225238
# generate an html report by default, unless explicitly disabled
226239
config.option.htmlpath = config.fixture_output.directory / default_html_report_file_path()
@@ -521,8 +534,6 @@ def create_properties_file(request: pytest.FixtureRequest, fixture_output: Fixtu
521534
if fixture_output.is_stdout:
522535
return
523536

524-
fixture_output.create_directories()
525-
526537
fixture_properties = {
527538
"timestamp": datetime.datetime.now().isoformat(),
528539
}

src/pytest_plugins/filler/fixture_output.py

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Fixture output configuration for generated test fixtures."""
22

3+
import shutil
34
import tarfile
45
from pathlib import Path
56

@@ -22,6 +23,10 @@ class FixtureOutput(BaseModel):
2223
"write each fixture to its own file"
2324
),
2425
)
26+
clean: bool = Field(
27+
default=False,
28+
description="Clean (remove) the output directory before filling fixtures.",
29+
)
2530

2631
@property
2732
def directory(self) -> Path:
@@ -53,11 +58,78 @@ def strip_tarball_suffix(path: Path) -> Path:
5358
return path.with_suffix("").with_suffix("")
5459
return path
5560

56-
def create_directories(self) -> None:
57-
"""Create output and metadata directories if needed."""
61+
def is_directory_empty(self) -> bool:
62+
"""Check if the output directory is empty."""
63+
if not self.directory.exists():
64+
return True
65+
66+
return not any(self.directory.iterdir())
67+
68+
def get_directory_summary(self) -> str:
69+
"""Return a summary of directory contents for error reporting."""
70+
if not self.directory.exists():
71+
return "directory does not exist"
72+
73+
items = list(self.directory.iterdir())
74+
if not items:
75+
return "empty directory"
76+
77+
dirs = [d.name for d in items if d.is_dir()]
78+
files = [f.name for f in items if f.is_file()]
79+
80+
max_dirs = 4
81+
summary_parts = []
82+
if dirs:
83+
summary_parts.append(
84+
f"{len(dirs)} directories"
85+
+ (
86+
f" ({', '.join(dirs[:max_dirs])}"
87+
+ (f"... and {len(dirs) - max_dirs} more" if len(dirs) > max_dirs else "")
88+
+ ")"
89+
if dirs
90+
else ""
91+
)
92+
)
93+
if files:
94+
summary_parts.append(
95+
f"{len(files)} files"
96+
+ (
97+
f" ({', '.join(files[:3])}"
98+
+ (f"... and {len(files) - 3} more" if len(files) > 3 else "")
99+
+ ")"
100+
if files
101+
else ""
102+
)
103+
)
104+
105+
return " and ".join(summary_parts)
106+
107+
def create_directories(self, is_master: bool) -> None:
108+
"""
109+
Create output and metadata directories if needed.
110+
111+
If clean flag is set, remove and recreate the directory.
112+
Otherwise, verify the directory is empty before proceeding.
113+
"""
58114
if self.is_stdout:
59115
return
60116

117+
# Only the master process should delete/create directories if using pytest-xdist
118+
if not is_master:
119+
return
120+
121+
if self.directory.exists() and self.clean:
122+
shutil.rmtree(self.directory)
123+
124+
if self.directory.exists() and not self.is_directory_empty():
125+
summary = self.get_directory_summary()
126+
raise ValueError(
127+
f"Output directory '{self.directory}' is not empty. "
128+
f"Contains: {summary}. Use --clean to remove all existing files "
129+
"or specify a different output directory."
130+
)
131+
132+
# Create directories
61133
self.directory.mkdir(parents=True, exist_ok=True)
62134
self.metadata_dir.mkdir(parents=True, exist_ok=True)
63135

@@ -72,22 +144,12 @@ def create_tarball(self) -> None:
72144
arcname = Path("fixtures") / file.relative_to(self.directory)
73145
tar.add(file, arcname=arcname)
74146

75-
@classmethod
76-
def from_options(
77-
cls, output_path: Path, flat_output: bool, single_fixture_per_file: bool
78-
) -> "FixtureOutput":
79-
"""Create a FixtureOutput instance from pytest options."""
80-
return cls(
81-
output_path=output_path,
82-
flat_output=flat_output,
83-
single_fixture_per_file=single_fixture_per_file,
84-
)
85-
86147
@classmethod
87148
def from_config(cls, config: pytest.Config) -> "FixtureOutput":
88149
"""Create a FixtureOutput instance from pytest configuration."""
89150
return cls(
90151
output_path=config.getoption("output"),
91152
flat_output=config.getoption("flat_output"),
92153
single_fixture_per_file=config.getoption("single_fixture_per_file"),
154+
clean=config.getoption("clean"),
93155
)

0 commit comments

Comments
 (0)