Skip to content

Commit d56ab8c

Browse files
Implement usethis tool coverage (#223)
* Implement usethis tool coverage * Try to keep benchmark consistent * Simply revised integration messages for speed (Create a method for usage instructions #222) * Fix bug * Fix bug * Use caching for reading pyproject.toml * Invalidate cache based on CWD and uv integration
1 parent 6dd166a commit d56ab8c

File tree

8 files changed

+267
-43
lines changed

8 files changed

+267
-43
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ To use pytest, run:
7373

7474
```console
7575
$ uvx usethis tool pytest
76-
✔ Adding dependencies 'pytest', 'pytest-cov', 'coverage' to the 'test' dependency group in 'pyproject.toml'.
76+
✔ Adding dependencies 'pytest', 'pytest-cov' to the 'test' dependency group in 'pyproject.toml'.
7777
✔ Adding pytest config to 'pyproject.toml'.
7878
✔ Enabling Ruff rule 'PT' in 'pyproject.toml'.
7979
✔ Creating '/tests'.

src/usethis/_core/tool.py

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from usethis._integrations.uv.init import ensure_pyproject_toml
2626
from usethis._tool import (
2727
ALL_TOOLS,
28+
CoverageTool,
2829
DeptryTool,
2930
PreCommitTool,
3031
PyprojectFmtTool,
@@ -34,6 +35,33 @@
3435
)
3536

3637

38+
def use_coverage(*, remove: bool = False) -> None:
39+
tool = CoverageTool()
40+
41+
ensure_pyproject_toml()
42+
43+
if not remove:
44+
add_deps_to_group(tool.dev_deps, "test")
45+
46+
tool.add_pyproject_configs()
47+
48+
if PytestTool().is_used():
49+
_coverage_instructions_pytest()
50+
else:
51+
_coverage_instructions_basic()
52+
else:
53+
tool.remove_pyproject_configs()
54+
remove_deps_from_group(tool.dev_deps, "test")
55+
56+
57+
def _coverage_instructions_basic() -> None:
58+
box_print("Run 'coverage help' to see available coverage commands.")
59+
60+
61+
def _coverage_instructions_pytest() -> None:
62+
box_print("Run 'pytest --cov' to run your tests with coverage.")
63+
64+
3765
def use_deptry(*, remove: bool = False) -> None:
3866
tool = DeptryTool()
3967

@@ -53,6 +81,7 @@ def use_deptry(*, remove: bool = False) -> None:
5381

5482
def use_pre_commit(*, remove: bool = False) -> None:
5583
tool = PreCommitTool()
84+
pyproject_fmt_tool = PyprojectFmtTool()
5685

5786
ensure_pyproject_toml()
5887

@@ -62,13 +91,14 @@ def use_pre_commit(*, remove: bool = False) -> None:
6291
if _tool.is_used():
6392
_tool.add_pre_commit_repo_configs()
6493

65-
if PyprojectFmtTool().is_used():
94+
if pyproject_fmt_tool.is_used():
6695
# We will use pre-commit instead of the dev-dep.
67-
remove_deps_from_group(PyprojectFmtTool().get_unique_dev_deps(), "dev")
68-
use_pyproject_fmt()
96+
remove_deps_from_group(pyproject_fmt_tool.get_unique_dev_deps(), "dev")
97+
pyproject_fmt_tool.add_pyproject_configs()
98+
_pyproject_fmt_instructions_pre_commit()
6999

70100
if RequirementsTxtTool().is_used():
71-
use_requirements_txt()
101+
_requirements_txt_instructions_pre_commit()
72102

73103
if not get_hook_names():
74104
add_placeholder_hook()
@@ -91,12 +121,15 @@ def use_pre_commit(*, remove: bool = False) -> None:
91121
remove_deps_from_group(tool.dev_deps, "dev")
92122

93123
# Need to add a new way of running some hooks manually if they are not dev
94-
# dependencies yet
95-
if PyprojectFmtTool().is_used():
96-
use_pyproject_fmt()
124+
# dependencies yet - explain to the user.
125+
if pyproject_fmt_tool.is_used():
126+
add_deps_to_group(pyproject_fmt_tool.dev_deps, "dev")
127+
_pyproject_fmt_instructions_basic()
97128

129+
# Likewise, explain how to manually generate the requirements.txt file, since
130+
# they're not going to do it via pre-commit anymore.
98131
if RequirementsTxtTool().is_used():
99-
use_requirements_txt()
132+
_requirements_txt_instructions_basic()
100133

101134

102135
def use_pyproject_fmt(*, remove: bool = False) -> None:
@@ -115,18 +148,24 @@ def use_pyproject_fmt(*, remove: bool = False) -> None:
115148
tool.add_pyproject_configs()
116149

117150
if not is_pre_commit:
118-
box_print("Run 'pyproject-fmt pyproject.toml' to run pyproject-fmt.")
151+
_pyproject_fmt_instructions_basic()
119152
else:
120-
box_print(
121-
"Run 'pre-commit run pyproject-fmt --all-files' to run pyproject-fmt."
122-
)
153+
_pyproject_fmt_instructions_pre_commit()
123154
else:
124155
tool.remove_pyproject_configs()
125156
if PreCommitTool().is_used():
126157
tool.remove_pre_commit_repo_configs()
127158
remove_deps_from_group(tool.dev_deps, "dev")
128159

129160

161+
def _pyproject_fmt_instructions_basic() -> None:
162+
box_print("Run 'pyproject-fmt pyproject.toml' to run pyproject-fmt.")
163+
164+
165+
def _pyproject_fmt_instructions_pre_commit() -> None:
166+
box_print("Run 'pre-commit run pyproject-fmt --all-files' to run pyproject-fmt.")
167+
168+
130169
def use_pytest(*, remove: bool = False) -> None:
131170
tool = PytestTool()
132171

@@ -137,6 +176,7 @@ def use_pytest(*, remove: bool = False) -> None:
137176
tool.add_pyproject_configs()
138177
if RuffTool().is_used():
139178
select_ruff_rules(tool.get_associated_ruff_rules())
179+
140180
# deptry currently can't scan the tests folder for dev deps
141181
# https://github.com/fpgmaas/deptry/issues/302
142182
add_pytest_dir()
@@ -149,6 +189,9 @@ def use_pytest(*, remove: bool = False) -> None:
149189
)
150190
box_print("Add test functions with the format 'test_*()'.")
151191
box_print("Run 'pytest' to run the tests.")
192+
193+
if CoverageTool().is_used():
194+
_coverage_instructions_pytest()
152195
else:
153196
if is_bitbucket_used():
154197
remove_bitbucket_pytest_steps()
@@ -159,6 +202,9 @@ def use_pytest(*, remove: bool = False) -> None:
159202
remove_deps_from_group(tool.dev_deps, "test")
160203
remove_pytest_dir() # Last, since this is a manual step
161204

205+
if CoverageTool().is_used():
206+
_coverage_instructions_basic()
207+
162208

163209
def use_requirements_txt(*, remove: bool = False) -> None:
164210
tool = RequirementsTxtTool()
@@ -190,11 +236,9 @@ def use_requirements_txt(*, remove: bool = False) -> None:
190236
)
191237

192238
if not is_pre_commit:
193-
box_print(
194-
"Run 'uv export --no-dev --output-file=requirements.txt' to write 'requirements.txt'."
195-
)
239+
_requirements_txt_instructions_basic()
196240
else:
197-
box_print("Run the 'pre-commit run uv-export' to write 'requirements.txt'.")
241+
_requirements_txt_instructions_pre_commit()
198242
else:
199243
if PreCommitTool().is_used():
200244
tool.remove_pre_commit_repo_configs()
@@ -204,6 +248,16 @@ def use_requirements_txt(*, remove: bool = False) -> None:
204248
path.unlink()
205249

206250

251+
def _requirements_txt_instructions_basic() -> None:
252+
box_print(
253+
"Run 'uv export --no-dev --output-file=requirements.txt' to write 'requirements.txt'."
254+
)
255+
256+
257+
def _requirements_txt_instructions_pre_commit() -> None:
258+
box_print("Run the 'pre-commit run uv-export' to write 'requirements.txt'.")
259+
260+
207261
def use_ruff(*, remove: bool = False) -> None:
208262
tool = RuffTool()
209263

src/usethis/_integrations/pyproject/io.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import tomllib
2+
from functools import cache
23
from pathlib import Path
34
from typing import Any
45

@@ -11,8 +12,13 @@
1112

1213

1314
def read_pyproject_toml() -> tomlkit.TOMLDocument:
15+
return read_pyproject_toml_from_path(Path.cwd() / "pyproject.toml")
16+
17+
18+
@cache
19+
def read_pyproject_toml_from_path(path: Path) -> tomlkit.TOMLDocument:
1420
try:
15-
return tomlkit.parse((Path.cwd() / "pyproject.toml").read_text())
21+
return tomlkit.parse(path.read_text())
1622
except FileNotFoundError:
1723
msg = "'pyproject.toml' not found in the current directory."
1824
raise PyProjectTOMLNotFoundError(msg)
@@ -33,4 +39,5 @@ def read_pyproject_dict() -> dict[str, Any]:
3339

3440

3541
def write_pyproject_toml(toml_document: tomlkit.TOMLDocument) -> None:
42+
read_pyproject_toml_from_path.cache_clear()
3643
(Path.cwd() / "pyproject.toml").write_text(tomlkit.dumps(toml_document))

src/usethis/_integrations/uv/call.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from usethis._integrations.pyproject.io import read_pyproject_toml_from_path
12
from usethis._integrations.uv.errors import UVSubprocessFailedError
23
from usethis._subprocess import SubprocessFailedError, call_subprocess
34

@@ -8,6 +9,7 @@ def call_uv_subprocess(args: list[str]) -> str:
89
Raises:
910
UVSubprocessFailedError: If the subprocess fails.
1011
"""
12+
read_pyproject_toml_from_path.cache_clear()
1113
try:
1214
return call_subprocess(["uv", *args])
1315
except SubprocessFailedError as err:

src/usethis/_interface/tool.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from usethis._config import offline_opt, quiet_opt, usethis_config
77
from usethis._console import err_print
88
from usethis._core.tool import (
9+
use_coverage,
910
use_deptry,
1011
use_pre_commit,
1112
use_pyproject_fmt,
@@ -22,6 +23,14 @@
2223
)
2324

2425

26+
@app.command(help="Use the coverage code coverage measurement tool.")
27+
def coverage(
28+
remove: bool = remove_opt, offline: bool = offline_opt, quiet: bool = quiet_opt
29+
) -> None:
30+
with usethis_config.set(offline=offline, quiet=quiet):
31+
_run_tool(use_coverage, remove=remove)
32+
33+
2534
@app.command(
2635
help="Use the deptry linter: avoid missing or superfluous dependency declarations."
2736
)

src/usethis/_tool.py

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,46 @@ def remove_pyproject_configs(self) -> None:
161161
first_removal = False
162162

163163

164+
class CoverageTool(Tool):
165+
@property
166+
def name(self) -> str:
167+
return "coverage"
168+
169+
@property
170+
def dev_deps(self) -> list[str]:
171+
return ["coverage[toml]"]
172+
173+
def get_pyproject_configs(self) -> list[PyProjectConfig]:
174+
return [
175+
PyProjectConfig(
176+
id_keys=["tool", "coverage", "run"],
177+
value={
178+
"source": ["src"],
179+
"omit": ["*/pytest-of-*/*"],
180+
},
181+
),
182+
PyProjectConfig(
183+
id_keys=["tool", "coverage", "report"],
184+
value={
185+
"exclude_also": [
186+
"if TYPE_CHECKING:",
187+
"raise AssertionError",
188+
"raise NotImplementedError",
189+
"assert_never(.*)",
190+
"class .*\\bProtocol\\):",
191+
"@(abc\\.)?abstractmethod",
192+
]
193+
},
194+
),
195+
]
196+
197+
def get_pyproject_id_keys(self):
198+
return [["tool", "coverage"]]
199+
200+
def get_managed_files(self):
201+
return [Path(".coveragerc")]
202+
203+
164204
class DeptryTool(Tool):
165205
@property
166206
def name(self) -> str:
@@ -238,7 +278,7 @@ def name(self) -> str:
238278

239279
@property
240280
def dev_deps(self) -> list[str]:
241-
return ["pytest", "pytest-cov", "coverage[toml]"]
281+
return ["pytest", "pytest-cov"]
242282

243283
def get_pyproject_configs(self) -> list[PyProjectConfig]:
244284
return [
@@ -254,26 +294,6 @@ def get_pyproject_configs(self) -> list[PyProjectConfig]:
254294
}
255295
},
256296
),
257-
PyProjectConfig(
258-
id_keys=["tool", "coverage", "run"],
259-
value={
260-
"source": ["src"],
261-
"omit": ["*/pytest-of-*/*"],
262-
},
263-
),
264-
PyProjectConfig(
265-
id_keys=["tool", "coverage", "report"],
266-
value={
267-
"exclude_also": [
268-
"if TYPE_CHECKING:",
269-
"raise AssertionError",
270-
"raise NotImplementedError",
271-
"assert_never(.*)",
272-
"class .*\\bProtocol\\):",
273-
"@(abc\\.)?abstractmethod",
274-
]
275-
},
276-
),
277297
]
278298

279299
def get_associated_ruff_rules(self) -> list[str]:
@@ -385,6 +405,7 @@ def get_managed_files(self):
385405

386406

387407
ALL_TOOLS: list[Tool] = [
408+
CoverageTool(),
388409
DeptryTool(),
389410
PreCommitTool(),
390411
PyprojectFmtTool(),

0 commit comments

Comments
 (0)