Skip to content
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
18 changes: 18 additions & 0 deletions src/usethis/_config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def files_manager() -> Iterator[None]:
CodespellRCManager(),
CoverageRCManager(),
DotRuffTOMLManager(),
DotPytestINIManager(),
PytestINIManager(),
RuffTOMLManager(),
ToxINIManager(),
):
Expand Down Expand Up @@ -51,6 +53,22 @@ def relative_path(self) -> Path:
return Path(".ruff.toml")


class DotPytestINIManager(INIFileManager):
"""Class to manage the .pytest.ini file."""

@property
def relative_path(self) -> Path:
return Path(".pytest.ini")


class PytestINIManager(INIFileManager):
"""Class to manage the pytest.ini file."""

@property
def relative_path(self) -> Path:
return Path("pytest.ini")


class RuffTOMLManager(TOMLFileManager):
"""Class to manage the ruff.toml file."""

Expand Down
103 changes: 97 additions & 6 deletions src/usethis/_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
from usethis._config_file import (
CodespellRCManager,
CoverageRCManager,
DotPytestINIManager,
DotRuffTOMLManager,
PytestINIManager,
RuffTOMLManager,
ToxINIManager,
)
Expand Down Expand Up @@ -40,7 +42,7 @@
)
from usethis._io import KeyValueFileManager

ResolutionT: TypeAlias = Literal["first"]
ResolutionT: TypeAlias = Literal["first", "bespoke"]


class ConfigSpec(BaseModel):
Expand Down Expand Up @@ -289,6 +291,7 @@ def get_active_config_file_managers(self) -> set[KeyValueFileManager]:
config_spec = self.get_config_spec()
resolution = config_spec.resolution
if resolution == "first":
# N.B. keep this roughly in sync with the bespoke logic for pytest
for (
relative_path,
file_manager,
Expand All @@ -311,6 +314,12 @@ def get_active_config_file_managers(self) -> set[KeyValueFileManager]:
)
raise NotImplementedError(msg)
return {preferred_file_manager}
elif resolution == "bespoke":
msg = (
"The bespoke resolution method is not yet implemented for the tool "
f"{self.name}."
)
raise NotImplementedError(msg)
else:
assert_never(resolution)

Expand Down Expand Up @@ -877,32 +886,114 @@ def get_config_spec(self) -> ConfigSpec:
"log_cli_level": "INFO", # include all >=INFO level log messages (sp-repo-review)
"minversion": "7", # minimum pytest version (sp-repo-review)
}
value_ini = value.copy()
# https://docs.pytest.org/en/stable/reference/reference.html#confval-xfail_strict
value_ini["xfail_strict"] = "True" # stringify boolean

return ConfigSpec.from_flat(
file_managers=[PyprojectTOMLManager()],
resolution="first",
file_managers=[
PytestINIManager(),
DotPytestINIManager(),
PyprojectTOMLManager(),
ToxINIManager(),
SetupCFGManager(),
],
resolution="bespoke",
config_items=[
ConfigItem(
description="Overall Config",
root={Path("pyproject.toml"): ConfigEntry(keys=["tool", "pytest"])},
root={
Path("pytest.ini"): ConfigEntry(keys=[]),
Path(".pytest.ini"): ConfigEntry(keys=[]),
Path("pyproject.toml"): ConfigEntry(keys=["tool", "pytest"]),
Path("tox.ini"): ConfigEntry(keys=["pytest"]),
Path("setup.cfg"): ConfigEntry(keys=["tool:pytest"]),
},
),
ConfigItem(
description="INI-Style Options",
root={
Path("pytest.ini"): ConfigEntry(
keys=["pytest"], value=value_ini
),
Path(".pytest.ini"): ConfigEntry(
keys=["pytest"], value=value_ini
),
Path("pyproject.toml"): ConfigEntry(
keys=["tool", "pytest", "ini_options"], value=value
)
),
Path("tox.ini"): ConfigEntry(keys=["pytest"], value=value_ini),
Path("setup.cfg"): ConfigEntry(
keys=["tool:pytest"], value=value_ini
),
},
),
],
)

def get_managed_files(self) -> list[Path]:
return [Path("pytest.ini"), Path("tests/conftest.py")]
return [Path(".pytest.ini"), Path("pytest.ini"), Path("tests/conftest.py")]

def get_associated_ruff_rules(self) -> list[str]:
return ["PT"]

def get_active_config_file_managers(self) -> set[KeyValueFileManager]:
# This is a variant of the "first" method
config_spec = self.get_config_spec()
assert config_spec.resolution == "bespoke"
# As per https://docs.pytest.org/en/stable/reference/customize.html#finding-the-rootdir
# Files will only be matched for configuration if:
# - pytest.ini: will always match and take precedence, even if empty.
# - pyproject.toml: contains a [tool.pytest.ini_options] table.
# - tox.ini: contains a [pytest] section.
# - setup.cfg: contains a [tool:pytest] section.
# Finally, a pyproject.toml file will be considered the configfile if no other
# match was found, in this case even if it does not contain a
# [tool.pytest.ini_options] table
# Also, the docs mention that the hidden .pytest.ini variant is allowed, in my
# experimentation is takes precedence over pyproject.toml but not pytest.ini.

