Skip to content

Commit 1be42b1

Browse files
Refactor add_badges to reduce complexity
Add tests for interfaces
1 parent f27b03a commit 1be42b1

10 files changed

Lines changed: 218 additions & 32 deletions

File tree

src/usethis/_core/badge.py

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

33
import re
4+
from dataclasses import dataclass
45
from pathlib import Path
56
from typing import TYPE_CHECKING
67

@@ -81,6 +82,33 @@ def get_badge_order() -> list[Badge]:
8182
]
8283

8384

85+
@dataclass
86+
class MarkdownH1Status:
87+
"""A way of keeping track of whether we're in a block of H1 tags.
88+
89+
We don't want to add badges inside a block of H1 tags.
90+
"""
91+
92+
h1_count: int = 0
93+
in_block: bool = False
94+
95+
def update_from_line(self, line: str) -> None:
96+
self.h1_count += self._count_h1_open_tags(line)
97+
self.in_block = self.h1_count > 0
98+
self.h1_count -= self._count_h1_close_tags(line)
99+
100+
@staticmethod
101+
def _count_h1_open_tags(line: str) -> int:
102+
h1_start_match = re.match(r"(<h1\s.*>)", line)
103+
if h1_start_match is not None:
104+
return len(h1_start_match.groups())
105+
return 0
106+
107+
@staticmethod
108+
def _count_h1_close_tags(line: str) -> int:
109+
return line.count("</h1>")
110+
111+
84112
def add_badge(badge: Badge) -> None:
85113
add_readme()
86114

@@ -91,27 +119,28 @@ def add_badge(badge: Badge) -> None:
91119
print(badge.markdown)
92120
return
93121

94-
prerequisites: list[Badge] = []
95-
for _b in get_badge_order():
96-
if badge.equivalent_to(_b):
97-
break
98-
prerequisites.append(_b)
99-
100-
content = path.read_text(encoding="utf-8")
122+
try:
123+
content = path.read_text(encoding="utf-8")
124+
except UnicodeDecodeError:
125+
warn_print(
126+
"README file uses an unsupported encoding, printing badge markdown instead..."
127+
)
128+
print(badge.markdown)
129+
return
101130

102131
original_lines = content.splitlines()
103132

133+
prerequisites = _get_prerequisites(badge)
134+
104135
have_added = False
105136
have_encountered_badge = False
106-
html_h1_count = 0
137+
h1_status = MarkdownH1Status()
107138
lines: list[str] = []
108139
for original_line in original_lines:
109140
if is_badge(original_line):
110141
have_encountered_badge = True
111142

112-
html_h1_count += _count_h1_open_tags(original_line)
113-
in_block = html_h1_count > 0
114-
html_h1_count -= _count_h1_close_tags(original_line)
143+
h1_status.update_from_line(original_line)
115144

116145
original_badge = Badge(markdown=original_line)
117146

@@ -126,7 +155,7 @@ def add_badge(badge: Badge) -> None:
126155
not original_line_is_prerequisite
127156
and (not is_blank(original_line) or have_encountered_badge)
128157
and not is_header(original_line)
129-
and not in_block
158+
and not h1_status.in_block
130159
):
131160
lines.append(badge.markdown)
132161
have_added = True
@@ -160,6 +189,20 @@ def add_badge(badge: Badge) -> None:
160189
path.write_text(output, encoding="utf-8")
161190

162191

192+
def _get_prerequisites(badge: Badge) -> list[Badge]:
193+
"""Get the prerequisites for a badge.
194+
195+
We want to place the badges in a specific order, so we need to check if we've got
196+
past those prerequisites.
197+
"""
198+
prerequisites: list[Badge] = []
199+
for _b in get_badge_order():
200+
if badge.equivalent_to(_b):
201+
break
202+
prerequisites.append(_b)
203+
return prerequisites
204+
205+
163206
def _get_markdown_readme_path() -> Path:
164207
path = get_readme_path()
165208

@@ -196,17 +239,6 @@ def is_badge(line: str) -> bool:
196239
)
197240

198241

