Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

161 implement usethis readme #163

Merged
merged 4 commits into from
Jan 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/usethis/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand All @@ -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"]
30 changes: 26 additions & 4 deletions src/usethis/_core/badge.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pydantic import BaseModel

from usethis._console import tick_print
from usethis._core.readme import add_readme


class Badge(BaseModel):
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand All @@ -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

Expand All @@ -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]
Expand Down Expand Up @@ -140,6 +151,17 @@ def is_badge(line: str) -> bool:
)


def _count_h1_open_tags(line: str) -> int:
h1_start_match = re.match(r"(<h1\s.*>)", 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("</h1>")


def remove_badge(badge: Badge) -> None:
path = Path.cwd() / "README.md"

Expand Down
45 changes: 45 additions & 0 deletions src/usethis/_core/readme.py
Original file line number Diff line number Diff line change
@@ -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.")
4 changes: 4 additions & 0 deletions src/usethis/_integrations/pyproject/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'."""

Expand Down
20 changes: 19 additions & 1 deletion src/usethis/_integrations/pyproject/name.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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
113 changes: 104 additions & 9 deletions tests/usethis/_core/test_badge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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](<https://example.com>)"""
path.write_text(content)

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

# 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](<https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json>)](<https://github.com/astral-sh/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](<https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit>)](<https://github.com/pre-commit/pre-commit>)",
)
)

# Assert
content = path.read_text()
assert (
content
== """\
# usethis

[![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>)
[![pre-commit](<https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit>)](<https://github.com/pre-commit/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](<https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit>)](<https://github.com/pre-commit/pre-commit>)
[![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>)
"""
path.write_text(content)

# Act
with change_cwd(tmp_path):
add_badge(
Badge(
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>)",
)
)

# 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("""\
<h1 align="center">
<img src="doc/logo.svg"><br>
</h1>

# usethis

[![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>)
""")

# Act
with change_cwd(tmp_path):
add_badge(
Badge(
markdown="[![pre-commit](<https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit>)](<https://github.com/pre-commit/pre-commit>)"
)
)

# Assert
content = path.read_text()
assert (
content
== """\
<h1 align="center">
<img src="doc/logo.svg"><br>
</h1>

# usethis

[![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>)
[![pre-commit](<https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit>)](<https://github.com/pre-commit/pre-commit>)
"""
)


class TestRemoveBadge:
def test_empty(self, tmp_path: Path, capfd: pytest.CaptureFixture[str]):
Expand Down Expand Up @@ -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

Expand All @@ -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](<https://img.shields.io/badge/licence-mit-green>)",
Expand All @@ -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](<https://example.com>)"))

# Assert
Expand Down
Loading