Skip to content

Commit

Permalink
Create UsethisConfig class, enable pyright, refactor handling off bei…
Browse files Browse the repository at this point in the history
…ng offline (#91)

* Create UsethisConfig class, enable pyright, refactor handling off being offline

* Rename "Source Archive" URL config for aesthetics.

* Pass failing test

* Better handling of being offline in `test_max_major_py3`

* Run online tests first before offline tests
  • Loading branch information
nathanjmcdougall authored Oct 31, 2024
1 parent 54c3798 commit c1d09e6
Show file tree
Hide file tree
Showing 22 changed files with 326 additions and 169 deletions.
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

0 comments on commit c1d09e6

Please sign in to comment.