Skip to content

Commit

Permalink
56 implement usethis badge ruff (#158)
Browse files Browse the repository at this point in the history
* Implement add_badge

* Add some badges to README

* Add GitHub Actions Status badge to README

* Add logo

* Move to class-based approach for badges for more robust comparisons

* Remove dead code, improve test fixture style

* Tweak hook removal message
  • Loading branch information
nathanjmcdougall authored Jan 11, 2025
1 parent 7f3bfcf commit d2b55a0
Show file tree
Hide file tree
Showing 20 changed files with 801 additions and 146 deletions.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# usethis

[![PyPI Version](<https://img.shields.io/pypi/v/usethis.svg>)](<https://pypi.python.org/pypi/usethis>)
![PyPI License](<https://img.shields.io/pypi/l/usethis.svg>)
[![PyPI Supported Versions](<https://img.shields.io/pypi/pyversions/usethis.svg>)](<https://pypi.python.org/pypi/usethis>)
[![uv](<https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json>)](<https://github.com/astral-sh/uv>)
[![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>)
[![GitHub Actions Status](https://github.com/nathanjmcdougall/usethis-python/workflows/CI/badge.svg)](https://github.com/nathanjmcdougall/usethis-python/actions)

Automate Python project setup and development tasks that are otherwise performed manually.

Inspired by an [**R** package of the same name](https://usethis.r-lib.org/index.html),
Expand Down Expand Up @@ -84,8 +91,6 @@ Supported arguments:

## Development

[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)

This project is at the early stages of development. If you are interested in contributing,
please ensure you have a corresponsing GitHub Issue open.

Expand Down
186 changes: 186 additions & 0 deletions doc/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion src/usethis/__main__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import typer

import usethis._interface.badge
import usethis._interface.browse
import usethis._interface.ci
import usethis._interface.show
Expand All @@ -11,10 +12,11 @@
"performed manually."
)
)
app.add_typer(usethis._interface.tool.app, name="tool")
app.add_typer(usethis._interface.badge.app, name="badge")
app.add_typer(usethis._interface.browse.app, name="browse")
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(prog_name="usethis")

__all__ = ["app"]
123 changes: 123 additions & 0 deletions src/usethis/_core/badge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import re
from pathlib import Path
from typing import Self

from pydantic import BaseModel

from usethis._console import tick_print


class Badge(BaseModel):
markdown: str

@property
def name(self) -> str | None:
match = re.match(r"^\s*\[!\[(.*)\]\(.*\)\]\(.*\)\s*$", self.markdown)
if match:
return match.group(1)
match = re.match(r"^\s*\!\[(.*)\]\(.*\)\s*$", self.markdown)
if match:
return match.group(1)
return None

def equivalent_to(self, other: Self) -> bool:
return self.name == other.name


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

BADGE_ORDER = [
RUFF_BADGE,
PRE_COMMIT_BADGE,
]


def add_ruff_badge():
add_badge(RUFF_BADGE)


def add_pre_commit_badge():
add_badge(PRE_COMMIT_BADGE)


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

if not path.exists():
raise NotImplementedError

prerequisites: list[Badge] = []
for _b in BADGE_ORDER:
if badge.equivalent_to(_b):
break
prerequisites.append(_b)

content = path.read_text()

original_lines = content.splitlines()

have_added = False
lines: list[str] = []
for original_line in original_lines:
original_badge = Badge(markdown=original_line)

if original_badge.equivalent_to(badge):
# If the badge is already there, we don't need to do anything
return

original_line_is_prerequisite = any(
original_badge.equivalent_to(prerequisite) for prerequisite in prerequisites
)
if not have_added and (
not original_line_is_prerequisite
and not is_blank(original_line)
and not is_header(original_line)
):
tick_print(f"Adding {badge.name} badge to 'README.md'.")
lines.append(badge.markdown)
have_added = True

# Protect the badge we've just added
if not is_blank(original_line) and not is_badge(original_line):
lines.append("")

lines.append(original_line)

# In case the badge needs to go at the bottom of the file
if not have_added:
# 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)

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

# Ensure final newline
if lines[-1] != "":
lines.append("")

path.write_text("\n".join(lines))


def is_blank(line: str) -> bool:
return line.isspace() or not line


def is_header(line: str) -> bool:
return line.strip().startswith("#")


def is_badge(line: str) -> bool:
# A heuristic
return (
re.match(r"^\[!\[.*\]\(.*\)\]\(.*\)$", line) is not None
or re.match(r"^\!\[.*\]\(.*\)$", line) is not None
)
6 changes: 3 additions & 3 deletions src/usethis/_core/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,11 @@ def use_ruff(*, remove: bool = False) -> None:
tool.add_pre_commit_repo_configs()

box_print(
"Call the 'ruff check --fix' command to run the ruff linter with autofixes."
"Call the 'ruff check --fix' command to run the Ruff linter with autofixes."
)
box_print("Call the 'ruff format' command to run the ruff formatter.")
box_print("Call the 'ruff format' command to run the Ruff formatter.")
else:
if PreCommitTool().is_used():
tool.remove_pre_commit_repo_configs()
tool.remove_pyproject_configs() # N.B. this will remove the selected ruff rules
tool.remove_pyproject_configs() # N.B. this will remove the selected Ruff rules
remove_deps_from_group(tool.dev_deps, "dev")
11 changes: 1 addition & 10 deletions src/usethis/_integrations/bitbucket/schema_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from usethis._integrations.bitbucket.schema import Step, Step1, Step2
from usethis._integrations.bitbucket.schema import Step, Step1


def step1tostep(step1: Step1) -> Step:
Expand All @@ -14,12 +14,3 @@ def step1tostep(step1: Step1) -> Step:

step = Step(**step2.model_dump())
return step


def steptostep1(step: Step) -> Step1:
"""Demoting Step to a Step1.
See `step1tostep` for more information.
"""
step1 = Step1(step=Step2(**step.model_dump()))
return step1
2 changes: 1 addition & 1 deletion src/usethis/_integrations/pre_commit/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def remove_hook(name: str) -> None:
for hook in repo.hooks:
if hook.id == name:
tick_print(
f"Removing {hook.id} config from '.pre-commit-config.yaml'."
f"Removing hook '{hook.id}' from '.pre-commit-config.yaml'."
)
repo.hooks.remove(hook)

Expand Down
16 changes: 8 additions & 8 deletions src/usethis/_integrations/ruff/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,35 @@


def select_ruff_rules(rules: list[str]) -> None:
"""Add ruff rules to the project."""
"""Add Ruff rules to the project."""
rules = sorted(set(rules) - set(get_ruff_rules()))

if not rules:
return

rules_str = ", ".join([f"'{rule}'" for rule in rules])
s = "" if len(rules) == 1 else "s"
tick_print(f"Enabling ruff rule{s} {rules_str} in 'pyproject.toml'.")
tick_print(f"Enabling Ruff rule{s} {rules_str} in 'pyproject.toml'.")

append_config_list(["tool", "ruff", "lint", "select"], rules)


def ignore_ruff_rules(rules: list[str]) -> None:
"""Ignore ruff rules in the project."""
"""Ignore Ruff rules in the project."""
rules = sorted(set(rules) - set(get_ignored_ruff_rules()))

if not rules:
return

rules_str = ", ".join([f"'{rule}'" for rule in rules])
s = "" if len(rules) == 1 else "s"
tick_print(f"Ignoring ruff rule{s} {rules_str} in 'pyproject.toml'.")
tick_print(f"Ignoring Ruff rule{s} {rules_str} in 'pyproject.toml'.")

append_config_list(["tool", "ruff", "lint", "ignore"], rules)


def deselect_ruff_rules(rules: list[str]) -> None:
"""Ensure ruff rules are not selected in the project."""
"""Ensure Ruff rules are not selected in the project."""

rules = list(set(rules) & set(get_ruff_rules()))

Expand All @@ -44,13 +44,13 @@ def deselect_ruff_rules(rules: list[str]) -> None:

rules_str = ", ".join([f"'{rule}'" for rule in rules])
s = "" if len(rules) == 1 else "s"
tick_print(f"Disabling ruff rule{s} {rules_str} in 'pyproject.toml'.")
tick_print(f"Disabling Ruff rule{s} {rules_str} in 'pyproject.toml'.")

remove_from_config_list(["tool", "ruff", "lint", "select"], rules)


def get_ruff_rules() -> list[str]:
"""Get the ruff rules selected in the project."""
"""Get the Ruff rules selected in the project."""

try:
rules: list[str] = get_config_value(["tool", "ruff", "lint", "select"])
Expand All @@ -61,7 +61,7 @@ def get_ruff_rules() -> list[str]:


def get_ignored_ruff_rules() -> list[str]:
"""Get the ruff rules ignored in the project."""
"""Get the Ruff rules ignored in the project."""

try:
rules: list[str] = get_config_value(["tool", "ruff", "lint", "ignore"])
Expand Down
5 changes: 1 addition & 4 deletions src/usethis/_integrations/yaml/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from dataclasses import dataclass
from pathlib import Path
from types import NoneType
from typing import TypeAlias, TypeVar
from typing import TypeAlias

import ruamel.yaml
from ruamel.yaml.comments import (
Expand Down Expand Up @@ -32,9 +32,6 @@

from usethis._integrations.yaml.errors import InvalidYAMLError

T = TypeVar("T")


YAMLLiteral: TypeAlias = (
NoneType
| bool
Expand Down
26 changes: 26 additions & 0 deletions src/usethis/_interface/badge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import typer

from usethis._config import offline_opt, quiet_opt, usethis_config
from usethis._core.badge import add_pre_commit_badge, add_ruff_badge

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


@app.command(help="Add a badge for the Ruff linter.")
def ruff(
*,
offline: bool = offline_opt,
quiet: bool = quiet_opt,
) -> None:
with usethis_config.set(offline=offline, quiet=quiet):
add_ruff_badge()


@app.command(help="Add a badge for the pre-commit framework.")
def pre_commit(
*,
offline: bool = offline_opt,
quiet: bool = quiet_opt,
) -> None:
with usethis_config.set(offline=offline, quiet=quiet):
add_pre_commit_badge()
2 changes: 1 addition & 1 deletion src/usethis/_interface/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def pytest(
_run_tool(use_pytest, remove=remove)


@app.command(help="Use ruff: an extremely fast Python linter and code formatter.")
@app.command(help="Use Ruff: an extremely fast Python linter and code formatter.")
def ruff(
remove: bool = remove_opt, offline: bool = offline_opt, quiet: bool = quiet_opt
) -> None:
Expand Down
4 changes: 2 additions & 2 deletions src/usethis/_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def get_pyproject_configs(self) -> list[PyProjectConfig]:
return []

def get_associated_ruff_rules(self) -> list[str]:
"""Get the ruff rule codes associated with the tool."""
"""Get the Ruff rule codes associated with the tool."""
return []

def get_unique_dev_deps(self) -> list[str]:
Expand Down Expand Up @@ -280,7 +280,7 @@ def get_managed_files(self):
class RuffTool(Tool):
@property
def name(self) -> str:
return "ruff"
return "Ruff"

@property
def dev_deps(self) -> list[str]:
Expand Down
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ class NetworkConn(Enum):
],
scope="session",
)
def vary_network_conn(request: pytest.FixtureRequest) -> Generator[bool, None, None]:
def _vary_network_conn(request: pytest.FixtureRequest) -> Generator[None, None, None]:
"""Fixture to vary the network connection; returns True if offline."""
if request.param is NetworkConn.ONLINE and is_offline():
pytest.skip("Network connection is offline")

offline = request.param is NetworkConn.OFFLINE

usethis_config.offline = offline
yield offline
yield
usethis_config.offline = False
Loading

0 comments on commit d2b55a0

Please sign in to comment.