Skip to content

Commit 259238c

Browse files
507 usethis docstyle uses ini conventions in toml file (#510)
* Add failing test * Add passing test * Create workaround for tomlkit issue * Tweak test * Avoid setting pyprojec.toml config as dict in tests * Have multiple behaviours to preserve style as much as possible * Handle edge case (bug?) in tomlkit's handling of key deletion from a table * Add test and simplify deletion logic
1 parent c753fba commit 259238c

File tree

6 files changed

+262
-32
lines changed

6 files changed

+262
-32
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ repos:
88
hooks:
99
- id: uv-lock
1010
name: uv-lock
11-
entry: uv lock
11+
entry: uv lock --offline
1212
files: ^(uv\.lock|pyproject\.toml|uv\.toml)$
1313
language: system
1414
always_run: true
1515
pass_filenames: false
1616
- id: uv-sync
1717
name: uv-sync
18-
entry: uv sync --no-active
18+
entry: uv sync --no-active --offline
1919
args: ["--locked"]
2020
language: system
2121
always_run: true

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

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from __future__ import annotations
22

3+
import contextlib
34
import copy
45
from typing import TYPE_CHECKING, Any
56

67
import mergedeep
78
import tomlkit.api
9+
import tomlkit.items
810
from pydantic import TypeAdapter
911
from tomlkit import TOMLDocument
1012
from tomlkit.exceptions import TOMLKitError
@@ -121,50 +123,62 @@ def set_value(
121123
"""
122124
toml_document = copy.copy(self.get())
123125

126+
if not keys:
127+
# Root level config - value must be a mapping.
128+
TypeAdapter(dict).validate_python(toml_document)
129+
assert isinstance(toml_document, dict)
130+
TypeAdapter(dict).validate_python(value)
131+
assert isinstance(value, dict)
132+
if not toml_document or exists_ok:
133+
toml_document.update(value)
134+
self.commit(toml_document)
135+
return
136+
137+
d, parent = toml_document, {}
138+
shared_keys = []
124139
try:
125140
# Index our way into each ID key.
126141
# Eventually, we should land at a final dict, which is the one we are setting.
127-
d, parent = toml_document, {}
128-
if not keys:
129-
# Root level config - value must be a mapping.
130-
TypeAdapter(dict).validate_python(d)
131-
assert isinstance(d, dict)
132-
TypeAdapter(dict).validate_python(value)
133-
assert isinstance(value, dict)
134-
if not d:
135-
raise KeyError
136142
for key in keys:
137143
TypeAdapter(dict).validate_python(d)
138144
assert isinstance(d, dict)
139145
d, parent = d[key], d
146+
shared_keys.append(key)
140147
except KeyError:
141148
# The old configuration should be kept for all ID keys except the
142149
# final/deepest one which shouldn't exist anyway since we checked as much,
143150
# above. For example, if there is [tool.ruff] then we shouldn't overwrite it
144151
# with [tool.deptry]; they should coexist. So under the "tool" key, we need
145-
# to merge the two dicts.
146-
contents = value
147-
for key in reversed(keys):
148-
contents = {key: contents}
149-
toml_document = mergedeep.merge(toml_document, contents) # type: ignore[reportAssignmentType]
150-
assert isinstance(toml_document, TOMLDocument)
152+
# to "merge" the two dicts.
153+
154+
if len(keys) <= 3:
155+
contents = value
156+
for key in reversed(keys):
157+
contents = {key: contents}
158+
toml_document = mergedeep.merge(toml_document, contents) # type: ignore[reportAssignmentType]
159+
assert isinstance(toml_document, TOMLDocument)
160+
else:
161+
# Note that this alternative logic is just to avoid a bug:
162+
# https://github.com/nathanjmcdougall/usethis-python/issues/507
163+
TypeAdapter(dict).validate_python(d)
164+
assert isinstance(d, dict)
165+
166+
unshared_keys = keys[len(shared_keys) :]
167+
168+
d[_get_unified_key(unshared_keys)] = value
151169
else:
152170
if not exists_ok:
153171
# The configuration is already present, which is not allowed.
154172
if keys:
155173
msg = f"Configuration value '{'.'.join(keys)}' is already set."
156174
else:
157-
msg = "Configuration value is at root level is already set."
175+
msg = "Configuration value at root level is already set."
158176
raise TOMLValueAlreadySetError(msg)
159177
else:
160178
# The configuration is already present, but we're allowed to overwrite it.
161179
TypeAdapter(dict).validate_python(parent)
162180
assert isinstance(parent, dict)
163-
if parent:
164-
parent[keys[-1]] = value
165-
else:
166-
# i.e. the case where we're creating the root of the document
167-
toml_document.update(value)
181+
parent[keys[-1]] = value
168182

169183
self.commit(toml_document)
170184

@@ -204,7 +218,14 @@ def __delitem__(self, keys: list[str]) -> None:
204218
for key in list(d.keys()):
205219
del d[key]
206220
else:
207-
del d[keys[-1]]
221+
with contextlib.suppress(KeyError):
222+
# There is a strange behaviour (bug?) in tomlkit where deleting a key
223+
# has two separate lines:
224+
# self._value.remove(key) # noqa: ERA001
225+
# dict.__delitem__(self, key) # noqa: ERA001
226+
# but it's not clear why there's this duplicate and it causes a KeyError
227+
# in some cases.
228+
d.remove(keys[-1])
208229

209230
# Cleanup: any empty sections should be removed.
210231
for idx in range(len(keys) - 1):
@@ -286,3 +307,12 @@ def remove_from_list(self, *, keys: list[str], values: list[Any]) -> None:
286307
p_parent[keys[-1]] = new_values
287308

288309
self.commit(toml_document)
310+
311+
312+
def _get_unified_key(keys: list[str]) -> tomlkit.items.Key:
313+
single_keys = [tomlkit.items.SingleKey(key) for key in keys]
314+
if len(single_keys) == 1:
315+
(unified_key,) = single_keys
316+
else:
317+
unified_key = tomlkit.items.DottedKey(single_keys)
318+
return unified_key

src/usethis/_integrations/uv/init.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ def ensure_pyproject_toml(*, author: bool = True) -> None:
3636
if not ((Path.cwd() / "src").exists() and (Path.cwd() / "src").is_dir()):
3737
# hatch needs to know where to find the package
3838
PyprojectTOMLManager().set_value(
39-
keys=["tool", "hatch", "build", "targets", "wheel"],
40-
value={"packages": ["."]},
39+
keys=["tool", "hatch", "build", "targets", "wheel", "packages"],
40+
value=["."],
4141
)

tests/usethis/_integrations/file/toml/test_toml_io_.py

Lines changed: 151 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
from pathlib import Path
22

33
import pytest
4-
5-
from usethis._integrations.file.toml.errors import TOMLValueAlreadySetError
4+
import tomlkit
5+
import tomlkit.api
6+
import tomlkit.items
7+
8+
from usethis._integrations.file.toml.errors import (
9+
TOMLValueAlreadySetError,
10+
TOMLValueMissingError,
11+
)
612
from usethis._integrations.file.toml.io_ import TOMLFileManager
713
from usethis._test import change_cwd
814

@@ -71,6 +77,131 @@ def relative_path(self) -> Path:
7177
# Assert
7278
assert manager._content == 42
7379

80+
def test_set_high_levels_of_nesting(self, tmp_path: Path) -> None:
81+
# Arrange
82+
class MyTOMLFileManager(TOMLFileManager):
83+
@property
84+
def relative_path(self) -> Path:
85+
return Path("pyproject.toml")
86+
87+
with change_cwd(tmp_path), MyTOMLFileManager() as manager:
88+
(tmp_path / "pyproject.toml").touch()
89+
90+
# Act
91+
manager.set_value(
92+
keys=[],
93+
value={
94+
"project": {"name": "usethis", "version": "0.1.0"},
95+
"tool": {
96+
"ruff": {
97+
"lint": {
98+
"select": ["A"],
99+
"pydocstyle": {"convention": "pep257"},
100+
}
101+
}
102+
},
103+
},
104+
)
105+
106+
# Assert
107+
contents = (tmp_path / "pyproject.toml").read_text()
108+
assert contents == (
109+
"""\
110+
[project]
111+
name = "usethis"
112+
version = "0.1.0"
113+
114+
[tool.ruff.lint]
115+
select = ["A"]
116+
117+
[tool.ruff.lint.pydocstyle]
118+
convention = "pep257"
119+
"""
120+
)
121+
122+
def test_set_high_levels_of_nesting_in_existing(self, tmp_path: Path) -> None:
123+
# https://github.com/nathanjmcdougall/usethis-python/issues/507
124+
# Arrange
125+
class MyTOMLFileManager(TOMLFileManager):
126+
@property
127+
def relative_path(self) -> Path:
128+
return Path("pyproject.toml")
129+
130+
with change_cwd(tmp_path), MyTOMLFileManager() as manager:
131+
pyproject_toml_path = tmp_path / "pyproject.toml"
132+
pyproject_toml_path.write_text(
133+
"""\
134+
[project]
135+
name = "usethis"
136+
version = "0.1.0"
137+
138+
[tool.ruff]
139+
lint.select = [ "A" ]
140+
"""
141+
)
142+
143+
# Act
144+
manager.set_value(
145+
keys=["tool", "ruff", "lint", "pydocstyle", "convention"],
146+
value="pep257",
147+
)
148+
149+
# Assert
150+
contents = (tmp_path / "pyproject.toml").read_text()
151+
assert contents == (
152+
"""\
153+
[project]
154+
name = "usethis"
155+
version = "0.1.0"
156+
157+
[tool.ruff]
158+
lint.select = [ "A" ]
159+
lint.pydocstyle.convention = "pep257"
160+
"""
161+
)
162+
163+
def test_tomlkit_high_levels_of_nesting_in_existing(
164+
self, tmp_path: Path
165+
) -> None:
166+
# To help debug https://github.com/nathanjmcdougall/usethis-python/issues/507
167+
# This proves that dottedkey works.
168+
169+
# Arrange
170+
txt = """\
171+
[tool.ruff]
172+
lint.select = [ "A" ]
173+
"""
174+
175+
toml_document: tomlkit.TOMLDocument = tomlkit.api.parse(txt)
176+
177+
# Act
178+
tool_section = toml_document["tool"]
179+
assert isinstance(tool_section, tomlkit.items.Table)
180+
ruff_section = tool_section["ruff"]
181+
assert isinstance(ruff_section, tomlkit.items.Table)
182+
lint_section = ruff_section["lint"]
183+
assert isinstance(lint_section, tomlkit.items.Table)
184+
lint_section[
185+
tomlkit.items.DottedKey(
186+
[
187+
tomlkit.items.SingleKey("pydocstyle"),
188+
tomlkit.items.SingleKey("convention"),
189+
]
190+
)
191+
] = "pep257"
192+
# Whereas this doesn't work:
193+
# lint_section["pydocstyle"] = {"convention": "pep257"} # noqa: ERA001
194+
195+
# Assert
196+
contents = tomlkit.api.dumps(toml_document)
197+
assert contents == (
198+
"""\
199+
[tool.ruff]
200+
lint.select = [ "A" ]
201+
lint.pydocstyle.convention = "pep257"
202+
"""
203+
)
204+
74205
class TestSetValue:
75206
def test_no_keys(self, tmp_path: Path) -> None:
76207
# Arrange
@@ -123,7 +254,7 @@ def relative_path(self) -> Path:
123254
# Act, Assert
124255
with pytest.raises(
125256
TOMLValueAlreadySetError,
126-
match="Configuration value is at root level is already set.",
257+
match="Configuration value at root level is already set.",
127258
):
128259
manager.set_value(keys=[], value={"a": "c"}, exists_ok=False)
129260

@@ -180,7 +311,7 @@ def relative_path(self) -> Path:
180311
):
181312
manager.set_value(keys=["a"], value=["d"])
182313

183-
class TestDel:
314+
class TestDelItem:
184315
def test_no_keys(self, tmp_path: Path) -> None:
185316
# Arrange
186317
class MyTOMLFileManager(TOMLFileManager):
@@ -234,6 +365,22 @@ def relative_path(self) -> Path:
234365
# Assert
235366
assert not manager._content
236367

368+
def test_removing_doesnt_exist(self, tmp_path: Path) -> None:
369+
# Arrange
370+
class MyTOMLFileManager(TOMLFileManager):
371+
@property
372+
def relative_path(self) -> Path:
373+
return Path("pyproject.toml")
374+
375+
with change_cwd(tmp_path), MyTOMLFileManager() as manager:
376+
(tmp_path / "pyproject.toml").touch()
377+
378+
manager[["a"]] = "b"
379+
380+
# Act
381+
with pytest.raises(TOMLValueMissingError):
382+
del manager[["c"]]
383+
237384
class TestExtendList:
238385
def test_inplace_modifications(self, tmp_path: Path) -> None:
239386
# Arrange

tests/usethis/_interface/test_docstyle.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,56 @@ def test_invalid_pyproject_toml(
3636

3737
# Assert
3838
assert result.exit_code == 1, result.output
39+
40+
def test_pyproject_toml_success(self, tmp_path: Path):
41+
# https://github.com/nathanjmcdougall/usethis-python/issues/507
42+
43+
# Arrange
44+
valid_pyproject_toml = tmp_path / "pyproject.toml"
45+
valid_pyproject_toml.touch()
46+
47+
# Act
48+
with change_cwd(tmp_path):
49+
runner = CliRunner()
50+
result = runner.invoke(app, ["docstyle", "numpy"])
51+
52+
# Assert
53+
assert result.exit_code == 0, result.output
54+
assert (
55+
"✔ Setting docstring style to 'numpy' in 'pyproject.toml'." in result.output
56+
)
57+
58+
assert valid_pyproject_toml.exists()
59+
content = valid_pyproject_toml.read_text()
60+
assert (
61+
"""\
62+
[tool.ruff]
63+
line-length = 88
64+
lint.pydocstyle.convention = "numpy"\
65+
"""
66+
in content
67+
)
68+
69+
def test_adding_to_existing_file(self, tmp_path: Path):
70+
# Arrange
71+
existing_pyproject_toml = tmp_path / "pyproject.toml"
72+
existing_pyproject_toml.write_text(
73+
"""\
74+
[project]
75+
name = "usethis"
76+
version = "0.1.0"
77+
78+
[tool.ruff]
79+
lint.select = [ "A" ]
80+
"""
81+
)
82+
83+
# Act
84+
with change_cwd(tmp_path):
85+
runner = CliRunner()
86+
result = runner.invoke(app, ["docstyle", "pep257"])
87+
88+
# Assert
89+
assert result.exit_code == 0, result.output
90+
content = existing_pyproject_toml.read_text()
91+
assert "[lint.pydocstyle]" not in content # Wrong section name

0 commit comments

Comments
 (0)