Skip to content

Commit 8ada11c

Browse files
Support removing badges and update docs (#162)
1 parent ae51630 commit 8ada11c

File tree

4 files changed

+261
-11
lines changed

4 files changed

+261
-11
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ Supported arguments:
6868

6969
- `--remove` to remove the tool instead of adding it
7070
- `--offline` to disable network access and rely on caches
71+
- `--quiet` to suppress output
7172

7273
### `usethis badge`
7374

@@ -78,6 +79,12 @@ Currently supported badges:
7879
- Ruff
7980
- pre-commit
8081

82+
Supported arguments:
83+
84+
- `--remove` to remove the badge instead of adding it
85+
- `--offline` to disable network access and rely on caches
86+
- `--quiet` to suppress output
87+
8188
### `usethis ci`
8289

8390
Add Continuous Integration pipelines to the project.
@@ -90,6 +97,12 @@ Example:
9097

9198
`usethis ci bitbucket`.
9299

100+
Supported arguments:
101+
102+
- `--remove` to remove the CI configuration instead of adding it
103+
- `--offline` to disable network access and rely on caches
104+
- `--quiet` to suppress output
105+
93106
### `usethis browse pypi`
94107

95108
Dispaly or open the PyPI landing page associated with another project.

src/usethis/_core/badge.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ def add_pre_commit_badge():
4545
add_badge(PRE_COMMIT_BADGE)
4646

4747

48+
def remove_ruff_badge():
49+
remove_badge(RUFF_BADGE)
50+
51+
52+
def remove_pre_commit_badge():
53+
remove_badge(PRE_COMMIT_BADGE)
54+
55+
4856
def add_badge(badge: Badge) -> None:
4957
path = Path.cwd() / "README.md"
5058

@@ -95,16 +103,25 @@ def add_badge(badge: Badge) -> None:
95103
lines.append("")
96104
tick_print(f"Adding {badge.name} badge to 'README.md'.")
97105
lines.append(badge.markdown)
106+
have_added = True
98107

99108
# If the first line is blank, we basically just want to replace it.
100109
if is_blank(lines[0]):
101110
del lines[0]
102111

112+
output = "\n".join(lines)
113+
103114
# Ensure final newline
104-
if lines[-1] != "":
105-
lines.append("")
115+
if have_added:
116+
output = _ensure_final_newline(output)
117+
118+
path.write_text(output)
106119

107-
path.write_text("\n".join(lines))
120+
121+
def _ensure_final_newline(content: str) -> str:
122+
if not content or content[-1] != "\n":
123+
content += "\n"
124+
return content
108125

109126

110127
def is_blank(line: str) -> bool:
@@ -121,3 +138,49 @@ def is_badge(line: str) -> bool:
121138
re.match(r"^\[!\[.*\]\(.*\)\]\(.*\)$", line) is not None
122139
or re.match(r"^\!\[.*\]\(.*\)$", line) is not None
123140
)
141+
142+
143+
def remove_badge(badge: Badge) -> None:
144+
path = Path.cwd() / "README.md"
145+
146+
if not path.exists():
147+
return
148+
149+
content = path.read_text()
150+
151+
original_lines = content.splitlines()
152+
if content.endswith("\n"):
153+
original_lines.append("")
154+
155+
lines: list[str] = []
156+
have_removed = False
157+
skip_blank = False
158+
for idx, original_line in enumerate(original_lines):
159+
if not skip_blank:
160+
if Badge(markdown=original_line).equivalent_to(badge):
161+
tick_print(f"Removing {badge.name} badge from 'README.md'.")
162+
have_removed = True
163+
164+
# Merge consecutive blank lines around the badges,
165+
# if there is only one left
166+
if (
167+
idx - 1 >= 0
168+
and idx + 1 < len(original_lines)
169+
and is_blank(original_lines[idx - 1])
170+
and is_blank(original_lines[idx + 1])
171+
):
172+
skip_blank = True # i.e. next iteration once we hit a blank
173+
174+
continue
175+
176+
lines.append(original_line)
177+
else:
178+
skip_blank = False
179+
180+
output = "\n".join(lines)
181+
182+
# Ensure final newline
183+
if have_removed:
184+
output = _ensure_final_newline(output)
185+
186+
path.write_text(output)

src/usethis/_interface/badge.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,41 @@
11
import typer
22

33
from usethis._config import offline_opt, quiet_opt, usethis_config
4-
from usethis._core.badge import add_pre_commit_badge, add_ruff_badge
4+
from usethis._core.badge import (
5+
add_pre_commit_badge,
6+
add_ruff_badge,
7+
remove_pre_commit_badge,
8+
remove_ruff_badge,
9+
)
510

611
app = typer.Typer(help="Add badges to the top of the README.md file.")
712

13+
remove_opt = typer.Option(
14+
False, "--remove", help="Remove the badge instead of adding it."
15+
)
16+
817

918
@app.command(help="Add a badge for the Ruff linter.")
1019
def ruff(
11-
*,
20+
remove: bool = remove_opt,
1221
offline: bool = offline_opt,
1322
quiet: bool = quiet_opt,
1423
) -> None:
15-
with usethis_config.set(offline=offline, quiet=quiet):
16-
add_ruff_badge()
24+
if not remove:
25+
with usethis_config.set(offline=offline, quiet=quiet):
26+
add_ruff_badge()
27+
else:
28+
remove_ruff_badge()
1729

1830