for (
relative_path,
file_manager,
) in config_spec.file_manager_by_relative_path.items():
path = Path.cwd() / relative_path
if path.exists() and path.is_file():
if isinstance(file_manager, PyprojectTOMLManager):
if ["tool", "pytest", "ini_options"] in file_manager:
return {file_manager}
else:
continue
return {file_manager}

# Second chance for pyproject.toml
for (
relative_path,
file_manager,
) in config_spec.file_manager_by_relative_path.items():
path = Path.cwd() / relative_path
if (
path.exists()
and path.is_file()
and isinstance(file_manager, PyprojectTOMLManager)
):
return {file_manager}

file_managers = config_spec.file_manager_by_relative_path.values()
if not file_managers:
return set()

# Use the preferred default file since there's no existing file.
preferred_file_manager = self.preferred_file_manager()
if preferred_file_manager not in file_managers:
msg = (
f"The preferred file manager '{preferred_file_manager}' is not "
f"among the file managers '{file_managers}' for the tool "
f"'{self.name}'"
)
raise NotImplementedError(msg)
return {preferred_file_manager}


class RequirementsTxtTool(Tool):
# https://pip.pypa.io/en/stable/reference/requirements-file-format/
Expand Down
79 changes: 76 additions & 3 deletions tests/usethis/_core/test_core_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ class TestAdd:
def test_from_nothing(
self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]
):
with change_cwd(uv_init_dir), PyprojectTOMLManager():
with change_cwd(uv_init_dir), files_manager():
# Act
use_coverage()

Expand All @@ -224,7 +224,7 @@ def test_no_pyproject_toml(
# Set python version
(tmp_path / ".python-version").write_text(get_python_version())

with change_cwd(tmp_path), PyprojectTOMLManager():
with change_cwd(tmp_path), files_manager():
# Act
use_coverage()

Expand Down Expand Up @@ -331,7 +331,7 @@ def test_unused(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]):
assert not err

def test_roundtrip(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]):
with change_cwd(uv_init_dir), PyprojectTOMLManager():
with change_cwd(uv_init_dir), files_manager():
# Arrange
with usethis_config.set(quiet=True):
use_coverage()
Expand Down Expand Up @@ -1360,6 +1360,79 @@ def test_ruff_integration(self, uv_init_dir: Path):
# Assert
assert "PT" in RuffTool().get_rules()

@pytest.mark.usefixtures("_vary_network_conn")
def test_pytest_ini_priority(self, uv_init_dir: Path):
# Arrange
(uv_init_dir / "pytest.ini").touch()
(uv_init_dir / "pyproject.toml").touch()

# Act
with change_cwd(uv_init_dir), files_manager():
use_pytest()

# Assert
assert (
(uv_init_dir / "pytest.ini").read_text()
== """\
[pytest]
testpaths =
tests
addopts =
--import-mode=importlib
-ra
--showlocals
--strict-markers
--strict-config
filterwarnings =
error
xfail_strict = True
log_cli_level = INFO
minversion = 7
"""
)

with PyprojectTOMLManager() as manager:
assert ["tool", "pytest"] not in manager

@pytest.mark.usefixtures("_vary_network_conn")
def test_pyproject_with_ini_priority(
self, uv_init_repo_dir: Path, capfd: pytest.CaptureFixture[str]
):
# testing it takes priority over setup.cfg
# Arrange
(uv_init_repo_dir / "setup.cfg").touch()
(uv_init_repo_dir / "pyproject.toml").write_text("""\
[tool.pytest.ini_options]
testpaths = ["tests"]
""")

# Act
with change_cwd(uv_init_repo_dir), files_manager():
use_pytest()

# Assert
assert (uv_init_repo_dir / "setup.cfg").read_text() == "", (
"Expected pyproject.toml to take priority when it has a [tool.pytest.ini_options] section"
)

@pytest.mark.usefixtures("_vary_network_conn")
def test_pyproject_without_ini_priority(
self, uv_init_repo_dir: Path, capfd: pytest.CaptureFixture[str]
):
# Arrange
(uv_init_repo_dir / "setup.cfg").touch()
(uv_init_repo_dir / "pyproject.toml").write_text("""\
[tool.pytest]
foo = "bar"
""")

# Act
with change_cwd(uv_init_repo_dir), files_manager():
use_pytest()

# Assert
assert (uv_init_repo_dir / "setup.cfg").read_text()

class TestRemove:
class TestRuffIntegration:
def test_deselected(self, uv_init_dir: Path):
Expand Down