Skip to content
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
2 changes: 2 additions & 0 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ layers =
all_
impl
base
heuristics
spec
config | pre_commit | rule
exhaustive = true

Expand Down
285 changes: 12 additions & 273 deletions src/usethis/_tool/base.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
from __future__ import annotations

from abc import abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal, Protocol

from typing_extensions import assert_never

from usethis._backend.dispatch import get_backend
from usethis._backend.uv.detect import is_uv_used
from usethis._config import usethis_config
from usethis._console import how_print, tick_print, warn_print
from usethis._deps import add_deps_to_group, is_dep_in_any_group, remove_deps_from_group
from usethis._console import how_print, tick_print
from usethis._deps import add_deps_to_group, remove_deps_from_group
from usethis._detect.ci.bitbucket import is_bitbucket_used
from usethis._detect.pre_commit import is_pre_commit_used
from usethis._file.pyproject_toml.io_ import PyprojectTOMLManager
from usethis._integrations.ci.bitbucket import schema as bitbucket_schema
from usethis._integrations.ci.bitbucket.anchor import (
ScriptItemAnchor as BitbucketScriptItemAnchor,
Expand All @@ -32,177 +29,22 @@
remove_hook,
)
from usethis._tool.config import ConfigSpec, NoConfigValue, ensure_managed_file_exists
from usethis._tool.pre_commit import PreCommitConfig
from usethis._tool.rule import RuleConfig
from usethis._tool.heuristics import is_likely_used
from usethis._tool.spec import ToolMeta, ToolSpec
from usethis._types.backend import BackendEnum
from usethis.errors import (
FileConfigError,
NoDefaultToolCommand,
UnhandledConfigEntryError,
)

if TYPE_CHECKING:
from pathlib import Path

from usethis._integrations.pre_commit import schema as pre_commit_schema
from usethis._io import KeyValueFileManager
from usethis._tool.config import ConfigItem, ResolutionT
from usethis._tool.rule import Rule
from usethis._types.deps import Dependency


@dataclass(frozen=True)
class ToolMeta:
"""These are static metadata associated with the tool.

These aspects are independent of the current project.

See the respective `ToolSpec` properties for each attribute for documentation on the
individual attributes.
"""

name: str
managed_files: list[Path] = field(default_factory=list)
# This is more about the inherent definition
rule_config: RuleConfig = field(default_factory=RuleConfig)
url: str | None = None # For documentation purposes


class ToolSpec(Protocol):
@property
@abstractmethod
def meta(self) -> ToolMeta: ...

@property
def name(self) -> str:
"""The name of the tool, for display purposes.

It is assumed that this name is also the name of the Python package associated
with the tool; if not, make sure to override methods which access this property.

This is the display-friendly (e.g. brand compliant) name of the tool, not the
name of a CLI command, etc. Pay mind to the correct capitalization.

For example, the tool named `ty` has a name of `ty`, not `Ty` or `TY`.
Import Linter has a name of `Import Linter`, not `import-linter`.
"""
return self.meta.name

@property
def managed_files(self) -> list[Path]:
"""Get (relative) paths to files managed by (solely) this tool."""
return self.meta.managed_files

@property
def rule_config(self) -> RuleConfig:
"""Get the linter rule configuration associated with this tool.

This is a static, opinionated configuration which usethis uses when adding the
tool (and managing this and other tools when adding and removing, etc.).
"""
return self.meta.rule_config

def preferred_file_manager(self) -> KeyValueFileManager:
"""If there is no currently active config file, this is the preferred one.

This can vary dynamically, since often we will prefer to respect an existing
configuration file if it exists.
"""
return PyprojectTOMLManager()

def raw_cmd(self) -> str:
"""The default command to run the tool.

This should not include a backend-specific prefix, e.g. don't include "uv run".

A non-default implementation should be provided when the tool has a CLI.

This will usually be a static string, but may involve some dynamic inference,
e.g. when determining the source directory for to operate on.

Returns:
The command string.

Raises:
NoDefaultToolCommand: If the tool has no associated command.

Examples:
For codespell: "codespell"
"""
msg = f"{self.name} has no default command."
raise NoDefaultToolCommand(msg)

def dev_deps(self, *, unconditional: bool = False) -> list[Dependency]:
"""The tool's development dependencies.

These should all be considered characteristic of this particular tool.

In general, these can vary dynamically, e.g. based on the versions of Python
supported in the current project.

Args:
unconditional: Whether to return all possible dependencies regardless of
whether they are relevant to the current project.
"""
return []

def test_deps(self, *, unconditional: bool = False) -> list[Dependency]:
"""The tool's test dependencies.

These should all be considered characteristic of this particular tool.

In general, these can vary dynamically, e.g. based on the versions of Python
supported in the current project.

Args:
unconditional: Whether to return all possible dependencies regardless of
whether they are relevant to the current project.
"""
return []

def doc_deps(self, *, unconditional: bool = False) -> list[Dependency]:
"""The tool's documentation dependencies.

These should all be considered characteristic of this particular tool.

In general, these can vary dynamically, e.g. based on the versions of Python
supported in the current project.

Args:
unconditional: Whether to return all possible dependencies regardless of
whether they are relevant to the current project.
"""
return []

def pre_commit_config(self) -> PreCommitConfig:
"""Get the pre-commit configurations for the tool.

In general, this can vary dynamically, e.g. based on whether Ruff is being
configured to be used as a formatter vs. a linter.
"""
return PreCommitConfig(repo_configs=[], inform_how_to_use_on_migrate=False)

