11from __future__ import annotations
22
3- from abc import abstractmethod
4- from dataclasses import dataclass , field
53from typing import TYPE_CHECKING , Literal , Protocol
64
75from typing_extensions import assert_never
86
97from usethis ._backend .dispatch import get_backend
108from usethis ._backend .uv .detect import is_uv_used
119from 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
1412from usethis ._detect .ci .bitbucket import is_bitbucket_used
1513from usethis ._detect .pre_commit import is_pre_commit_used
16- from usethis ._file .pyproject_toml .io_ import PyprojectTOMLManager
1714from usethis ._integrations .ci .bitbucket import schema as bitbucket_schema
1815from usethis ._integrations .ci .bitbucket .anchor import (
1916 ScriptItemAnchor as BitbucketScriptItemAnchor ,
3229 remove_hook ,
3330)
3431from 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
3734from usethis ._types .backend import BackendEnum
3835from usethis .errors import (
39- FileConfigError ,
4036 NoDefaultToolCommand ,
4137 UnhandledConfigEntryError ,
4238)
4339
4440if 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
20850class 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.
0 commit comments