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

Create UsethisConfig class, enable pyright, refactor handling off being offline #91

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
16 changes: 16 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,19 @@ repos:
language: system
always_run: true
pass_filenames: false
- repo: local
hooks:
- id: import-linter
name: import-linter
entry: uv run --frozen lint-imports
language: system
always_run: true
pass_filenames: false
- repo: local
hooks:
- id: pyright
name: pyright
entry: uv run --frozen pyright
language: system
always_run: true
pass_filenames: false
9 changes: 6 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ requires = [ "hatch-vcs", "hatchling" ]
name = "usethis"
description = "Automate Python project setup and development tasks that are otherwise performed manually."
readme = "README.md"
keywords = ["usethis", "project", "init", "setup", "start"]
keywords = [ "init", "project", "setup", "start", "usethis" ]
license = { file = "LICENSE" }
authors = [
{ name = "Nathan McDougall", email = "[email protected]" },
Expand Down Expand Up @@ -48,6 +48,7 @@ dev = [
"import-linter>=2.1",
"pre-commit>=4.0.1",
"pyproject-fmt>=2.4.3",
"pyright>=1.1.387",
"ruff>=0.7.1",
]
test = [
Expand All @@ -66,13 +67,14 @@ source = "vcs"
"Source Code" = "https://github.com/nathanjmcdougall/usethis-python"
"Bug Tracker" = "https://github.com/nathanjmcdougall/usethis-python/issues"
"Releases" = "https://github.com/nathanjmcdougall/usethis-python/releases"
"source_archive" = "https://github.com/nathanjmcdougall/usethis-python/archive/{commit_hash}.zip"
"Source Archive" = "https://github.com/nathanjmcdougall/usethis-python/archive/{commit_hash}.zip"

[tool.ruff]
line-length = 88

src = [ "src" ]
lint.select = ["C4", "E4", "E7", "E9", "F", "FURB", "I", "PLE", "PLR", "RUF", "SIM", "UP", "PT"]
lint.select = [ "C4", "E4", "E7", "E9", "F", "FURB", "I", "PLE", "PLR", "PT", "RUF", "SIM", "UP" ]
lint.ignore = ["PT004", "PT005"]

[tool.pytest.ini_options]
testpaths = [ "tests" ]
Expand All @@ -99,6 +101,7 @@ layers = [
"_tool",
"_integrations",
"_console",
"_config",
"_utils",
]
containers = [ "usethis" ]
Expand Down
30 changes: 30 additions & 0 deletions src/usethis/_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from collections.abc import Generator
from contextlib import contextmanager

import typer
from pydantic import BaseModel

_OFFLINE_DEFAULT = False
_QUIET_DEFAULT = False

offline_opt = typer.Option(_OFFLINE_DEFAULT, "--offline", help="Disable network access")
quiet_opt = typer.Option(_QUIET_DEFAULT, "--quiet", help="Suppress output")


class UsethisConfig(BaseModel):
"""Global-state for command options which affect low level behaviour."""

offline: bool
quiet: bool

@contextmanager
def set(self, *, offline: bool, quiet: bool) -> Generator[None, None, None]:
"""Temporarily set the console to quiet mode."""
self.offline = offline
self.quiet = quiet
yield
self.offline = _OFFLINE_DEFAULT
self.quiet = _QUIET_DEFAULT


usethis_config = UsethisConfig(offline=_OFFLINE_DEFAULT, quiet=_QUIET_DEFAULT)
30 changes: 8 additions & 22 deletions src/usethis/_console.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,15 @@
from collections.abc import Generator
from contextlib import contextmanager

from pydantic import BaseModel
from rich.console import Console

typer_console = Console()


class UsethisConsole(BaseModel):
quiet: bool
from usethis._config import usethis_config

def tick_print(self, msg: str) -> None:
if not self.quiet:
typer_console.print(f"✔ {msg}", style="green")
console = Console()

def box_print(self, msg: str) -> None:
if not self.quiet:
typer_console.print(f"☐ {msg}", style="blue")

@contextmanager
def set(self, *, quiet: bool) -> Generator[None, None, None]:
"""Temporarily set the console to quiet mode."""
self.quiet = quiet
yield
self.quiet = False
def tick_print(msg: str) -> None:
if not usethis_config.quiet:
console.print(f"✔ {msg}", style="green")


console = UsethisConsole(quiet=False)
def box_print(msg: str) -> None:
if not usethis_config.quiet:
console.print(f"☐ {msg}", style="blue")
6 changes: 3 additions & 3 deletions src/usethis/_integrations/bitbucket/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from pathlib import Path

from usethis._console import console
from usethis._console import tick_print

_YAML_CONTENTS = """\
image: atlassian/default-image:3
Expand All @@ -27,7 +27,7 @@ def add_bitbucket_pipeline_config() -> None:
# Early exit; the file already exists
return

console.tick_print("Writing 'bitbucket-pipelines.yml'.")
tick_print("Writing 'bitbucket-pipelines.yml'.")
yaml_contents = _YAML_CONTENTS

(Path.cwd() / "bitbucket-pipelines.yml").write_text(yaml_contents)
Expand All @@ -38,5 +38,5 @@ def remove_bitbucket_pipeline_config() -> None:
# Early exit; the file already doesn't exist
return

console.tick_print("Removing 'bitbucket-pipelines.yml' file.")
tick_print("Removing 'bitbucket-pipelines.yml' file.")
(Path.cwd() / "bitbucket-pipelines.yml").unlink()
10 changes: 5 additions & 5 deletions src/usethis/_integrations/pre_commit/core.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import subprocess
from pathlib import Path

from usethis._console import console
from usethis._console import tick_print
from usethis._integrations.github.tags import GitHubTagError, get_github_latest_tag

_YAML_CONTENTS_TEMPLATE = """\
Expand All @@ -21,7 +21,7 @@ def add_pre_commit_config() -> None:
# Early exit; the file already exists
return

console.tick_print("Writing '.pre-commit-config.yaml'.")
tick_print("Writing '.pre-commit-config.yaml'.")
try:
pkg_version = get_github_latest_tag("abravalheri", "validate-pyproject")
except GitHubTagError:
Expand All @@ -37,12 +37,12 @@ def remove_pre_commit_config() -> None:
# Early exit; the file already doesn't exist
return

console.tick_print("Removing .pre-commit-config.yaml file.")
tick_print("Removing .pre-commit-config.yaml file.")
(Path.cwd() / ".pre-commit-config.yaml").unlink()


def install_pre_commit() -> None:
console.tick_print("Ensuring pre-commit hooks are installed.")
tick_print("Ensuring pre-commit hooks are installed.")
subprocess.run(
["uv", "run", "pre-commit", "install"],
check=True,
Expand All @@ -51,7 +51,7 @@ def install_pre_commit() -> None:


def uninstall_pre_commit() -> None:
console.tick_print("Ensuring pre-commit hooks are uninstalled.")
tick_print("Ensuring pre-commit hooks are uninstalled.")
subprocess.run(
["uv", "run", "pre-commit", "uninstall"],
check=True,
Expand Down
62 changes: 58 additions & 4 deletions src/usethis/_integrations/pyproject/core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from typing import Any

import mergedeep
from pydantic import TypeAdapter
from tomlkit import TOMLDocument

from usethis._integrations.pyproject.io import (
read_pyproject_toml,
Expand All @@ -17,10 +19,15 @@ class ConfigValueMissingError(ValueError):


def get_config_value(id_keys: list[str]) -> Any:
if not id_keys:
raise ValueError("At least one ID key must be provided.")

pyproject = read_pyproject_toml()

p = pyproject
for key in id_keys:
TypeAdapter(dict).validate_python(p)
assert isinstance(p, dict)
p = p[key]

return p
Expand All @@ -34,11 +41,16 @@ def set_config_value(
Raises:
ConfigValueAlreadySetError: If the configuration value is already set.
"""
if not id_keys:
raise ValueError("At least one ID key must be provided.")

pyproject = read_pyproject_toml()

try:
p = pyproject
p, parent = pyproject, {}
for key in id_keys:
TypeAdapter(dict).validate_python(p)
assert isinstance(p, dict)
p, parent = p[key], p
except KeyError:
# The old configuration should be kept for all ID keys except the
Expand All @@ -50,25 +62,33 @@ def set_config_value(
for key in reversed(id_keys):
contents = {key: contents}
pyproject = mergedeep.merge(pyproject, contents)
assert isinstance(pyproject, TOMLDocument)
else:
if not exists_ok:
# The configuration is already present, which is not allowed.
msg = f"Configuration value [{'.'.join(id_keys)}] is already set."
raise ConfigValueAlreadySetError(msg)
else:
# The configuration is already present, but we're allowed to overwrite it.
TypeAdapter(dict).validate_python(parent)
assert isinstance(parent, dict)
parent[id_keys[-1]] = value

write_pyproject_toml(pyproject)


def remove_config_value(id_keys: list[str], *, missing_ok: bool = False) -> None:
if not id_keys:
raise ValueError("At least one ID key must be provided.")

pyproject = read_pyproject_toml()

# Exit early if the configuration is not present.
try:
p = pyproject
for key in id_keys:
TypeAdapter(dict).validate_python(p)
assert isinstance(p, dict)
p = p[key]
except KeyError:
if not missing_ok:
Expand All @@ -83,14 +103,23 @@ def remove_config_value(id_keys: list[str], *, missing_ok: bool = False) -> None
# Remove the configuration.
p = pyproject
for key in id_keys[:-1]:
TypeAdapter(dict).validate_python(p)
assert isinstance(p, dict)
p = p[key]
assert isinstance(p, dict)
del p[id_keys[-1]]

# Cleanup: any empty sections should be removed.
for idx in range(len(id_keys) - 1):
p = pyproject
p, parent = pyproject, {}
TypeAdapter(dict).validate_python(p)
for key in id_keys[: idx + 1]:
p, parent = p[key], p
TypeAdapter(dict).validate_python(p)
TypeAdapter(dict).validate_python(parent)
assert isinstance(p, dict)
assert isinstance(parent, dict)
assert isinstance(p, dict)
if not p:
del parent[id_keys[idx]]

Expand All @@ -100,41 +129,66 @@ def remove_config_value(id_keys: list[str], *, missing_ok: bool = False) -> None
def append_config_list(
id_keys: list[str],
values: list[Any],
) -> list[str]:
) -> None:
"""Append values to a list in the pyproject.toml configuration file."""
if not id_keys:
raise ValueError("At least one ID key must be provided.")

pyproject = read_pyproject_toml()

try:
p = pyproject
for key in id_keys[:-1]:
TypeAdapter(dict).validate_python(p)
assert isinstance(p, dict)
p = p[key]
p_parent = p
TypeAdapter(dict).validate_python(p_parent)
assert isinstance(p_parent, dict)
p = p_parent[id_keys[-1]]
except KeyError:
contents = values
for key in reversed(id_keys):
contents = {key: contents}
assert isinstance(contents, dict)
pyproject = mergedeep.merge(pyproject, contents)
assert isinstance(pyproject, TOMLDocument)
else:
TypeAdapter(dict).validate_python(p_parent)
TypeAdapter(list).validate_python(p)
assert isinstance(p_parent, dict)
assert isinstance(p, list)
p_parent[id_keys[-1]] = p + values

write_pyproject_toml(pyproject)


def remove_from_config_list(id_keys: list[str], values: list[str]) -> None:
if not id_keys:
raise ValueError("At least one ID key must be provided.")

pyproject = read_pyproject_toml()

try:
p = pyproject
for key in id_keys[:-1]:
TypeAdapter(dict).validate_python(p)
assert isinstance(p, dict)
p = p[key]

p_parent = p
TypeAdapter(dict).validate_python(p_parent)
assert isinstance(p_parent, dict)
p = p_parent[id_keys[-1]]
except KeyError:
# The configuration is not present.
return

# Remove the rules from the existing configuration.
TypeAdapter(dict).validate_python(p_parent)
TypeAdapter(list).validate_python(p)
assert isinstance(p_parent, dict)
assert isinstance(p, list)

new_values = [value for value in p if value not in values]
p_parent[id_keys[-1]] = new_values

Expand Down
5 changes: 4 additions & 1 deletion src/usethis/_integrations/pyproject/requires_python.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from packaging.specifiers import SpecifierSet
from pydantic import TypeAdapter

from usethis._integrations.pyproject.io import read_pyproject_toml

Expand All @@ -14,7 +15,9 @@ def get_requires_python() -> SpecifierSet:
pyproject = read_pyproject_toml()

try:
requires_python = pyproject["project"]["requires-python"]
requires_python = TypeAdapter(str).validate_python(
TypeAdapter(dict).validate_python(pyproject["project"])["requires-python"]
)
except KeyError:
raise MissingRequiresPythonError(
"The [project.requires-python] value is missing from 'pyproject.toml'."
Expand Down
Loading