Skip to content

Commit 0aa2eed

Browse files
Refactor Tool defaults into standalone ToolSpec methods and is_likely_used function (#1351)
* Initial plan * Create standalone is_likely_used function and move ToolSpec to spec module Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> * Add test for ConfigSpec.is_present with unmanaged config item Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com>
1 parent c3cf2b6 commit 0aa2eed

7 files changed

Lines changed: 498 additions & 317 deletions

File tree

.importlinter

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ layers =
5858
all_
5959
impl
6060
base
61+
heuristics
62+
spec
6163
config | pre_commit | rule
6264
exhaustive = true
6365

src/usethis/_tool/base.py

Lines changed: 12 additions & 273 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
11
from __future__ import annotations
22

3-
from abc import abstractmethod
4-
from dataclasses import dataclass, field
53
from typing import TYPE_CHECKING, Literal, Protocol
64

75
from typing_extensions import assert_never
86

97
from usethis._backend.dispatch import get_backend
108
from usethis._backend.uv.detect import is_uv_used
119
from usethis._config import usethis_config
12-
from usethis._console import how_print, tick_print, warn_print
13-
from usethis._deps import add_deps_to_group, is_dep_in_any_group, remove_deps_from_group
10+
from usethis._console import how_print, tick_print
11+
from usethis._deps import add_deps_to_group, remove_deps_from_group
1412
from usethis._detect.ci.bitbucket import is_bitbucket_used
1513
from usethis._detect.pre_commit import is_pre_commit_used
16-
from usethis._file.pyproject_toml.io_ import PyprojectTOMLManager
1714
from usethis._integrations.ci.bitbucket import schema as bitbucket_schema
1815
from usethis._integrations.ci.bitbucket.anchor import (
1916
ScriptItemAnchor as BitbucketScriptItemAnchor,
@@ -32,177 +29,22 @@
3229
remove_hook,
3330
)
3431
from usethis._tool.config import ConfigSpec, NoConfigValue, ensure_managed_file_exists
35-
from usethis._tool.pre_commit import PreCommitConfig
36-
from usethis._tool.rule import RuleConfig
32+
from usethis._tool.heuristics import is_likely_used
33+
from usethis._tool.spec import ToolMeta, ToolSpec
3734
from usethis._types.backend import BackendEnum
3835
from usethis.errors import (
39-
FileConfigError,
4036
NoDefaultToolCommand,
4137
UnhandledConfigEntryError,
4238
)
4339

4440
if TYPE_CHECKING:
4541
from pathlib import Path
4642

47-
from usethis._integrations.pre_commit import schema as pre_commit_schema
4843
from usethis._io import KeyValueFileManager
4944
from usethis._tool.config import ConfigItem, ResolutionT
5045
from usethis._tool.rule import Rule
51-
from usethis._types.deps import Dependency
5246

53-
54-
@dataclass(frozen=True)
55-
class ToolMeta:
56-
"""These are static metadata associated with the tool.
57-
58-
These aspects are independent of the current project.
59-
60-
See the respective `ToolSpec` properties for each attribute for documentation on the
61-
individual attributes.
62-
"""
63-
64-
name: str
65-
managed_files: list[Path] = field(default_factory=list)
66-
# This is more about the inherent definition
67-
rule_config: RuleConfig = field(default_factory=RuleConfig)
68-
url: str | None = None # For documentation purposes
69-
70-
71-
class ToolSpec(Protocol):
72-
@property
73-
@abstractmethod
74-
def meta(self) -> ToolMeta: ...
75-
76-
@property
77-
def name(self) -> str:
78-
"""The name of the tool, for display purposes.
79-
80-
It is assumed that this name is also the name of the Python package associated
81-
with the tool; if not, make sure to override methods which access this property.
82-
83-
This is the display-friendly (e.g. brand compliant) name of the tool, not the
84-
name of a CLI command, etc. Pay mind to the correct capitalization.
85-
86-
For example, the tool named `ty` has a name of `ty`, not `Ty` or `TY`.
87-
Import Linter has a name of `Import Linter`, not `import-linter`.
88-
"""
89-
return self.meta.name
90-
91-
@property
92-
def managed_files(self) -> list[Path]:
93-
"""Get (relative) paths to files managed by (solely) this tool."""
94-
return self.meta.managed_files
95-
96-
@property
97-
def rule_config(self) -> RuleConfig:
98-
"""Get the linter rule configuration associated with this tool.
99-
100-
This is a static, opinionated configuration which usethis uses when adding the
101-
tool (and managing this and other tools when adding and removing, etc.).
102-
"""
103-
return self.meta.rule_config
104-
105-
def preferred_file_manager(self) -> KeyValueFileManager:
106-
"""If there is no currently active config file, this is the preferred one.
107-
108-
This can vary dynamically, since often we will prefer to respect an existing
109-
configuration file if it exists.
110-
"""
111-
return PyprojectTOMLManager()
112-
113-
def raw_cmd(self) -> str:
114-
"""The default command to run the tool.
115-
116-
This should not include a backend-specific prefix, e.g. don't include "uv run".
117-
118-
A non-default implementation should be provided when the tool has a CLI.
119-
120-
This will usually be a static string, but may involve some dynamic inference,
121-
e.g. when determining the source directory for to operate on.
122-
123-
Returns:
124-
The command string.
125-
126-
Raises:
127-
NoDefaultToolCommand: If the tool has no associated command.
128-
129-
Examples:
130-
For codespell: "codespell"
131-
"""
132-
msg = f"{self.name} has no default command."
133-
raise NoDefaultToolCommand(msg)
134-
135-
def dev_deps(self, *, unconditional: bool = False) -> list[Dependency]:
136-
"""The tool's development dependencies.
137-
138-
These should all be considered characteristic of this particular tool.
139-
140-
In general, these can vary dynamically, e.g. based on the versions of Python
141-
supported in the current project.
142-
143-
Args:
144-
unconditional: Whether to return all possible dependencies regardless of
145-
whether they are relevant to the current project.
146-
"""
147-
return []
148-
149-
def test_deps(self, *, unconditional: bool = False) -> list[Dependency]:
150-
"""The tool's test dependencies.
151-
152-
These should all be considered characteristic of this particular tool.
153-
154-
In general, these can vary dynamically, e.g. based on the versions of Python
155-
supported in the current project.
156-
157-
Args:
158-
unconditional: Whether to return all possible dependencies regardless of
159-
whether they are relevant to the current project.
160-
"""
161-
return []
162-
163-
def doc_deps(self, *, unconditional: bool = False) -> list[Dependency]:
164-
"""The tool's documentation dependencies.
165-
166-
These should all be considered characteristic of this particular tool.
167-
168-
In general, these can vary dynamically, e.g. based on the versions of Python
169-
supported in the current project.
170-
171-
Args:
172-
unconditional: Whether to return all possible dependencies regardless of
173-
whether they are relevant to the current project.
174-
"""
175-
return []
176-
177-
def pre_commit_config(self) -> PreCommitConfig:
178-
"""Get the pre-commit configurations for the tool.
179-
180-
In general, this can vary dynamically, e.g. based on whether Ruff is being
181-
configured to be used as a formatter vs. a linter.
182-
"""
183-
return PreCommitConfig(repo_configs=[], inform_how_to_use_on_migrate=False)
184-
185-
def selected_rules(self) -> list[Rule]:
186-
"""Get the rules managed by the tool that are currently selected.
187-
188-
In general, this requires reading config files to look at which rules are
189-
selected for the project.
190-
"""
191-
if not self.rule_config.selected:
192-
return []
193-
194-
raise NotImplementedError
195-
196-
def ignored_rules(self) -> list[Rule]:
197-
"""Get the ignored rules managed by the tool.
198-
199-
In general, this requires reading config files to look at which rules are
200-
ignored for the project.
201-
"""
202-
if not self.rule_config.ignored:
203-
return []
204-
205-
raise NotImplementedError
47+
__all__ = ["Tool", "ToolMeta", "ToolSpec"]
20648

20749

20850
class Tool(ToolSpec, Protocol):
@@ -294,70 +136,13 @@ def config_spec(self) -> ConfigSpec:
294136
def is_used(self) -> bool:
295137
"""Whether the tool is being used in the current project.
296138
297-
Three heuristics are used by default:
298-
1. Whether any of the tool's characteristic dependencies are in the project.
299-
2. Whether any of the tool's characteristic pre-commit hooks are in the project.
300-
3. Whether any of the tool's managed files are in the project.
301-
4. Whether any of the tool's managed config file sections are present.
302-
"""
303-
decode_err_by_name: dict[str, FileConfigError] = {}
304-
_is_used = False
305-
306-
_is_used = any(file.exists() and file.is_file() for file in self.managed_files)
307-
308-
if not _is_used:
309-
try:
310-
_is_used = self.is_declared_as_dep()
311-
except FileConfigError as err:
312-
decode_err_by_name[err.name] = err
313-
314-
if not _is_used:
315-
try:
316-
_is_used = self.is_config_present()
317-
except FileConfigError as err:
318-
decode_err_by_name[err.name] = err
319-
320-
# Do this last since the YAML parsing is expensive.
321-
if not _is_used:
322-
try:
323-
_is_used = self.is_pre_commit_config_present()
324-
except FileConfigError as err:
325-
decode_err_by_name[err.name] = err
326-
327-
for name, decode_err in decode_err_by_name.items():
328-
warn_print(decode_err)
329-
warn_print(
330-
f"Assuming '{name}' contains no evidence of {self.name} being used."
331-
)
332-
333-
return _is_used
334-
335-
def is_declared_as_dep(self) -> bool:
336-
"""Whether the tool is declared as a dependency in the project.
337-
338-
This is inferred based on whether any of the tools characteristic dependencies
339-
are declared in the project.
139+
Four heuristics are used by default:
140+
1. Whether any of the tool's managed files are present.
141+
2. Whether any of the tool's characteristic dependencies are declared.
142+
3. Whether any of the tool's managed config file sections are present.
143+
4. Whether any of the tool's characteristic pre-commit hooks are present.
340144
"""
341-
# N.B. currently doesn't check core dependencies nor extras.
342-
# Only PEP735 dependency groups.
343-
# See https://github.com/usethis-python/usethis-python/issues/809
344-
_is_declared = False
345-
346-
_is_declared = any(
347-
is_dep_in_any_group(dep) for dep in self.dev_deps(unconditional=True)
348-
)
349-
350-
if not _is_declared:
351-
_is_declared = any(
352-
is_dep_in_any_group(dep) for dep in self.test_deps(unconditional=True)
353-
)
354-
355-
if not _is_declared:
356-
_is_declared = any(
357-
is_dep_in_any_group(dep) for dep in self.doc_deps(unconditional=True)
358-
)
359-
360-
return _is_declared
145+
return is_likely_used(self, self.config_spec())
361146

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

380-
def get_pre_commit_repos(
381-
self,
382-
) -> list[pre_commit_schema.LocalRepo | pre_commit_schema.UriRepo]:
383-
"""Get the pre-commit repository definitions for the tool."""
384-
return [c.repo for c in self.pre_commit_config().repo_configs]
385-
386-
def is_pre_commit_config_present(self) -> bool:
387-
"""Whether the tool's pre-commit configuration is present."""
388-
repo_configs = self.get_pre_commit_repos()
389-
390-
for repo_config in repo_configs:
391-
if repo_config.hooks is None:
392-
continue
393-
394-
# Check if any of the hooks are present.
395-
for hook in repo_config.hooks:
396-
if any(
397-
hook_ids_are_equivalent(hook.id, hook_id)
398-
for hook_id in get_hook_ids()
399-
):
400-
return True
401-
402-
return False
403-
404165
def add_pre_commit_config(self) -> None:
405166
"""Add the tool's pre-commit configuration.
406167
@@ -542,29 +303,7 @@ def _get_active_config_file_managers_from_resolution(
542303

543304
def is_config_present(self) -> bool:
544305
"""Whether any of the tool's managed config sections are present."""
545-
return self._is_config_spec_present(self.config_spec())
546-
547-
def _is_config_spec_present(self, config_spec: ConfigSpec) -> bool:
548-
"""Check whether a bespoke config spec is present.
549-
550-
The reason for splitting this method out from the overall `is_config_present`
551-
method is to allow for checking a `config_spec` different from the main
552-
config_spec (e.g. a subset of it to distinguish between two different aspects
553-
of a tool, e.g. Ruff's linter vs. formatter configuration sections).
554-
"""
555-
for config_item in config_spec.config_items:
556-
if not config_item.managed:
557-
continue
558-
559-
for relative_path, entry in config_item.root.items():
560-
file_manager = config_spec.file_manager_by_relative_path[relative_path]
561-
if not (file_manager.path.exists() and file_manager.path.is_file()):
562-
continue
563-
564-
if file_manager.__contains__(entry.keys):
565-
return True
566-
567-
return False
306+
return self.config_spec().is_present()
568307

569308
def add_configs(self) -> None:
570309
"""Add the tool's configuration sections.

src/usethis/_tool/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,22 @@ def empty(cls) -> Self:
6767
file_manager_by_relative_path={}, resolution="first", config_items=[]
6868
)
6969

70+
def is_present(self) -> bool:
71+
"""Check whether any managed configuration in this spec is present on disk."""
72+
for config_item in self.config_items:
73+
if not config_item.managed:
74+
continue
75+
76+
for relative_path, entry in config_item.root.items():
77+
file_manager = self.file_manager_by_relative_path[relative_path]
78+
if not (file_manager.path.exists() and file_manager.path.is_file()):
79+
continue
80+
81+
if file_manager.__contains__(entry.keys):
82+
return True
83+
84+
return False
85+
7086

7187
class NoConfigValue:
7288
pass

0 commit comments

Comments
 (0)