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

Handle missing project.version automatically #301

Merged
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
18 changes: 14 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ scripts.usethis = "usethis.__main__:app"
dev = [
"datamodel-code-generator[http]>=0.26.5",
"deptry>=0.23.0",
"import-linter>=2.1",
"import-linter>=2.2",
"pre-commit>=4.1.0",
"pyright>=1.1.393",
"ruff>=0.9.4",
"pyright>=1.1.394",
"ruff>=0.9.6",
]
test = [
"coverage[toml]>=7.6.10",
Expand Down Expand Up @@ -149,6 +149,7 @@ type = "layers"
layers = [
"_test",
"__main__",
"_app",
"_interface",
"_core",
"_tool | _ci",
Expand All @@ -162,13 +163,22 @@ containers = [ "usethis" ]
exhaustive = true
exhaustive_ignores = [ "_version" ]

[[tool.importlinter.contracts]]
name = "Interface Independence"
type = "layers"
layers = [
"badge | browse | ci | readme | show | tool | version",
]
containers = [ "usethis._interface" ]
exhaustive = true

[[tool.importlinter.contracts]]
name = "Integrations Modular Design"
type = "layers"
layers = [
"bitbucket | github | pre_commit | pytest | ruff",
"uv | pydantic | sonarqube",
"pyproject | yaml",
"pyproject | yaml | python",
]
containers = [ "usethis._integrations" ]
exhaustive = true
59 changes: 2 additions & 57 deletions src/usethis/__main__.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,7 @@
"""The Typer application for usethis."""

import sys

import typer

import usethis._interface.badge
import usethis._interface.browse
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

try:
from usethis._version import __version__
except ImportError:
__version__ = None

app = typer.Typer(
help=(
"Automate Python package and project setup tasks that are otherwise "
"performed manually."
)
)
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.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.command(help="Display the version of usethis.")
def version() -> None:
if __version__ is not None:
print(__version__)
else:
sys.exit(1)
"""The CLI application for usethis."""

from usethis._app import app

app(prog_name="usethis")


__all__ = ["app"]
29 changes: 29 additions & 0 deletions src/usethis/_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""The Typer application for usethis."""

import typer

import usethis._interface.badge
import usethis._interface.browse
import usethis._interface.ci
import usethis._interface.readme
import usethis._interface.show
import usethis._interface.tool
import usethis._interface.version

app = typer.Typer(
help=(
"Automate Python package and project setup tasks that are otherwise "
"performed manually."
)
)
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.command(help="Add a README.md file to the project.")(
usethis._interface.readme.readme,
)
app.add_typer(usethis._interface.show.app, name="show")
app.add_typer(usethis._interface.tool.app, name="tool")
app.command(help="Display the version of usethis.")(
usethis._interface.version.version,
)
7 changes: 4 additions & 3 deletions src/usethis/_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ def is_bitbucket_used() -> bool:


def update_bitbucket_pytest_steps() -> None:
matrix = get_supported_major_python_versions()
for version in matrix:
versions = get_supported_major_python_versions()

for version in versions:
add_bitbucket_step_in_default(
Step(
name=f"Test on 3.{version}",
Expand All @@ -36,7 +37,7 @@ def update_bitbucket_pytest_steps() -> None:
match = re.match(r"^Test on 3\.(\d+)$", step.name)
if match:
version = int(match.group(1))
if version not in matrix:
if version not in versions:
remove_bitbucket_step_from_default(step)


Expand Down
4 changes: 2 additions & 2 deletions src/usethis/_core/badge.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import re
import sys
from pathlib import Path

import typer
from pydantic import BaseModel
from typing_extensions import Self

Expand Down Expand Up @@ -92,7 +92,7 @@ def add_badge(badge: Badge) -> None:
path = _get_markdown_readme_path()
except FileNotFoundError as err:
err_print(err)
sys.exit(1)
raise typer.Exit(code=1)

prerequisites: list[Badge] = []
for _b in get_badge_order():
Expand Down
11 changes: 7 additions & 4 deletions src/usethis/_core/show.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import sys
import typer

from usethis._config import usethis_config
from usethis._console import err_print
from usethis._integrations.pyproject.name import get_name
from usethis._integrations.sonarqube.config import get_sonar_project_properties
Expand All @@ -8,14 +9,16 @@


def show_name() -> None:
ensure_pyproject_toml()
with usethis_config.set(quiet=True):
ensure_pyproject_toml()
print(get_name())


def show_sonarqube_config() -> None:
ensure_pyproject_toml()
with usethis_config.set(quiet=True):
ensure_pyproject_toml()
try:
print(get_sonar_project_properties())
except UsethisError as err:
err_print(err)
sys.exit(1)
raise typer.Exit(code=1)
5 changes: 3 additions & 2 deletions src/usethis/_core/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ def use_requirements_txt(*, remove: bool = False) -> None:
# N.B. this is where a task runner would come in handy, to reduce duplication.
if not (Path.cwd() / "uv.lock").exists() and not usethis_config.frozen:
tick_print("Writing 'uv.lock'.")
call_uv_subprocess(["lock"])
call_uv_subprocess(["lock"], change_toml=False)

if not usethis_config.frozen:
tick_print("Writing 'requirements.txt'.")
Expand All @@ -323,7 +323,8 @@ def use_requirements_txt(*, remove: bool = False) -> None:
"--frozen",
"--no-dev",
"--output-file=requirements.txt",
]
],
change_toml=False,
)

if not is_pre_commit:
Expand Down
8 changes: 4 additions & 4 deletions src/usethis/_integrations/bitbucket/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,9 @@ def _add_step_in_default_via_doc(
) -> None:
_add_step_caches_via_doc(step, doc=doc)

if step.name == _PLACEHOLDER_NAME:
pass # We need to selectively choose to report at a higher level.
if step.name != _PLACEHOLDER_NAME:
# We need to selectively choose to report at a higher level.
# It's not always notable that the placeholder is being added.
else:
tick_print(
f"Adding '{step.name}' to default pipeline in 'bitbucket-pipelines.yml'."
)
Expand Down Expand Up @@ -157,14 +156,15 @@ def _add_step_in_default_via_doc(
# N.B. Currently, we are not accounting for parallelism, whereas all these steps
# could be parallel potentially.
# See https://github.com/nathanjmcdougall/usethis-python/issues/149
maj_versions = get_supported_major_python_versions()
step_order = [
"Run pre-commit",
# For these tools, sync them with the pre-commit removal logic
"Run pyproject-fmt",
"Run Ruff",
"Run Deptry",
"Run Codespell",
*[f"Test on 3.{maj}" for maj in get_supported_major_python_versions()],
*[f"Test on 3.{maj_version}" for maj_version in maj_versions],
]
for step_name in step_order:
if step_name == step.name:
Expand Down
9 changes: 6 additions & 3 deletions src/usethis/_integrations/pre_commit/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def install_pre_commit_hooks() -> None:

tick_print("Ensuring pre-commit is installed to Git.")
try:
call_uv_subprocess(["run", "pre-commit", "install"])
call_uv_subprocess(["run", "pre-commit", "install"], change_toml=False)
except UVSubprocessFailedError as err:
msg = f"Failed to install pre-commit in the Git repository:\n{err}"
raise PreCommitInstallationError(msg) from None
Expand All @@ -38,7 +38,7 @@ def install_pre_commit_hooks() -> None:
"This may take a minute or so while the hooks are downloaded.", temporary=True
)
try:
call_uv_subprocess(["run", "pre-commit", "install-hooks"])
call_uv_subprocess(["run", "pre-commit", "install-hooks"], change_toml=False)
except UVSubprocessFailedError as err:
msg = f"Failed to install pre-commit hooks:\n{err}"
raise PreCommitInstallationError(msg) from None
Expand All @@ -57,7 +57,10 @@ def uninstall_pre_commit_hooks() -> None:

tick_print("Ensuring pre-commit hooks are uninstalled.")
try:
call_uv_subprocess(["run", "--with", "pre-commit", "pre-commit", "uninstall"])
call_uv_subprocess(
["run", "--with", "pre-commit", "pre-commit", "uninstall"],
change_toml=False,
)
except UVSubprocessFailedError as err:
msg = f"Failed to uninstall pre-commit hooks:\n{err}"
raise PreCommitInstallationError(msg) from None
2 changes: 1 addition & 1 deletion src/usethis/_integrations/pyproject/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def set_config_value(

try:
# Index our way into each ID key.
# Eventually, we should land at a final dict, which si the one we are setting.
# Eventually, we should land at a final dict, which is the one we are setting.
p, parent = pyproject, {}
for key in id_keys:
TypeAdapter(dict).validate_python(p)
Expand Down
81 changes: 77 additions & 4 deletions src/usethis/_integrations/pyproject/io_.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from tomlkit.api import dumps, parse
from tomlkit.exceptions import TOMLKitError
from tomlkit.toml_document import TOMLDocument
from typing_extensions import Self

from usethis._integrations.pyproject.errors import (
PyProjectTOMLDecodeError,
Expand All @@ -12,7 +13,11 @@


def read_pyproject_toml() -> TOMLDocument:
return read_pyproject_toml_from_path(Path.cwd() / "pyproject.toml")
return pyproject_toml_io_manager._opener.read()


def write_pyproject_toml(toml_document: TOMLDocument) -> None:
return pyproject_toml_io_manager._opener.write(toml_document)


@cache
Expand All @@ -27,6 +32,74 @@ def read_pyproject_toml_from_path(path: Path) -> TOMLDocument:
raise PyProjectTOMLDecodeError(msg) from None


def write_pyproject_toml(toml_document: TOMLDocument) -> None:
read_pyproject_toml_from_path.cache_clear()
(Path.cwd() / "pyproject.toml").write_text(dumps(toml_document))
class UnexpectedPyprojectTOMLReadError(Exception):
"""Raised when the pyproject.toml is read unexpectedly."""


class PyprojectTOMLOpener:
def __init__(self) -> None:
self.path = Path.cwd() / "pyproject.toml"
self.content = TOMLDocument()
self.open = False
self._set = False

def read(self) -> TOMLDocument:
if not self._set:
msg = """The pyproject.toml opener has not been set yet."""
raise UnexpectedPyprojectTOMLOpenError(msg)

if not self.open:
self.read_file()
self.open = True

return self.content

def write(self, toml_document: TOMLDocument) -> None:
if not self._set:
msg = """The pyproject.toml opener has not been set yet."""
raise UnexpectedPyprojectTOMLOpenError(msg)

self.content = toml_document

def write_file(self) -> None:
read_pyproject_toml_from_path.cache_clear()
self.path.write_text(dumps(self.content))

def read_file(self) -> None:
self.content = read_pyproject_toml_from_path(self.path)

def __enter__(self) -> Self:
self._set = True
return self

def __exit__(self, exc_type: None, exc_value: None, traceback: None) -> None:
self.write_file()
self._set = False


class UnexpectedPyprojectTOMLOpenError(Exception):
"""Raised when the pyproject.toml opener is accessed unexpectedly."""


class PyprojectTOMLIOManager:
def __init__(self) -> None:
self._opener = PyprojectTOMLOpener()
self._set = False

@property
def opener(self) -> PyprojectTOMLOpener:
if not self._opener._set:
self._set = False

if not self._set:
msg = """The pyproject.toml opener has not been set to open yet."""
raise UnexpectedPyprojectTOMLOpenError(msg)

return self._opener

def open(self) -> PyprojectTOMLOpener:
self._opener = PyprojectTOMLOpener()
return self._opener


pyproject_toml_io_manager = PyprojectTOMLIOManager()
Loading