Skip to content

Commit 32e8a90

Browse files
161 implement usethis readme (#163)
* Add basic logic to create a README file automatically * use add_readme for badges when README doesn't exist Improve usethis readme interface with a --badges flag * Better handling of HTML blocks when adding badges
1 parent 8ada11c commit 32e8a90

File tree

6 files changed

+221
-14
lines changed

6 files changed

+221
-14
lines changed

src/usethis/__main__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
import usethis._interface.ci
66
import usethis._interface.show
77
import usethis._interface.tool
8+
from usethis._config import quiet_opt, usethis_config
9+
from usethis._core.badge import add_pre_commit_badge, add_ruff_badge
10+
from usethis._core.readme import add_readme
11+
from usethis._tool import PreCommitTool, RuffTool
812

913
app = typer.Typer(
1014
help=(
@@ -17,6 +21,25 @@
1721
app.add_typer(usethis._interface.ci.app, name="ci")
1822
app.add_typer(usethis._interface.show.app, name="show")
1923
app.add_typer(usethis._interface.tool.app, name="tool")
24+
25+
26+
@app.command(help="Add a README.md file to the project.")
27+
def readme(
28+
quiet: bool = quiet_opt,
29+
badges: bool = typer.Option(False, "--badges", help="Add relevant badges"),
30+
) -> None:
31+
with usethis_config.set(quiet=quiet):
32+
add_readme()
33+
34+
if badges:
35+
if RuffTool().is_used():
36+
add_ruff_badge()
37+
38+
if PreCommitTool().is_used():
39+
add_pre_commit_badge()
40+
41+
2042
app(prog_name="usethis")
2143

44+
2245
__all__ = ["app"]

src/usethis/_core/badge.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pydantic import BaseModel
66

77
from usethis._console import tick_print
8+
from usethis._core.readme import add_readme
89

910

1011
class Badge(BaseModel):
@@ -57,7 +58,7 @@ def add_badge(badge: Badge) -> None:
5758
path = Path.cwd() / "README.md"
5859

5960
if not path.exists():
60-
raise NotImplementedError
61+
add_readme()
6162

6263
prerequisites: list[Badge] = []
6364
for _b in BADGE_ORDER:
@@ -70,8 +71,17 @@ def add_badge(badge: Badge) -> None:
7071
original_lines = content.splitlines()
7172

7273
have_added = False
74+
have_encountered_badge = False
75+
html_h1_count = 0
7376
lines: list[str] = []
7477
for original_line in original_lines:
78+
if is_badge(original_line):
79+
have_encountered_badge = True
80+
81+
html_h1_count += _count_h1_open_tags(original_line)
82+
in_block = html_h1_count > 0
83+
html_h1_count -= _count_h1_close_tags(original_line)
84+
7585
original_badge = Badge(markdown=original_line)
7686

7787
if original_badge.equivalent_to(badge):
@@ -83,10 +93,10 @@ def add_badge(badge: Badge) -> None:
8393
)
8494
if not have_added and (
8595
not original_line_is_prerequisite
86-
and not is_blank(original_line)
96+
and (not is_blank(original_line) or have_encountered_badge)
8797
and not is_header(original_line)
98+
and not in_block
8899
):
89-
tick_print(f"Adding {badge.name} badge to 'README.md'.")
90100
lines.append(badge.markdown)
91101
have_added = True
92102

@@ -101,10 +111,11 @@ def add_badge(badge: Badge) -> None:
101111
# Add a blank line between headers and the badge
102112
if original_lines and is_header(original_lines[-1]):
103113
lines.append("")
104-
tick_print(f"Adding {badge.name} badge to 'README.md'.")
105114
lines.append(badge.markdown)
106115
have_added = True
107116

117+
tick_print(f"Adding {badge.name} badge to 'README.md'.")
118+
108119
# If the first line is blank, we basically just want to replace it.
109120
if is_blank(lines[0]):
110121
del lines[0]
@@ -140,6 +151,17 @@ def is_badge(line: str) -> bool:
140151
)
141152

142153

154+
def _count_h1_open_tags(line: str) -> int:
155+
h1_start_match = re.match(r"(<h1\s.*>)", line)
156+
if h1_start_match is not None:
157+
return len(h1_start_match.groups())
158+
return 0
159+
160+
161+
def _count_h1_close_tags(line: str) -> int:
162+
return line.count("</h1>")
163+
164+
143165
def remove_badge(badge: Badge) -> None:
144166
path = Path.cwd() / "README.md"
145167

src/usethis/_core/readme.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from pathlib import Path
2+
3+
from usethis._console import box_print, tick_print
4+
from usethis._integrations.pyproject.errors import PyProjectTOMLError
5+
from usethis._integrations.pyproject.name import get_description, get_name
6+
7+
8+
def add_readme() -> None:
9+
"""Add a README.md file to the project."""
10+
11+
path = Path.cwd() / "README.md"
12+
13+
if path.exists():
14+
return
15+
16+
try:
17+
project_name = get_name()
18+
except PyProjectTOMLError:
19+
project_name = None
20+
21+
try:
22+
project_description = get_description()
23+
except PyProjectTOMLError:
24+
project_description = None
25+
26+
if project_name is not None and project_description is not None:
27+
content = f"""\
28+
# {project_name}
29+
30+
{project_description}
31+
"""
32+
elif project_name is not None:
33+
content = f"""\
34+
# {project_name}
35+
"""
36+
elif project_description is not None:
37+
content = f"""\
38+
{project_description}
39+
"""
40+
else:
41+
content = ""
42+
43+
tick_print("Writing 'README.md'.")
44+
path.write_text(content)
45+
box_print("Populate 'README.md' to help users understand the project.")

src/usethis/_integrations/pyproject/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ class PyProjectTOMLProjectNameError(PyProjectTOMLError):
1717
"""Raised when the 'project.name' key is missing or invalid in 'pyproject.toml'."""
1818

1919

20+
class PyProjectTOMLProjectDescriptionError(PyProjectTOMLError):
21+
"""Raised when the 'project.description' key is missing or invalid in 'pyproject.toml'."""
22+
23+
2024
class PyProjectTOMLProjectSectionError(PyProjectTOMLError):
2125
"""Raised when the 'project' section is missing or invalid in 'pyproject.toml'."""
2226

src/usethis/_integrations/pyproject/name.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from pydantic import TypeAdapter, ValidationError
22

3-
from usethis._integrations.pyproject.errors import PyProjectTOMLProjectNameError
3+
from usethis._integrations.pyproject.errors import (
4+
PyProjectTOMLProjectDescriptionError,
5+
PyProjectTOMLProjectNameError,
6+
)
47
from usethis._integrations.pyproject.project import get_project_dict
58

69

@@ -19,3 +22,18 @@ def get_name() -> str:
1922
raise PyProjectTOMLProjectNameError(msg)
2023

2124
return name
25+
26+
27+
def get_description() -> str:
28+
project_dict = get_project_dict()
29+
30+
try:
31+
description = TypeAdapter(str).validate_python(project_dict["description"])
32+
except KeyError:
33+
msg = "The 'project.description' value is missing from 'pyproject.toml'."
34+
raise PyProjectTOMLProjectDescriptionError(msg)
35+
except ValidationError as err:
36+
msg = f"The 'project.description' value in 'pyproject.toml' is not a valid string: {err}"
37+
raise PyProjectTOMLProjectDescriptionError(msg)
38+
39+
return description

tests/usethis/_core/test_badge.py

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -341,19 +341,114 @@ def test_recognized_gets_put_before_unknown(self, tmp_path: Path):
341341
"""
342342
)
343343

344-
def test_already_exists_no_newline_added(self):
344+
def test_already_exists_no_newline_added(self, tmp_path: Path):
345345
# Arrange
346-
path = Path("README.md")
346+
path = tmp_path / Path("README.md")
347347
content = """![Ruff](<https://example.com>)"""
348348
path.write_text(content)
349349

350350
# Act
351-
with change_cwd(path.parent):
351+
with change_cwd(tmp_path):
352352
add_badge(Badge(markdown="![Ruff](<https://example.com>)"))
353353

354354
# Assert
355355
assert path.read_text() == content
356356

357+
def test_no_unnecessary_spaces(self, tmp_path: Path):
358+
# Arrange
359+
path = tmp_path / "README.md"
360+
path.write_text("""\
361+
# usethis
362+
363+
[![Ruff](<https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json>)](<https://github.com/astral-sh/ruff>)
364+
365+
Automate Python project setup and development tasks that are otherwise performed manually.
366+
""")
367+
368+
# Act
369+
with change_cwd(tmp_path):
370+
add_badge(
371+
Badge(
372+
markdown="[![pre-commit](<https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit>)](<https://github.com/pre-commit/pre-commit>)",
373+
)
374+
)
375+
376+
# Assert
377+
content = path.read_text()
378+
assert (
379+
content
380+
== """\
381+
# usethis
382+
383+
[![Ruff](<https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json>)](<https://github.com/astral-sh/ruff>)
384+
[![pre-commit](<https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit>)](<https://github.com/pre-commit/pre-commit>)
385+
386+
Automate Python project setup and development tasks that are otherwise performed manually.
387+
"""
388+
)
389+
390+
def test_already_exists_out_of_order(
391+
self, tmp_path: Path, capfd: pytest.CaptureFixture[str]
392+
):
393+
# Arrange
394+
path = tmp_path / "README.md"
395+
content = """\
396+
[![pre-commit](<https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit>)](<https://github.com/pre-commit/pre-commit>)
397+
[![Ruff](<https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json>)](<https://github.com/astral-sh/ruff>)
398+
"""
399+
path.write_text(content)
400+
401+
# Act
402+
with change_cwd(tmp_path):
403+
add_badge(
404+
Badge(
405+
markdown="[![Ruff](<https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json>)](<https://github.com/astral-sh/ruff>)",
406+
)
407+
)
408+
409+
# Assert
410+
assert path.read_text() == content
411+
out, err = capfd.readouterr()
412+
assert not err
413+
assert not out
414+
415+
def test_skip_html_block(self, tmp_path: Path):
416+
# Arrange
417+
path = tmp_path / "README.md"
418+
path.write_text("""\
419+
<h1 align="center">
420+
<img src="doc/logo.svg"><br>
421+
</h1>
422+
423+
# usethis
424+
425+
[![Ruff](<https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json>)](<https://github.com/astral-sh/ruff>)
426+
""")
427+
428+
# Act
429+
with change_cwd(tmp_path):
430+
add_badge(
431+
Badge(
432+
markdown="[![pre-commit](<https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit>)](<https://github.com/pre-commit/pre-commit>)"
433+
)
434+
)
435+
436+
# Assert
437+
content = path.read_text()
438+
assert (
439+
content
440+
== """\
441+
<h1 align="center">
442+
<img src="doc/logo.svg"><br>
443+
</h1>
444+
445+
# usethis
446+
447+
[![Ruff](<https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json>)](<https://github.com/astral-sh/ruff>)
448+
[![pre-commit](<https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit>)](<https://github.com/pre-commit/pre-commit>)
449+
"""
450+
)
451+
357452

358453
class TestRemoveBadge:
359454
def test_empty(self, tmp_path: Path, capfd: pytest.CaptureFixture[str]):
@@ -466,9 +561,9 @@ def test_multiple_badges(self, tmp_path: Path):
466561
"""
467562
)
468563

469-
def test_no_badges_but_header_and_text(self):
564+
def test_no_badges_but_header_and_text(self, tmp_path: Path):
470565
# Arrange
471-
path = Path("README.md")
566+
path = tmp_path / Path("README.md")
472567
content = """\
473568
# Header
474569
@@ -477,7 +572,7 @@ def test_no_badges_but_header_and_text(self):
477572
path.write_text(content)
478573

479574
# Act
480-
with change_cwd(path.parent):
575+
with change_cwd(tmp_path):
481576
remove_badge(
482577
Badge(
483578
markdown="![Licence](<https://img.shields.io/badge/licence-mit-green>)",
@@ -487,14 +582,14 @@ def test_no_badges_but_header_and_text(self):
487582
# Assert
488583
assert path.read_text() == content
489584

490-
def test_already_exists_no_newline_added(self):
585+
def test_already_exists_no_newline_added(self, tmp_path: Path):
491586
# Arrange
492-
path = Path("README.md")
587+
path = tmp_path / Path("README.md")
493588
content = """Nothing will be removed"""
494589
path.write_text(content)
495590

496591
# Act
497-
with change_cwd(path.parent):
592+
with change_cwd(tmp_path):
498593
remove_badge(Badge(markdown="![Ruff](<https://example.com>)"))
499594

500595
# Assert

0 commit comments

Comments
 (0)