Skip to content

Commit 7797c9f

Browse files
authored
Merge pull request #812 from codeflash-ai/improve-addopts
Improve addopts masking (fixes repos with addopts which interfere with cf)
2 parents ffd8c90 + 4414b25 commit 7797c9f

File tree

3 files changed

+314
-29
lines changed

3 files changed

+314
-29
lines changed

codeflash/code_utils/code_utils.py

Lines changed: 95 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import ast
4+
import configparser
45
import difflib
56
import os
67
import re
@@ -15,10 +16,12 @@
1516
import tomlkit
1617

1718
from codeflash.cli_cmds.console import logger, paneled_text
18-
from codeflash.code_utils.config_parser import find_pyproject_toml
19+
from codeflash.code_utils.config_parser import find_pyproject_toml, get_all_closest_config_files
1920

2021
ImportErrorPattern = re.compile(r"ModuleNotFoundError.*$", re.MULTILINE)
2122

23+
BLACKLIST_ADDOPTS = ("--benchmark", "--sugar", "--codespeed", "--cov", "--profile", "--junitxml", "-n")
24+
2225

2326
def unified_diff_strings(code1: str, code2: str, fromfile: str = "original", tofile: str = "modified") -> str:
2427
"""Return the unified diff between two code strings as a single string.
@@ -81,42 +84,105 @@ def create_rank_dictionary_compact(int_array: list[int]) -> dict[int, int]:
8184
return {original_index: rank for rank, original_index in enumerate(sorted_indices)}
8285

8386

84-
@contextmanager
85-
def custom_addopts() -> None:
86-
pyproject_file = find_pyproject_toml()
87-
original_content = None
88-
non_blacklist_plugin_args = ""
89-
87+
def filter_args(addopts_args: list[str]) -> list[str]:
88+
# Convert BLACKLIST_ADDOPTS to a set for faster lookup of simple matches
89+
# But keep tuple for startswith
90+
blacklist = BLACKLIST_ADDOPTS
91+
# Precompute the length for re-use
92+
n = len(addopts_args)
93+
filtered_args = []
94+
i = 0
95+
while i < n:
96+
current_arg = addopts_args[i]
97+
if current_arg.startswith(blacklist):
98+
i += 1
99+
if i < n and not addopts_args[i].startswith("-"):
100+
i += 1
101+
else:
102+
filtered_args.append(current_arg)
103+
i += 1
104+
return filtered_args
105+
106+
107+
def modify_addopts(config_file: Path) -> tuple[str, bool]: # noqa : PLR0911
108+
file_type = config_file.suffix.lower()
109+
filename = config_file.name
110+
config = None
111+
if file_type not in {".toml", ".ini", ".cfg"} or not config_file.exists():
112+
return "", False
113+
# Read original file
114+
with Path.open(config_file, encoding="utf-8") as f:
115+
content = f.read()
90116
try:
91-
# Read original file
92-
if pyproject_file.exists():
93-
with Path.open(pyproject_file, encoding="utf-8") as f:
94-
original_content = f.read()
95-
data = tomlkit.parse(original_content)
96-
# Backup original addopts
117+
if filename == "pyproject.toml":
118+
# use tomlkit
119+
data = tomlkit.parse(content)
97120
original_addopts = data.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("addopts", "")
98121
# nothing to do if no addopts present
99-
if original_addopts != "" and isinstance(original_addopts, list):
100-
original_addopts = [x.strip() for x in original_addopts]
101-
non_blacklist_plugin_args = re.sub(r"-n(?: +|=)\S+", "", " ".join(original_addopts)).split(" ")
102-
non_blacklist_plugin_args = [x for x in non_blacklist_plugin_args if x != ""]
103-
if non_blacklist_plugin_args != original_addopts:
104-
data["tool"]["pytest"]["ini_options"]["addopts"] = non_blacklist_plugin_args
105-
# Write modified file
106-
with Path.open(pyproject_file, "w", encoding="utf-8") as f:
107-
f.write(tomlkit.dumps(data))
122+
if original_addopts == "":
123+
return content, False
124+
if isinstance(original_addopts, list):
125+
original_addopts = " ".join(original_addopts)
126+
original_addopts = original_addopts.replace("=", " ")
127+
addopts_args = (
128+
original_addopts.split()
129+
) # any number of space characters as delimiter, doesn't look at = which is fine
130+
else:
131+
# use configparser
132+
config = configparser.ConfigParser()
133+
config.read_string(content)
134+
data = {section: dict(config[section]) for section in config.sections()}
135+
if config_file.name in {"pytest.ini", ".pytest.ini", "tox.ini"}:
136+
original_addopts = data.get("pytest", {}).get("addopts", "") # should only be a string
137+
else:
138+
original_addopts = data.get("tool:pytest", {}).get("addopts", "") # should only be a string
139+
original_addopts = original_addopts.replace("=", " ")
140+
addopts_args = original_addopts.split()
141+
new_addopts_args = filter_args(addopts_args)
142+
if new_addopts_args == addopts_args:
143+
return content, False
144+
# change addopts now
145+
if file_type == ".toml":
146+
data["tool"]["pytest"]["ini_options"]["addopts"] = " ".join(new_addopts_args)
147+
# Write modified file
148+
with Path.open(config_file, "w", encoding="utf-8") as f:
149+
f.write(tomlkit.dumps(data))
150+
return content, True
151+
elif config_file.name in {"pytest.ini", ".pytest.ini", "tox.ini"}:
152+
config.set("pytest", "addopts", " ".join(new_addopts_args))
153+
# Write modified file
154+
with Path.open(config_file, "w", encoding="utf-8") as f:
155+
config.write(f)
156+
return content, True
157+
else:
158+
config.set("tool:pytest", "addopts", " ".join(new_addopts_args))
159+
# Write modified file
160+
with Path.open(config_file, "w", encoding="utf-8") as f:
161+
config.write(f)
162+
return content, True
163+
164+
except Exception:
165+
logger.debug("Trouble parsing")
166+
return content, False # not modified
167+
168+
169+
@contextmanager
170+
def custom_addopts() -> None:
171+
closest_config_files = get_all_closest_config_files()
172+
173+
original_content = {}
108174

175+
try:
176+
for config_file in closest_config_files:
177+
original_content[config_file] = modify_addopts(config_file)
109178
yield
110179

111180
finally:
112181
# Restore original file
113-
if (
114-
original_content
115-
and pyproject_file.exists()
116-
and tuple(original_addopts) not in {(), tuple(non_blacklist_plugin_args)}
117-
):
118-
with Path.open(pyproject_file, "w", encoding="utf-8") as f:
119-
f.write(original_content)
182+
for file, (content, was_modified) in original_content.items():
183+
if was_modified:
184+
with Path.open(file, "w", encoding="utf-8") as f:
185+
f.write(content)
120186

121187

122188
@contextmanager

codeflash/code_utils/config_parser.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import tomlkit
77

8+
ALL_CONFIG_FILES = {} # map path to closest config file
9+
810

911
def find_pyproject_toml(config_file: Path | None = None) -> Path:
1012
# Find the pyproject.toml file on the root of the project
@@ -31,6 +33,33 @@ def find_pyproject_toml(config_file: Path | None = None) -> Path:
3133
raise ValueError(msg)
3234

3335

36+
def get_all_closest_config_files() -> list[Path]:
37+
all_closest_config_files = []
38+
for file_type in ["pyproject.toml", "pytest.ini", ".pytest.ini", "tox.ini", "setup.cfg"]:
39+
closest_config_file = find_closest_config_file(file_type)
40+
if closest_config_file:
41+
all_closest_config_files.append(closest_config_file)
42+
return all_closest_config_files
43+
44+
45+
def find_closest_config_file(file_type: str) -> Path | None:
46+
# Find the closest pyproject.toml, pytest.ini, tox.ini, or setup.cfg file on the root of the project
47+
dir_path = Path.cwd()
48+
cur_path = dir_path
49+
if cur_path in ALL_CONFIG_FILES and file_type in ALL_CONFIG_FILES[cur_path]:
50+
return ALL_CONFIG_FILES[cur_path][file_type]
51+
while dir_path != dir_path.parent:
52+
config_file = dir_path / file_type
53+
if config_file.exists():
54+
if cur_path not in ALL_CONFIG_FILES:
55+
ALL_CONFIG_FILES[cur_path] = {}
56+
ALL_CONFIG_FILES[cur_path][file_type] = config_file
57+
return config_file
58+
# Search for pyproject.toml in the parent directories
59+
dir_path = dir_path.parent
60+
return None
61+
62+
3463
def find_conftest_files(test_paths: list[Path]) -> list[Path]:
3564
list_of_conftest_files = set()
3665
for test_path in test_paths:
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
from __future__ import annotations
2+
3+
import configparser
4+
import os
5+
import stat
6+
from pathlib import Path
7+
from unittest.mock import patch
8+
9+
import pytest
10+
import tomlkit
11+
12+
from codeflash.code_utils.code_utils import custom_addopts
13+
14+
def test_custom_addopts_modifies_and_restores_dotini_file(tmp_path: Path) -> None:
15+
"""Verify that custom_addopts correctly modifies and then restores a pytest.ini file."""
16+
# Create a dummy pytest.ini file
17+
config_file = tmp_path / ".pytest.ini"
18+
original_content = "[pytest]\naddopts = -v --cov=./src -n auto\n"
19+
config_file.write_text(original_content)
20+
21+
# Use patch to mock get_all_closest_config_files
22+
os.chdir(tmp_path)
23+
with custom_addopts():
24+
# Check that the file is modified inside the context
25+
modified_content = config_file.read_text()
26+
config = configparser.ConfigParser()
27+
config.read_string(modified_content)
28+
modified_addopts = config.get("pytest", "addopts", fallback="")
29+
assert modified_addopts == "-v"
30+
31+
# Check that the file is restored after exiting the context
32+
restored_content = config_file.read_text()
33+
assert restored_content.strip() == original_content.strip()
34+
35+
def test_custom_addopts_modifies_and_restores_ini_file(tmp_path: Path) -> None:
36+
"""Verify that custom_addopts correctly modifies and then restores a pytest.ini file."""
37+
# Create a dummy pytest.ini file
38+
config_file = tmp_path / "pytest.ini"
39+
original_content = "[pytest]\naddopts = -v --cov=./src -n auto\n"
40+
config_file.write_text(original_content)
41+
42+
# Use patch to mock get_all_closest_config_files
43+
os.chdir(tmp_path)
44+
with custom_addopts():
45+
# Check that the file is modified inside the context
46+
modified_content = config_file.read_text()
47+
config = configparser.ConfigParser()
48+
config.read_string(modified_content)
49+
modified_addopts = config.get("pytest", "addopts", fallback="")
50+
assert modified_addopts == "-v"
51+
52+
# Check that the file is restored after exiting the context
53+
restored_content = config_file.read_text()
54+
assert restored_content.strip() == original_content.strip()
55+
56+
57+
def test_custom_addopts_modifies_and_restores_toml_file(tmp_path: Path) -> None:
58+
"""Verify that custom_addopts correctly modifies and then restores a pyproject.toml file."""
59+
# Create a dummy pyproject.toml file
60+
config_file = tmp_path / "pyproject.toml"
61+
os.chdir(tmp_path)
62+
original_addopts = "-v --cov=./src --junitxml=report.xml"
63+
original_content_dict = {
64+
"tool": {"pytest": {"ini_options": {"addopts": original_addopts}}}
65+
}
66+
original_content = tomlkit.dumps(original_content_dict)
67+
config_file.write_text(original_content)
68+
69+
# Use patch to mock get_all_closest_config_files
70+
os.chdir(tmp_path)
71+
with custom_addopts():
72+
# Check that the file is modified inside the context
73+
modified_content = config_file.read_text()
74+
modified_data = tomlkit.parse(modified_content)
75+
modified_addopts = modified_data.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("addopts", "")
76+
assert modified_addopts == "-v"
77+
78+
# Check that the file is restored after exiting the context
79+
restored_content = config_file.read_text()
80+
assert restored_content.strip() == original_content.strip()
81+
82+
83+
def test_custom_addopts_handles_no_addopts(tmp_path: Path) -> None:
84+
"""Ensure custom_addopts doesn't fail when a config file has no addopts."""
85+
# Create a dummy pytest.ini file without addopts
86+
config_file = tmp_path / "pytest.ini"
87+
original_content = "[pytest]\n# no addopts here\n"
88+
config_file.write_text(original_content)
89+
90+
os.chdir(tmp_path)
91+
with custom_addopts():
92+
# The file should not be modified
93+
content_inside_context = config_file.read_text()
94+
assert content_inside_context == original_content
95+
96+
# The file should remain unchanged
97+
content_after_context = config_file.read_text()
98+
assert content_after_context == original_content
99+
100+
def test_custom_addopts_handles_no_relevant_files(tmp_path: Path) -> None:
101+
"""Ensure custom_addopts runs without error when no config files are found."""
102+
# No config files created in tmp_path
103+
104+
os.chdir(tmp_path)
105+
# This should execute without raising any exceptions
106+
with custom_addopts():
107+
pass
108+
# No assertions needed, the test passes if no exceptions were raised
109+
110+
111+
def test_custom_addopts_toml_without_pytest_section(tmp_path: Path) -> None:
112+
"""Verify custom_addopts doesn't fail with a toml file missing a [tool.pytest] section."""
113+
config_file = tmp_path / "pyproject.toml"
114+
original_content_dict = {"tool": {"other_tool": {"key": "value"}}}
115+
original_content = tomlkit.dumps(original_content_dict)
116+
config_file.write_text(original_content)
117+
118+
os.chdir(tmp_path)
119+
with custom_addopts():
120+
content_inside_context = config_file.read_text()
121+
assert content_inside_context == original_content
122+
123+
content_after_context = config_file.read_text()
124+
assert content_after_context == original_content
125+
126+
127+
def test_custom_addopts_ini_without_pytest_section(tmp_path: Path) -> None:
128+
"""Verify custom_addopts doesn't fail with an ini file missing a [pytest] section."""
129+
config_file = tmp_path / "pytest.ini"
130+
original_content = "[other_section]\nkey = value\n"
131+
config_file.write_text(original_content)
132+
133+
os.chdir(tmp_path)
134+
with custom_addopts():
135+
content_inside_context = config_file.read_text()
136+
assert content_inside_context == original_content
137+
138+
content_after_context = config_file.read_text()
139+
assert content_after_context == original_content
140+
141+
142+
def test_custom_addopts_with_multiple_config_files(tmp_path: Path) -> None:
143+
"""Verify custom_addopts modifies and restores all found config files."""
144+
os.chdir(tmp_path)
145+
146+
# Create pytest.ini
147+
ini_file = tmp_path / "pytest.ini"
148+
ini_original_content = "[pytest]\naddopts = -v --cov\n"
149+
ini_file.write_text(ini_original_content)
150+
151+
# Create pyproject.toml
152+
toml_file = tmp_path / "pyproject.toml"
153+
toml_original_addopts = "-s -n auto"
154+
toml_original_content_dict = {
155+
"tool": {"pytest": {"ini_options": {"addopts": toml_original_addopts}}}
156+
}
157+
toml_original_content = tomlkit.dumps(toml_original_content_dict)
158+
toml_file.write_text(toml_original_content)
159+
160+
with custom_addopts():
161+
# Check INI file modification
162+
ini_modified_content = ini_file.read_text()
163+
config = configparser.ConfigParser()
164+
config.read_string(ini_modified_content)
165+
assert config.get("pytest", "addopts", fallback="") == "-v"
166+
167+
# Check TOML file modification
168+
toml_modified_content = toml_file.read_text()
169+
modified_data = tomlkit.parse(toml_modified_content)
170+
modified_addopts = modified_data.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("addopts", "")
171+
assert modified_addopts == "-s"
172+
173+
# Check that both files are restored
174+
assert ini_file.read_text().strip() == ini_original_content.strip()
175+
assert toml_file.read_text().strip() == toml_original_content.strip()
176+
177+
178+
def test_custom_addopts_restores_on_exception(tmp_path: Path) -> None:
179+
"""Ensure config file is restored even if an exception occurs inside the context."""
180+
config_file = tmp_path / "pytest.ini"
181+
original_content = "[pytest]\naddopts = -v --cov\n"
182+
config_file.write_text(original_content)
183+
184+
os.chdir(tmp_path)
185+
with pytest.raises(ValueError, match="Test exception"):
186+
with custom_addopts():
187+
raise ValueError("Test exception")
188+
189+
restored_content = config_file.read_text()
190+
assert restored_content.strip() == original_content.strip()

0 commit comments

Comments
 (0)