199-
def _count_h1_open_tags(line: str) -> int:
200-
h1_start_match = re.match(r"(<h1\s.*>)", line)
201-
if h1_start_match is not None:
202-
return len(h1_start_match.groups())
203-
return 0
204-
205-
206-
def _count_h1_close_tags(line: str) -> int:
207-
return line.count("</h1>")
208-
209-
210242
def remove_badge(badge: Badge) -> None:
211243
path = Path.cwd() / "README.md"
212244

src/usethis/_core/show.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
from __future__ import annotations
22

3-
import typer
4-
53
from usethis._config import usethis_config
6-
from usethis._console import err_print
74
from usethis._integrations.file.pyproject_toml.name import get_name
85
from usethis._integrations.sonarqube.config import get_sonar_project_properties
96
from usethis._integrations.uv.init import ensure_pyproject_toml
10-
from usethis.errors import UsethisError
117

128

139
def show_name() -> None:
@@ -19,8 +15,4 @@ def show_name() -> None:
1915
def show_sonarqube_config() -> None:
2016
with usethis_config.set(quiet=True):
2117
ensure_pyproject_toml()
22-
try:
23-
print(get_sonar_project_properties())
24-
except UsethisError as err:
25-
err_print(err)
26-
raise typer.Exit(code=1) from None
18+
get_sonar_project_properties()

