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