diff --git a/src/usethis/__main__.py b/src/usethis/__main__.py
index faf4296..9e2aeb5 100644
--- a/src/usethis/__main__.py
+++ b/src/usethis/__main__.py
@@ -5,6 +5,10 @@
import usethis._interface.ci
import usethis._interface.show
import usethis._interface.tool
+from usethis._config import quiet_opt, usethis_config
+from usethis._core.badge import add_pre_commit_badge, add_ruff_badge
+from usethis._core.readme import add_readme
+from usethis._tool import PreCommitTool, RuffTool
app = typer.Typer(
help=(
@@ -17,6 +21,25 @@
app.add_typer(usethis._interface.ci.app, name="ci")
app.add_typer(usethis._interface.show.app, name="show")
app.add_typer(usethis._interface.tool.app, name="tool")
+
+
+@app.command(help="Add a README.md file to the project.")
+def readme(
+ quiet: bool = quiet_opt,
+ badges: bool = typer.Option(False, "--badges", help="Add relevant badges"),
+) -> None:
+ with usethis_config.set(quiet=quiet):
+ add_readme()
+
+ if badges:
+ if RuffTool().is_used():
+ add_ruff_badge()
+
+ if PreCommitTool().is_used():
+ add_pre_commit_badge()
+
+
app(prog_name="usethis")
+
__all__ = ["app"]
diff --git a/src/usethis/_core/badge.py b/src/usethis/_core/badge.py
index 7f2c9b0..372abc4 100644
--- a/src/usethis/_core/badge.py
+++ b/src/usethis/_core/badge.py
@@ -5,6 +5,7 @@
from pydantic import BaseModel
from usethis._console import tick_print
+from usethis._core.readme import add_readme
class Badge(BaseModel):
@@ -57,7 +58,7 @@ def add_badge(badge: Badge) -> None:
path = Path.cwd() / "README.md"
if not path.exists():
- raise NotImplementedError
+ add_readme()
prerequisites: list[Badge] = []
for _b in BADGE_ORDER:
@@ -70,8 +71,17 @@ def add_badge(badge: Badge) -> None:
original_lines = content.splitlines()
have_added = False
+ have_encountered_badge = False
+ html_h1_count = 0
lines: list[str] = []
for original_line in original_lines:
+ if is_badge(original_line):
+ have_encountered_badge = True
+
+ html_h1_count += _count_h1_open_tags(original_line)
+ in_block = html_h1_count > 0
+ html_h1_count -= _count_h1_close_tags(original_line)
+
original_badge = Badge(markdown=original_line)
if original_badge.equivalent_to(badge):
@@ -83,10 +93,10 @@ def add_badge(badge: Badge) -> None:
)
if not have_added and (
not original_line_is_prerequisite
- and not is_blank(original_line)
+ and (not is_blank(original_line) or have_encountered_badge)
and not is_header(original_line)
+ and not in_block
):
- tick_print(f"Adding {badge.name} badge to 'README.md'.")
lines.append(badge.markdown)
have_added = True
@@ -101,10 +111,11 @@ def add_badge(badge: Badge) -> None:
# Add a blank line between headers and the badge
if original_lines and is_header(original_lines[-1]):
lines.append("")
- tick_print(f"Adding {badge.name} badge to 'README.md'.")
lines.append(badge.markdown)
have_added = True
+ tick_print(f"Adding {badge.name} badge to 'README.md'.")
+
# If the first line is blank, we basically just want to replace it.
if is_blank(lines[0]):
del lines[0]
@@ -140,6 +151,17 @@ def is_badge(line: str) -> bool:
)
+def _count_h1_open_tags(line: str) -> int:
+ h1_start_match = re.match(r"(
)", line)
+ if h1_start_match is not None:
+ return len(h1_start_match.groups())
+ return 0
+
+
+def _count_h1_close_tags(line: str) -> int:
+ return line.count("
")
+
+
def remove_badge(badge: Badge) -> None:
path = Path.cwd() / "README.md"
diff --git a/src/usethis/_core/readme.py b/src/usethis/_core/readme.py
new file mode 100644
index 0000000..6e39ec0
--- /dev/null
+++ b/src/usethis/_core/readme.py
@@ -0,0 +1,45 @@
+from pathlib import Path
+
+from usethis._console import box_print, tick_print
+from usethis._integrations.pyproject.errors import PyProjectTOMLError
+from usethis._integrations.pyproject.name import get_description, get_name
+
+
+def add_readme() -> None:
+ """Add a README.md file to the project."""
+
+ path = Path.cwd() / "README.md"
+
+ if path.exists():
+ return
+
+ try:
+ project_name = get_name()
+ except PyProjectTOMLError:
+ project_name = None
+
+ try:
+ project_description = get_description()
+ except PyProjectTOMLError:
+ project_description = None
+
+ if project_name is not None and project_description is not None:
+ content = f"""\
+# {project_name}
+
+{project_description}
+"""
+ elif project_name is not None:
+ content = f"""\
+# {project_name}
+"""
+ elif project_description is not None:
+ content = f"""\
+{project_description}
+"""
+ else:
+ content = ""
+
+ tick_print("Writing 'README.md'.")
+ path.write_text(content)
+ box_print("Populate 'README.md' to help users understand the project.")
diff --git a/src/usethis/_integrations/pyproject/errors.py b/src/usethis/_integrations/pyproject/errors.py
index 8767dbc..28f623d 100644
--- a/src/usethis/_integrations/pyproject/errors.py
+++ b/src/usethis/_integrations/pyproject/errors.py
@@ -17,6 +17,10 @@ class PyProjectTOMLProjectNameError(PyProjectTOMLError):
"""Raised when the 'project.name' key is missing or invalid in 'pyproject.toml'."""
+class PyProjectTOMLProjectDescriptionError(PyProjectTOMLError):
+ """Raised when the 'project.description' key is missing or invalid in 'pyproject.toml'."""
+
+
class PyProjectTOMLProjectSectionError(PyProjectTOMLError):
"""Raised when the 'project' section is missing or invalid in 'pyproject.toml'."""
diff --git a/src/usethis/_integrations/pyproject/name.py b/src/usethis/_integrations/pyproject/name.py
index c38b119..af1a3e1 100644
--- a/src/usethis/_integrations/pyproject/name.py
+++ b/src/usethis/_integrations/pyproject/name.py
@@ -1,6 +1,9 @@
from pydantic import TypeAdapter, ValidationError
-from usethis._integrations.pyproject.errors import PyProjectTOMLProjectNameError
+from usethis._integrations.pyproject.errors import (
+ PyProjectTOMLProjectDescriptionError,
+ PyProjectTOMLProjectNameError,
+)
from usethis._integrations.pyproject.project import get_project_dict
@@ -19,3 +22,18 @@ def get_name() -> str:
raise PyProjectTOMLProjectNameError(msg)
return name
+
+
+def get_description() -> str:
+ project_dict = get_project_dict()
+
+ try:
+ description = TypeAdapter(str).validate_python(project_dict["description"])
+ except KeyError:
+ msg = "The 'project.description' value is missing from 'pyproject.toml'."
+ raise PyProjectTOMLProjectDescriptionError(msg)
+ except ValidationError as err:
+ msg = f"The 'project.description' value in 'pyproject.toml' is not a valid string: {err}"
+ raise PyProjectTOMLProjectDescriptionError(msg)
+
+ return description
diff --git a/tests/usethis/_core/test_badge.py b/tests/usethis/_core/test_badge.py
index 65fd8cb..51feb23 100644
--- a/tests/usethis/_core/test_badge.py
+++ b/tests/usethis/_core/test_badge.py
@@ -341,19 +341,114 @@ def test_recognized_gets_put_before_unknown(self, tmp_path: Path):
"""
)
- def test_already_exists_no_newline_added(self):
+ def test_already_exists_no_newline_added(self, tmp_path: Path):
# Arrange
- path = Path("README.md")
+ path = tmp_path / Path("README.md")
content = """![Ruff]()"""
path.write_text(content)
# Act
- with change_cwd(path.parent):
+ with change_cwd(tmp_path):
add_badge(Badge(markdown="![Ruff]()"))
# Assert
assert path.read_text() == content
+ def test_no_unnecessary_spaces(self, tmp_path: Path):
+ # Arrange
+ path = tmp_path / "README.md"
+ path.write_text("""\
+# usethis
+
+[![Ruff]()]()
+
+Automate Python project setup and development tasks that are otherwise performed manually.
+""")
+
+ # Act
+ with change_cwd(tmp_path):
+ add_badge(
+ Badge(
+ markdown="[![pre-commit]()]()",
+ )
+ )
+
+ # Assert
+ content = path.read_text()
+ assert (
+ content
+ == """\
+# usethis
+
+[![Ruff]()]()
+[![pre-commit]()]()
+
+Automate Python project setup and development tasks that are otherwise performed manually.
+"""
+ )
+
+ def test_already_exists_out_of_order(
+ self, tmp_path: Path, capfd: pytest.CaptureFixture[str]
+ ):
+ # Arrange
+ path = tmp_path / "README.md"
+ content = """\
+[![pre-commit]()]()
+[![Ruff]()]()
+"""
+ path.write_text(content)
+
+ # Act
+ with change_cwd(tmp_path):
+ add_badge(
+ Badge(
+ markdown="[![Ruff]()]()",
+ )
+ )
+
+ # Assert
+ assert path.read_text() == content
+ out, err = capfd.readouterr()
+ assert not err
+ assert not out
+
+ def test_skip_html_block(self, tmp_path: Path):
+ # Arrange
+ path = tmp_path / "README.md"
+ path.write_text("""\
+
+
+
+
+# usethis
+
+[![Ruff]()]()
+""")
+
+ # Act
+ with change_cwd(tmp_path):
+ add_badge(
+ Badge(
+ markdown="[![pre-commit]()]()"
+ )
+ )
+
+ # Assert
+ content = path.read_text()
+ assert (
+ content
+ == """\
+
+
+
+
+# usethis
+
+[![Ruff]()]()
+[![pre-commit]()]()
+"""
+ )
+
class TestRemoveBadge:
def test_empty(self, tmp_path: Path, capfd: pytest.CaptureFixture[str]):
@@ -466,9 +561,9 @@ def test_multiple_badges(self, tmp_path: Path):
"""
)
- def test_no_badges_but_header_and_text(self):
+ def test_no_badges_but_header_and_text(self, tmp_path: Path):
# Arrange
- path = Path("README.md")
+ path = tmp_path / Path("README.md")
content = """\
# Header
@@ -477,7 +572,7 @@ def test_no_badges_but_header_and_text(self):
path.write_text(content)
# Act
- with change_cwd(path.parent):
+ with change_cwd(tmp_path):
remove_badge(
Badge(
markdown="![Licence]()",
@@ -487,14 +582,14 @@ def test_no_badges_but_header_and_text(self):
# Assert
assert path.read_text() == content
- def test_already_exists_no_newline_added(self):
+ def test_already_exists_no_newline_added(self, tmp_path: Path):
# Arrange
- path = Path("README.md")
+ path = tmp_path / Path("README.md")
content = """Nothing will be removed"""
path.write_text(content)
# Act
- with change_cwd(path.parent):
+ with change_cwd(tmp_path):
remove_badge(Badge(markdown="![Ruff]()"))
# Assert