tests/usethis/_core/test_core_badge.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,31 @@ def test_add_to_no_file_extension_readme(self, bare_dir: Path):
539539
"""
540540
)
541541

542+
def test_wrong_readme_encoding(
543+
self, bare_dir: Path, capfd: pytest.CaptureFixture[str]
544+
):
545+
# Arrange
546+
path = bare_dir / "README.md"
547+
path.write_text(
548+
"""\
549+
# usethis
550+
""",
551+
encoding="utf-16",
552+
)
553+
554+
# Act
555+
with change_cwd(bare_dir), PyprojectTOMLManager():
556+
add_badge(
557+
Badge(
558+
markdown="[![Ruff](https://example.com>)](<https://example.com)",
559+
)
560+
)
561+
562+
# Assert
563+
out, err = capfd.readouterr()
564+
assert not err
565+
assert "encoding" in out
566+
542567

543568
class TestRemoveBadge:
544569
def test_empty(self, bare_dir: Path, capfd: pytest.CaptureFixture[str]):
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from pathlib import Path
2+
3+
from typer.testing import CliRunner
4+
5+
from usethis._interface.browse import app
6+
from usethis._test import change_cwd
7+
8+
9+
class TestBrowse:
10+
def test_success(self, tmp_path: Path):
11+
# Act
12+
runner = CliRunner()
13+
with change_cwd(tmp_path):
14+
result = runner.invoke(app, ["usethis"])
15+
16+
# Assert
17+
assert result.exit_code == 0, result.output
18+
19+
def test_missing_package_name(self, tmp_path: Path):
20+
# Arrange
21+
(tmp_path / "pyproject.toml").write_text("")
22+
23+
# Act
24+
runner = CliRunner()
25+
with change_cwd(tmp_path):
26+
result = runner.invoke(app, [])
27+
28+
# Assert
29+
assert result.exit_code == 2, result.output

tests/usethis/_interface/test_docstyle.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import pytest
44
import typer
5+
from typer.testing import CliRunner
56

7+
from usethis._app import app
68
from usethis._interface.docstyle import docstyle
79
from usethis._test import change_cwd
810

@@ -18,3 +20,19 @@ def test_invalid_style(self, capfd: pytest.CaptureFixture[str]):
1820
def test_google_runs(self, tmp_path: Path):
1921
with change_cwd(tmp_path):
2022
docstyle("google")
23+
24+
def test_invalid_pyproject_toml(
25+
self, tmp_path: Path, capfd: pytest.CaptureFixture[str]
26+
):
27+
# Arrange
28+
invalid_pyproject_toml = tmp_path / "pyproject.toml"
29+
invalid_pyproject_toml.write_text("[")
30+
31+
# Act
32+
with change_cwd(tmp_path):
33+
runner = CliRunner()
34+
with change_cwd(tmp_path):
35+
result = runner.invoke(app, ["docstyle", "google"])
36+
37+
# Assert
38+
assert result.exit_code == 1, result.output

tests/usethis/_interface/test_interface_badge.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ def test_remove(self, tmp_path: Path):
5151
# Assert
5252
assert result.exit_code == 0, result.output
5353

54+
def test_wrong_encoding(self, tmp_path: Path):
55+
# Arrange
56+
(tmp_path / "README.md").write_text("utf-8", encoding="utf-16")
57+
58+
# Act
59+
runner = CliRunner()
60+
with change_cwd(tmp_path):
61+
result = runner.invoke(app, ["ruff"])
62+
63+
# Assert
64+
assert result.exit_code == 0, result.output
65+
5466

5567
class TestPreCommit:
5668
def test_add(self, tmp_path: Path):

tests/usethis/_interface/test_interface_ci.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,15 @@ def test_maximal_config(self, tmp_path: Path):
6565
Path(__file__).parent / "maximal_bitbucket_pipelines.yml"
6666
).read_text()
6767
assert (tmp_path / "bitbucket-pipelines.yml").read_text() == expected_yml
68+
69+
def test_invalid_pyproject_toml(self, tmp_path: Path):
70+
# Arrange
71+
(tmp_path / "pyproject.toml").write_text("(")
72+
73+
# Act
74+
runner = CliRunner()
75+
with change_cwd(tmp_path):
76+
result = runner.invoke(app)
77+
78+
# Assert
79+
assert result.exit_code == 1, result.output

tests/usethis/_interface/test_interface_readme.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,15 @@ def test_badges(self, tmp_path: Path):
3737
assert "ruff" in (tmp_path / "README.md").read_text()
3838
assert "pre-commit" in (tmp_path / "README.md").read_text()
3939
assert "uv" in (tmp_path / "README.md").read_text()
40+
41+
def test_invalid_pyproject_toml(self, tmp_path: Path):
42+
# Arrange
43+
(tmp_path / "pyproject.toml").write_text("[")
44+
45+
# Act
46+
runner = CliRunner()
47+
with change_cwd(tmp_path):
48+
result = runner.invoke(app, ["readme", "--badges"])
49+
50+
# Assert
51+
assert result.exit_code == 1, result.output
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from pathlib import Path
2+
3+
from typer.testing import CliRunner
4+
5+
from usethis._app import app
6+
from usethis._test import change_cwd
7+
8+
9+
class TestList:
10+
def test_success(self, tmp_path: Path):
11+
# Act
12+
runner = CliRunner()
13+
with change_cwd(tmp_path):
14+
result = runner.invoke(app, ["list"])
15+
16+
# Assert
17+
assert result.exit_code == 0, result.output
18+
19+
def test_error(self, tmp_path: Path):
20+
# Arrange
21+
# Syntax error in pyproject.toml to trigger an error
22+
(tmp_path / "pyproject.toml").write_text("[")
23+
24+
# Act
25+
runner = CliRunner()
26+
with change_cwd(tmp_path):
27+
result = runner.invoke(app, ["list"])
28+
29+
# Assert
30+
assert result.exit_code == 1, result.output

tests/usethis/_interface/test_show.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ def test_output(self, tmp_path: Path):
2121
assert result.exit_code == 0, result.output
2222
assert result.output == """fun\n"""
2323

24+
def test_invalid_pyproject(self, tmp_path: Path):
25+
# Arrange
26+
(tmp_path / "pyproject.toml").write_text("[")
27+
28+
# Act
29+
runner = CliRunner()
30+
with change_cwd(tmp_path):
31+
result = runner.invoke(app, ["name"])
32+
33+
# Assert
34+
assert result.exit_code == 1, result.output
35+
2436

2537
class TestSonarqube:
2638
def test_runs(self, tmp_path: Path):
@@ -53,3 +65,15 @@ def test_missing_key(self, tmp_path: Path):
5365

5466
# Assert
5567
assert result.exit_code == 1, result.output
68+
69+
def test_invalid_pyproject(self, tmp_path: Path):
70+
# Arrange
71+
(tmp_path / "pyproject.toml").write_text("[")
72+
73+
# Act
74+
runner = CliRunner()
75+
with change_cwd(tmp_path):
76+
result = runner.invoke(app, ["sonarqube"])
77+
78+
# Assert
79+
assert result.exit_code == 1, result.output

0 commit comments

Comments
 (0)