def selected_rules(self) -> list[Rule]:
"""Get the rules managed by the tool that are currently selected.

In general, this requires reading config files to look at which rules are
selected for the project.
"""
if not self.rule_config.selected:
return []

raise NotImplementedError

def ignored_rules(self) -> list[Rule]:
"""Get the ignored rules managed by the tool.

In general, this requires reading config files to look at which rules are
ignored for the project.
"""
if not self.rule_config.ignored:
return []

raise NotImplementedError
__all__ = ["Tool", "ToolMeta", "ToolSpec"]


class Tool(ToolSpec, Protocol):
Expand Down Expand Up @@ -294,70 +136,13 @@ def config_spec(self) -> ConfigSpec:
def is_used(self) -> bool:
"""Whether the tool is being used in the current project.

Three heuristics are used by default:
1. Whether any of the tool's characteristic dependencies are in the project.
2. Whether any of the tool's characteristic pre-commit hooks are in the project.
3. Whether any of the tool's managed files are in the project.
4. Whether any of the tool's managed config file sections are present.
"""
decode_err_by_name: dict[str, FileConfigError] = {}
_is_used = False

_is_used = any(file.exists() and file.is_file() for file in self.managed_files)

if not _is_used:
try:
_is_used = self.is_declared_as_dep()
except FileConfigError as err:
decode_err_by_name[err.name] = err

if not _is_used:
try:
_is_used = self.is_config_present()
except FileConfigError as err:
decode_err_by_name[err.name] = err

# Do this last since the YAML parsing is expensive.
if not _is_used:
try:
_is_used = self.is_pre_commit_config_present()
except FileConfigError as err:
decode_err_by_name[err.name] = err

for name, decode_err in decode_err_by_name.items():
warn_print(decode_err)
warn_print(
f"Assuming '{name}' contains no evidence of {self.name} being used."
)

return _is_used

def is_declared_as_dep(self) -> bool:
"""Whether the tool is declared as a dependency in the project.

This is inferred based on whether any of the tools characteristic dependencies
are declared in the project.
Four heuristics are used by default:
1. Whether any of the tool's managed files are present.
2. Whether any of the tool's characteristic dependencies are declared.
3. Whether any of the tool's managed config file sections are present.
4. Whether any of the tool's characteristic pre-commit hooks are present.
"""
# N.B. currently doesn't check core dependencies nor extras.
# Only PEP735 dependency groups.
# See https://github.com/usethis-python/usethis-python/issues/809
_is_declared = False

_is_declared = any(
is_dep_in_any_group(dep) for dep in self.dev_deps(unconditional=True)
)

if not _is_declared:
_is_declared = any(
is_dep_in_any_group(dep) for dep in self.test_deps(unconditional=True)
)

if not _is_declared:
_is_declared = any(
is_dep_in_any_group(dep) for dep in self.doc_deps(unconditional=True)
)

return _is_declared
return is_likely_used(self, self.config_spec())

def add_dev_deps(self) -> None:
add_deps_to_group(self.dev_deps(), "dev")
Expand All @@ -377,30 +162,6 @@ def add_doc_deps(self) -> None:
def remove_doc_deps(self) -> None:
remove_deps_from_group(self.doc_deps(unconditional=True), "doc")

def get_pre_commit_repos(
self,
) -> list[pre_commit_schema.LocalRepo | pre_commit_schema.UriRepo]:
"""Get the pre-commit repository definitions for the tool."""
return [c.repo for c in self.pre_commit_config().repo_configs]

def is_pre_commit_config_present(self) -> bool:
"""Whether the tool's pre-commit configuration is present."""
repo_configs = self.get_pre_commit_repos()

for repo_config in repo_configs:
if repo_config.hooks is None:
continue

# Check if any of the hooks are present.
for hook in repo_config.hooks:
if any(
hook_ids_are_equivalent(hook.id, hook_id)
for hook_id in get_hook_ids()
):
return True

return False

def add_pre_commit_config(self) -> None:
"""Add the tool's pre-commit configuration.

Expand Down Expand Up @@ -542,29 +303,7 @@ def _get_active_config_file_managers_from_resolution(

def is_config_present(self) -> bool:
"""Whether any of the tool's managed config sections are present."""
return self._is_config_spec_present(self.config_spec())

def _is_config_spec_present(self, config_spec: ConfigSpec) -> bool:
"""Check whether a bespoke config spec is present.

The reason for splitting this method out from the overall `is_config_present`
method is to allow for checking a `config_spec` different from the main
config_spec (e.g. a subset of it to distinguish between two different aspects
of a tool, e.g. Ruff's linter vs. formatter configuration sections).
"""
for config_item in config_spec.config_items:
if not config_item.managed:
continue

for relative_path, entry in config_item.root.items():
file_manager = config_spec.file_manager_by_relative_path[relative_path]
if not (file_manager.path.exists() and file_manager.path.is_file()):
continue

if file_manager.__contains__(entry.keys):
return True

return False
return self.config_spec().is_present()

def add_configs(self) -> None:
"""Add the tool's configuration sections.
Expand Down
16 changes: 16 additions & 0 deletions src/usethis/_tool/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ def empty(cls) -> Self:
file_manager_by_relative_path={}, resolution="first", config_items=[]
)

def is_present(self) -> bool:
"""Check whether any managed configuration in this spec is present on disk."""
for config_item in self.config_items:
if not config_item.managed:
continue

for relative_path, entry in config_item.root.items():
file_manager = self.file_manager_by_relative_path[relative_path]
if not (file_manager.path.exists() and file_manager.path.is_file()):
continue

if file_manager.__contains__(entry.keys):
return True

return False


class NoConfigValue:
pass
Expand Down
Loading
Loading