1931
@app.command(help="Add a badge for the pre-commit framework.")
2032
def pre_commit(
21-
*,
33+
remove: bool = remove_opt,
2234
offline: bool = offline_opt,
2335
quiet: bool = quiet_opt,
2436
) -> None:
25-
with usethis_config.set(offline=offline, quiet=quiet):
26-
add_pre_commit_badge()
37+
if not remove:
38+
with usethis_config.set(offline=offline, quiet=quiet):
39+
add_pre_commit_badge()
40+
else:
41+
remove_pre_commit_badge()

tests/usethis/_core/test_badge.py

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from usethis._core.badge import Badge, add_badge
5+
from usethis._core.badge import Badge, add_badge, remove_badge
66
from usethis._test import change_cwd
77

88

@@ -340,3 +340,162 @@ def test_recognized_gets_put_before_unknown(self, tmp_path: Path):
340340
![Don't Know What This Is](<https://example.com>)
341341
"""
342342
)
343+
344+
def test_already_exists_no_newline_added(self):
345+
# Arrange
346+
path = Path("README.md")
347+
content = """![Ruff](<https://example.com>)"""
348+
path.write_text(content)
349+
350+
# Act
351+
with change_cwd(path.parent):
352+
add_badge(Badge(markdown="![Ruff](<https://example.com>)"))
353+
354+
# Assert
355+
assert path.read_text() == content
356+
357+
358+
class TestRemoveBadge:
359+
def test_empty(self, tmp_path: Path, capfd: pytest.CaptureFixture[str]):
360+
# Arrange
361+
path = tmp_path / "README.md"
362+
path.touch()
363+
364+
# Act
365+
with change_cwd(tmp_path):
366+
remove_badge(
367+
Badge(
368+
markdown="![Licence](<https://img.shields.io/badge/licence-mit-green>)",
369+
)
370+
)
371+
372+
# Assert
373+
content = path.read_text()
374+
assert not content
375+
out, err = capfd.readouterr()
376+
assert not err
377+
assert not out
378+
379+
def test_single(self, tmp_path: Path, capfd: pytest.CaptureFixture[str]):
380+
# Arrange
381+
path = tmp_path / "README.md"
382+
path.write_text("""\
383+
![Licence](<https://img.shields.io/badge/licence-mit-green>)
384+
""")
385+
386+
# Act
387+
with change_cwd(tmp_path):
388+
remove_badge(
389+
Badge(
390+
markdown="![Licence](<https://img.shields.io/badge/licence-mit-green>)",
391+
)
392+
)
393+
394+
# Assert
395+
content = path.read_text()
396+
assert content == "\n"
397+
out, err = capfd.readouterr()
398+
assert not err
399+
assert out == "✔ Removing Licence badge from 'README.md'.\n"
400+
401+
def test_no_reademe_file(self, tmp_path: Path):
402+
# Arrange
403+
path = tmp_path / "README.md"
404+
405+
# Act
406+
with change_cwd(tmp_path):
407+
remove_badge(
408+
Badge(
409+
markdown="![Licence](<https://img.shields.io/badge/licence-mit-green>)",
410+
)
411+
)
412+
413+
# Assert
414+
assert not path.exists()
415+
416+
def test_header_and_text(self, tmp_path: Path):
417+
# Arrange
418+
path = tmp_path / "README.md"
419+
path.write_text("""\
420+
# Header
421+
422+
![Licence](<https://img.shields.io/badge/licence-mit-green>)
423+
424+
And some text
425+
""")
426+
427+
# Act
428+
with change_cwd(tmp_path):
429+
remove_badge(
430+
Badge(
431+
markdown="![Licence](<https://img.shields.io/badge/licence-mit-green>)",
432+
)
433+
)
434+
435+
# Assert
436+
assert (
437+
path.read_text()
438+
== """\
439+
# Header
440+
441+
And some text
442+
"""
443+
)
444+
445+
def test_multiple_badges(self, tmp_path: Path):
446+
# Arrange
447+
path = tmp_path / "README.md"
448+
path.write_text("""\
449+
![Ruff](<https://example.com>)
450+
![pre-commit](<https://example.com>)
451+
""")
452+
453+
# Act
454+
with change_cwd(tmp_path):
455+
remove_badge(
456+
Badge(
457+
markdown="![Ruff](<https://example.com>)",
458+
)
459+
)
460+
461+
# Assert
462+
assert (
463+
path.read_text()
464+
== """\
465+
![pre-commit](<https://example.com>)
466+
"""
467+
)
468+
469+
def test_no_badges_but_header_and_text(self):
470+
# Arrange
471+
path = Path("README.md")
472+
content = """\
473+
# Header
474+
475+
And some text
476+
"""
477+
path.write_text(content)
478+
479+
# Act
480+
with change_cwd(path.parent):
481+
remove_badge(
482+
Badge(
483+
markdown="![Licence](<https://img.shields.io/badge/licence-mit-green>)",
484+
)
485+
)
486+
487+
# Assert
488+
assert path.read_text() == content
489+
490+
def test_already_exists_no_newline_added(self):
491+
# Arrange
492+
path = Path("README.md")
493+
content = """Nothing will be removed"""
494+
path.write_text(content)
495+
496+
# Act
497+
with change_cwd(path.parent):
498+
remove_badge(Badge(markdown="![Ruff](<https://example.com>)"))
499+
500+
# Assert
501+
assert path.read_text() == content

0 commit comments

Comments
 (0)