Skip to content

Commit 85f13c7

Browse files
558 usethis tool coverage breaks existing config in some cases (#560)
* Add failing test to reproduce bug in `usethis tool coverage` breaking TOML config * Switch to using tmp_path in test_after_codespell test * Add lower-level failing tests * Add lower level tests * Finish merge * Use better strategy for handling high levels of nesting in tomlkit workaround * Tweak tests to accomodate slightly different config structure * Simplify logic and handle edge case of root level config * Empty commit to trigger CI * Add additional check to failing test * Fix indexing * Handle merge case * Use "seeding" approach to fine-tune workaround * Massaging logic to pass tests * Restrict workarond * Modify test to reflect updated behaviour * Bump tomlkit * Pass tests * Pass tests * Tweak logic to pass tests * Revert "Bump tomlkit" This reverts commit f17264c. * Re-enable PLR rule
1 parent 41d1feb commit 85f13c7

File tree

6 files changed

+212
-39
lines changed

6 files changed

+212
-39
lines changed

src/usethis/_integrations/file/toml/io_.py

Lines changed: 66 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pydantic import TypeAdapter
1212
from tomlkit import TOMLDocument
1313
from tomlkit.exceptions import TOMLKitError
14-
from typing_extensions import assert_never
14+
from typing_extensions import Never, assert_never
1515

1616
from usethis._integrations.file.toml.errors import (
1717
TOMLDecodeError,
@@ -34,6 +34,8 @@
3434
from pathlib import Path
3535
from typing import ClassVar
3636

37+
from tomlkit.container import Container
38+
from tomlkit.items import Item
3739
from typing_extensions import Self
3840

3941
from usethis._io import Key
@@ -145,7 +147,7 @@ def set_value(
145147
return
146148

147149
d, parent = toml_document, {}
148-
shared_keys = []
150+
shared_keys: list[str] = []
149151
try:
150152
# Index our way into each ID key.
151153
# Eventually, we should land at a final dict, which is the one we are setting.
@@ -155,42 +157,24 @@ def set_value(
155157
d, parent = d[key], d
156158
shared_keys.append(key)
157159
except KeyError:
158-
# The old configuration should be kept for all ID keys except the
159-
# final/deepest one which shouldn't exist anyway since we checked as much,
160-
# above. For example, if there is [tool.ruff] then we shouldn't overwrite it
161-
# with [tool.deptry]; they should coexist. So under the "tool" key, we need
162-
# to "merge" the two dicts.
163-
164-
if len(keys) <= 3:
165-
contents = value
166-
for key in reversed(keys):
167-
contents = {key: contents}
168-
toml_document = mergedeep.merge(toml_document, contents) # type: ignore[reportAssignmentType]
169-
assert isinstance(toml_document, TOMLDocument)
170-
else:
171-
# Note that this alternative logic is just to avoid a bug:
172-
# https://github.com/nathanjmcdougall/usethis-python/issues/507
173-
TypeAdapter(dict).validate_python(d)
174-
assert isinstance(d, dict)
175-
176-
unshared_keys = keys[len(shared_keys) :]
177-
178-
d[_get_unified_key(unshared_keys)] = value
160+
_set_value_in_existing(
161+
toml_document=toml_document,
162+
current_container=d,
163+
keys=keys,
164+
current_keys=shared_keys,
165+
value=value,
166+
)
179167
else:
180168
if not exists_ok:
181169
# The configuration is already present, which is not allowed.
182-
if keys:
183-
msg = f"Configuration value '{print_keys(keys)}' is already set."
184-
else:
185-
msg = "Configuration value at root level is already set."
186-
raise TOMLValueAlreadySetError(msg)
170+
_raise_already_set(keys)
187171
else:
188172
# The configuration is already present, but we're allowed to overwrite it.
189173
TypeAdapter(dict).validate_python(parent)
190174
assert isinstance(parent, dict)
191175
parent[keys[-1]] = value
192176

193-
self.commit(toml_document)
177+
self.commit(toml_document) # type: ignore[reportAssignmentType]
194178

195179
def __delitem__(self, keys: Sequence[Key]) -> None:
196180
"""Delete a value in the TOML file.
@@ -322,6 +306,50 @@ def remove_from_list(self, *, keys: Sequence[Key], values: list[Any]) -> None:
322306
self.commit(toml_document)
323307

324308

309+
def _set_value_in_existing(
310+
*,
311+
toml_document: TOMLDocument,
312+
current_container: TOMLDocument | Item | Container,
313+
keys: Sequence[Key],
314+
current_keys: Sequence[Key],
315+
value: Any,
316+
) -> None:
317+
# The old configuration should be kept for all ID keys except the
318+
# final/deepest one which shouldn't exist anyway since we checked as much,
319+
# above. For example, if there is [tool.ruff] then we shouldn't overwrite it
320+
# with [tool.deptry]; they should coexist. So under the "tool" key, we need
321+
# to "merge" the two dicts.
322+
323+
if len(keys) <= 3:
324+
contents = value
325+
for key in reversed(keys):
326+
contents = {key: contents}
327+
toml_document = mergedeep.merge(toml_document, contents) # type: ignore[reportAssignmentType]
328+
assert isinstance(toml_document, TOMLDocument)
329+
else:
330+
# Note that this alternative logic is just to avoid a bug:
331+
# https://github.com/nathanjmcdougall/usethis-python/issues/507
332+
TypeAdapter(dict).validate_python(current_container)
333+
assert isinstance(current_container, dict)
334+
335+
unshared_keys = keys[len(current_keys) :]
336+
337+
if len(current_keys) == 1:
338+
# In this case, we need to "seed" the section to avoid another bug:
339+
# https://github.com/nathanjmcdougall/usethis-python/issues/558
340+
341+
placeholder = {keys[0]: {keys[1]: {}}}
342+
toml_document = mergedeep.merge(toml_document, placeholder) # type: ignore[reportArgumentType]
343+
344+
contents = value
345+
for key in reversed(unshared_keys[1:]):
346+
contents = {key: contents}
347+
348+
current_container[keys[1]] = contents # type: ignore[reportAssignmentType]
349+
else:
350+
current_container[_get_unified_key(unshared_keys)] = value
351+
352+
325353
def _validate_keys(keys: Sequence[Key]) -> list[str]:
326354
"""Validate the keys.
327355
@@ -348,6 +376,15 @@ def _validate_keys(keys: Sequence[Key]) -> list[str]:
348376
return so_far_keys
349377

350378

379+
def _raise_already_set(keys: Sequence[Key]) -> Never:
380+
"""Raise an error if the configuration is already set."""
381+
if keys:
382+
msg = f"Configuration value '{print_keys(keys)}' is already set."
383+
else:
384+
msg = "Configuration value at root level is already set."
385+
raise TOMLValueAlreadySetError(msg)
386+
387+
351388
def _get_unified_key(keys: Sequence[Key]) -> tomlkit.items.Key:
352389
keys = _validate_keys(keys)
353390

tests/usethis/_core/test_core_tool.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -272,14 +272,17 @@ def test_from_nothing(
272272
assert Dependency(
273273
name="coverage", extras=frozenset({"toml"})
274274
) in get_deps_from_group("test")
275-
out, err = capfd.readouterr()
276-
assert not err
277-
assert out == (
278-
"✔ Adding dependency 'coverage' to the 'test' group in 'pyproject.toml'.\n"
279-
"☐ Install the dependency 'coverage'.\n"
280-
"✔ Adding coverage config to 'pyproject.toml'.\n"
281-
"☐ Run 'uv run coverage help' to see available coverage commands.\n"
282-
)
275+
276+
assert ["tool", "uv", "default-groups"] in PyprojectTOMLManager()
277+
278+
out, err = capfd.readouterr()
279+
assert not err
280+
assert out == (
281+
"✔ Adding dependency 'coverage' to the 'test' group in 'pyproject.toml'.\n"
282+
"☐ Install the dependency 'coverage'.\n"
283+
"✔ Adding coverage config to 'pyproject.toml'.\n"
284+
"☐ Run 'uv run coverage help' to see available coverage commands.\n"
285+
)
283286

284287
@pytest.mark.usefixtures("_vary_network_conn")
285288
def test_no_pyproject_toml(
@@ -387,6 +390,36 @@ class .*\\bProtocol\\):
387390
"""
388391
)
389392

393+
@pytest.mark.usefixtures("_vary_network_conn")
394+
def test_after_codespell(self, tmp_path: Path):
395+
# To check the config is valid
396+
# https://github.com/nathanjmcdougall/usethis-python/issues/558
397+
398+
# Arrange
399+
(tmp_path / "pyproject.toml").write_text("""\
400+
[project]
401+
name = "example"
402+
version = "0.1.0"
403+
description = "Add your description here"
404+
405+
[dependency-groups]
406+
dev = [
407+
"codespell>=2.4.1",
408+
]
409+
410+
[tool.codespell]
411+
ignore-regex = ["[A-Za-z0-9+/]{100,}"]
412+
""")
413+
414+
# Act
415+
with change_cwd(tmp_path), files_manager():
416+
use_coverage()
417+
418+
# Assert
419+
assert ["tool", "coverage"] in PyprojectTOMLManager()
420+
content = (tmp_path / "pyproject.toml").read_text()
421+
assert "[tool.coverage]" in content
422+
390423
class TestRemove:
391424
def test_unused(self, uv_init_dir: Path, capfd: pytest.CaptureFixture[str]):
392425
with change_cwd(uv_init_dir), PyprojectTOMLManager():

tests/usethis/_integrations/file/pyproject_toml/test_pyproject_toml_io_.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,27 @@ def test_update_not_exists_ok(self, tmp_path: Path):
269269
keys=["tool", "usethis", "key"], value="value2", exists_ok=False
270270
)
271271

272+
def test_coverage_to_codespell(self, tmp_path: Path):
273+
# https://github.com/nathanjmcdougall/usethis-python/issues/558
274+
275+
# Arrange
276+
(tmp_path / "pyproject.toml").write_text("""\
277+
[tool.codespell]
278+
ignore-regex = ["[A-Za-z0-9+/]{100,}"]
279+
""")
280+
281+
# Act
282+
with change_cwd(tmp_path), PyprojectTOMLManager() as file_manager:
283+
file_manager.set_value(
284+
keys=["tool", "coverage", "run", "source"], value=["."]
285+
)
286+
287+
# Assert
288+
assert ["tool", "coverage"] in PyprojectTOMLManager()
289+
290+
with change_cwd(tmp_path), PyprojectTOMLManager() as file_manager:
291+
assert ["tool", "coverage"] in PyprojectTOMLManager()
292+
272293
class TestDel:
273294
def test_already_missing(self, tmp_path: Path):
274295
# Arrange

tests/usethis/_integrations/sonarqube/test_sonarqube_config.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,20 @@ def test_file_not_exists(self, uv_init_dir: Path):
3838
# If the file does not exist, we should construct based on information in
3939
# the repo.
4040

41+
# Arrange
4142
with change_cwd(uv_init_dir), PyprojectTOMLManager():
42-
# Arrange
4343
PyprojectTOMLManager().set_value(
4444
keys=["tool", "usethis", "sonarqube", "project-key"], value="foobar"
4545
)
4646
PyprojectTOMLManager().set_value(
4747
keys=["tool", "coverage", "xml", "output"], value="coverage.xml"
4848
)
4949
python_pin("3.12")
50+
content = (uv_init_dir / "pyproject.toml").read_text()
51+
assert "xml" in content
5052

51-
# Act
53+
# Act
54+
with change_cwd(uv_init_dir), PyprojectTOMLManager():
5255
result = get_sonar_project_properties()
5356

5457
# Assert
@@ -79,7 +82,10 @@ def test_different_python_version(self, tmp_path: Path):
7982
PyprojectTOMLManager().set_value(
8083
keys=["tool", "coverage", "xml", "output"], value="coverage.xml"
8184
)
85+
content = (tmp_path / "pyproject.toml").read_text()
86+
assert "xml" in content
8287

88+
with change_cwd(tmp_path), PyprojectTOMLManager():
8389
# Act
8490
result = get_sonar_project_properties()
8591

tests/usethis/_interface/test_tool.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from typer.testing import CliRunner
55

66
from usethis._config import usethis_config
7+
from usethis._config_file import files_manager
8+
from usethis._integrations.file.pyproject_toml.io_ import PyprojectTOMLManager
79
from usethis._integrations.uv.call import call_uv_subprocess
810
from usethis._interface.tool import ALL_TOOL_COMMANDS, app
911
from usethis._subprocess import SubprocessFailedError, call_subprocess
@@ -55,6 +57,42 @@ def test_runs(self, tmp_path: Path):
5557
assert result.exit_code == 0, result.output
5658
call_subprocess(["coverage", "run", "."])
5759

60+
@pytest.mark.usefixtures("_vary_network_conn")
61+
def test_after_codespell(self, tmp_path: Path):
62+
# To check the config is valid
63+
# https://github.com/nathanjmcdougall/usethis-python/issues/558
64+
65+
# Arrange
66+
(tmp_path / "pyproject.toml").write_text("""\
67+
[project]
68+
name = "example"
69+
version = "0.1.0"
70+
description = "Add your description here"
71+
72+
[dependency-groups]
73+
dev = [
74+
"codespell>=2.4.1",
75+
]
76+
77+
[tool.codespell]
78+
ignore-regex = ["[A-Za-z0-9+/]{100,}"]
79+
""")
80+
81+
runner = CliRunner()
82+
with change_cwd(tmp_path):
83+
# Act
84+
if not usethis_config.offline:
85+
result = runner.invoke(app, ["coverage"])
86+
else:
87+
result = runner.invoke(app, ["coverage", "--offline"])
88+
89+
# Assert
90+
assert result.exit_code == 0, result.output
91+
with files_manager():
92+
assert ["tool", "coverage"] in PyprojectTOMLManager()
93+
94+
assert "[tool.coverage]" in (tmp_path / "pyproject.toml").read_text()
95+
5896

5997
class TestDeptry:
6098
@pytest.mark.usefixtures("_vary_network_conn")
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from pathlib import Path
2+
3+
from usethis._config_file import files_manager
4+
from usethis._integrations.file.pyproject_toml.io_ import PyprojectTOMLManager
5+
from usethis._test import change_cwd
6+
from usethis._tool.impl.coverage import CoverageTool
7+
8+
9+
class TestCoverageTool:
10+
class TestAddConfigs:
11+
def test_after_codespell(self, tmp_path: Path):
12+
# To check the config is valid
13+
# https://github.com/nathanjmcdougall/usethis-python/issues/558
14+
15+
# Arrange
16+
(tmp_path / "pyproject.toml").write_text("""\
17+
[project]
18+
name = "example"
19+
version = "0.1.0"
20+
description = "Add your description here"
21+
22+
[dependency-groups]
23+
dev = [
24+
"codespell>=2.4.1",
25+
]
26+
27+
[tool.codespell]
28+
ignore-regex = ["[A-Za-z0-9+/]{100,}"]
29+
""")
30+
31+
# Act
32+
with change_cwd(tmp_path), files_manager():
33+
CoverageTool().add_configs()
34+
35+
# Assert
36+
with change_cwd(tmp_path), files_manager():
37+
assert ["tool", "coverage"] in PyprojectTOMLManager()
38+
assert "[tool.coverage]" in (tmp_path / "pyproject.toml").read_text()

0 commit comments

Comments
 (0)