From 3368debe02203f200b4a898eddd8074da6f75cd3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 05:28:35 +0000 Subject: [PATCH 1/3] Initial plan From 0b960c5627bfe66b777451fbda48e429f884f9fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 05:39:35 +0000 Subject: [PATCH 2/3] Create standalone is_likely_used function and move ToolSpec to spec module Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> --- .importlinter | 2 + src/usethis/_tool/base.py | 285 ++----------------------- src/usethis/_tool/config.py | 16 ++ src/usethis/_tool/heuristics.py | 59 +++++ src/usethis/_tool/impl/ruff.py | 84 ++++---- src/usethis/_tool/spec.py | 225 +++++++++++++++++++ tests/usethis/_tool/test_heuristics.py | 113 ++++++++++ 7 files changed, 467 insertions(+), 317 deletions(-) create mode 100644 src/usethis/_tool/heuristics.py create mode 100644 src/usethis/_tool/spec.py create mode 100644 tests/usethis/_tool/test_heuristics.py diff --git a/.importlinter b/.importlinter index e922b60a..82794a2a 100644 --- a/.importlinter +++ b/.importlinter @@ -58,6 +58,8 @@ layers = all_ impl base + heuristics + spec config | pre_commit | rule exhaustive = true diff --git a/src/usethis/_tool/base.py b/src/usethis/_tool/base.py index 63af8af1..279e1952 100644 --- a/src/usethis/_tool/base.py +++ b/src/usethis/_tool/base.py @@ -1,7 +1,5 @@ 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 @@ -9,11 +7,10 @@ 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, @@ -32,11 +29,10 @@ 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, ) @@ -44,165 +40,11 @@ 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): @@ -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") @@ -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. @@ -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. diff --git a/src/usethis/_tool/config.py b/src/usethis/_tool/config.py index 8d97424f..768942e1 100644 --- a/src/usethis/_tool/config.py +++ b/src/usethis/_tool/config.py @@ -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 diff --git a/src/usethis/_tool/heuristics.py b/src/usethis/_tool/heuristics.py new file mode 100644 index 00000000..b933cc36 --- /dev/null +++ b/src/usethis/_tool/heuristics.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from usethis._console import warn_print +from usethis.errors import FileConfigError + +if TYPE_CHECKING: + from usethis._tool.config import ConfigSpec + from usethis._tool.spec import ToolSpec + + +def is_likely_used(tool_spec: ToolSpec, config_spec: ConfigSpec) -> bool: + """Determine whether a tool is likely used in the current project. + + Four heuristics are used: + 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. + + Args: + tool_spec: The tool specification to check. + config_spec: The configuration specification for the tool. + + Returns: + True if the tool is likely used, False otherwise. + """ + decode_err_by_name: dict[str, FileConfigError] = {} + _is_used = False + + _is_used = any(file.exists() and file.is_file() for file in tool_spec.managed_files) + + if not _is_used: + try: + _is_used = tool_spec.is_declared_as_dep() + except FileConfigError as err: + decode_err_by_name[err.name] = err + + if not _is_used: + try: + _is_used = config_spec.is_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 = tool_spec.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 {tool_spec.name} being used." + ) + + return _is_used diff --git a/src/usethis/_tool/impl/ruff.py b/src/usethis/_tool/impl/ruff.py index 03077cb3..1b66f825 100644 --- a/src/usethis/_tool/impl/ruff.py +++ b/src/usethis/_tool/impl/ruff.py @@ -528,28 +528,26 @@ def is_linter_used(self) -> bool: ) def is_linter_config_present(self) -> bool: - return self._is_config_spec_present( - ConfigSpec.from_flat( - file_managers=[ - DotRuffTOMLManager(), - RuffTOMLManager(), - PyprojectTOMLManager(), - ], - resolution="first", - config_items=[ - ConfigItem( - description="Linter Config", - root={ - Path(".ruff.toml"): ConfigEntry(keys=["lint"]), - Path("ruff.toml"): ConfigEntry(keys=["lint"]), - Path("pyproject.toml"): ConfigEntry( - keys=["tool", "ruff", "lint"] - ), - }, - ), - ], - ) - ) + return ConfigSpec.from_flat( + file_managers=[ + DotRuffTOMLManager(), + RuffTOMLManager(), + PyprojectTOMLManager(), + ], + resolution="first", + config_items=[ + ConfigItem( + description="Linter Config", + root={ + Path(".ruff.toml"): ConfigEntry(keys=["lint"]), + Path("ruff.toml"): ConfigEntry(keys=["lint"]), + Path("pyproject.toml"): ConfigEntry( + keys=["tool", "ruff", "lint"] + ), + }, + ), + ], + ).is_present() def is_formatter_used(self) -> bool: """Check if the formatter is used in the project. @@ -570,28 +568,26 @@ def is_formatter_used(self) -> bool: ) def is_formatter_config_present(self) -> bool: - return self._is_config_spec_present( - ConfigSpec.from_flat( - file_managers=[ - DotRuffTOMLManager(), - RuffTOMLManager(), - PyprojectTOMLManager(), - ], - resolution="first", - config_items=[ - ConfigItem( - description="Formatter Config", - root={ - Path(".ruff.toml"): ConfigEntry(keys=["format"]), - Path("ruff.toml"): ConfigEntry(keys=["format"]), - Path("pyproject.toml"): ConfigEntry( - keys=["tool", "ruff", "format"] - ), - }, - ), - ], - ) - ) + return ConfigSpec.from_flat( + file_managers=[ + DotRuffTOMLManager(), + RuffTOMLManager(), + PyprojectTOMLManager(), + ], + resolution="first", + config_items=[ + ConfigItem( + description="Formatter Config", + root={ + Path(".ruff.toml"): ConfigEntry(keys=["format"]), + Path("ruff.toml"): ConfigEntry(keys=["format"]), + Path("pyproject.toml"): ConfigEntry( + keys=["tool", "ruff", "format"] + ), + }, + ), + ], + ).is_present() def is_no_subtool_config_present(self) -> bool: """Check if no subtool config is present.""" diff --git a/src/usethis/_tool/spec.py b/src/usethis/_tool/spec.py new file mode 100644 index 00000000..0ae5a4a3 --- /dev/null +++ b/src/usethis/_tool/spec.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from abc import abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Protocol + +from usethis._deps import is_dep_in_any_group +from usethis._file.pyproject_toml.io_ import PyprojectTOMLManager +from usethis._integrations.pre_commit.hooks import get_hook_ids, hook_ids_are_equivalent +from usethis._tool.pre_commit import PreCommitConfig +from usethis._tool.rule import RuleConfig +from usethis.errors import NoDefaultToolCommand + +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.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 + + 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. + """ + # 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 + + 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 diff --git a/tests/usethis/_tool/test_heuristics.py b/tests/usethis/_tool/test_heuristics.py new file mode 100644 index 00000000..297526b6 --- /dev/null +++ b/tests/usethis/_tool/test_heuristics.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from pathlib import Path + +from usethis._console import how_print +from usethis._file.pyproject_toml.io_ import PyprojectTOMLManager +from usethis._integrations.pre_commit import schema +from usethis._test import change_cwd +from usethis._tool.base import Tool, ToolMeta +from usethis._tool.config import ConfigEntry, ConfigItem, ConfigSpec +from usethis._tool.heuristics import is_likely_used +from usethis._tool.pre_commit import PreCommitConfig + + +class SimpleTool(Tool): + """Minimal tool for testing is_likely_used.""" + + @property + def meta(self) -> ToolMeta: + return ToolMeta(name="simple_tool", managed_files=[Path("simple_tool.cfg")]) + + def print_how_to_use(self) -> None: + how_print("How to use simple_tool") + + def pre_commit_config(self) -> PreCommitConfig: + return PreCommitConfig.from_single_repo( + schema.UriRepo( + repo="https://example.com/simple-tool", + hooks=[schema.HookDefinition(id="simple-tool-hook")], + ), + requires_venv=False, + ) + + def config_spec(self) -> ConfigSpec: + return ConfigSpec( + file_manager_by_relative_path={ + Path("pyproject.toml"): PyprojectTOMLManager(), + }, + resolution="first", + config_items=[ + ConfigItem( + root={ + Path("pyproject.toml"): ConfigEntry( + keys=["tool", "simple_tool"], + get_value=lambda: {"key": "value"}, + ) + } + ) + ], + ) + + +class TestIsLikelyUsed: + def test_managed_file_present(self, tmp_path: Path): + # Arrange + tool = SimpleTool() + with change_cwd(tmp_path): + (tmp_path / "simple_tool.cfg").touch() + + # Act + result = is_likely_used(tool, tool.config_spec()) + + # Assert + assert result + + def test_managed_file_is_dir(self, tmp_path: Path): + # Arrange + tool = SimpleTool() + with change_cwd(tmp_path): + (tmp_path / "simple_tool.cfg").mkdir() + + # Act + result = is_likely_used(tool, tool.config_spec()) + + # Assert + assert not result + + def test_config_spec_present(self, uv_init_dir: Path): + # Arrange + tool = SimpleTool() + with change_cwd(uv_init_dir), PyprojectTOMLManager(): + PyprojectTOMLManager().set_value( + keys=["tool", "simple_tool", "key"], value="value" + ) + + # Act + result = is_likely_used(tool, tool.config_spec()) + + # Assert + assert result + + def test_nothing_present(self, uv_init_dir: Path): + # Arrange + tool = SimpleTool() + + # Act + with change_cwd(uv_init_dir), PyprojectTOMLManager(): + result = is_likely_used(tool, tool.config_spec()) + + # Assert + assert not result + + def test_empty_config_spec(self, tmp_path: Path): + # Arrange + tool = SimpleTool() + empty_spec = ConfigSpec.empty() + + # Act + with change_cwd(tmp_path): + result = is_likely_used(tool, empty_spec) + + # Assert + assert not result From a2e09d536122546f831c5766c056d1ceffd16398 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 06:26:25 +0000 Subject: [PATCH 3/3] Add test for ConfigSpec.is_present with unmanaged config item Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> --- tests/usethis/_tool/test_heuristics.py | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/usethis/_tool/test_heuristics.py b/tests/usethis/_tool/test_heuristics.py index 297526b6..d35797cd 100644 --- a/tests/usethis/_tool/test_heuristics.py +++ b/tests/usethis/_tool/test_heuristics.py @@ -111,3 +111,34 @@ def test_empty_config_spec(self, tmp_path: Path): # Assert assert not result + + +class TestConfigSpecIsPresent: + def test_unmanaged_item_not_detected(self, uv_init_dir: Path): + # An unmanaged config item is skipped even when its keys exist on disk. + with change_cwd(uv_init_dir), PyprojectTOMLManager(): + PyprojectTOMLManager().set_value( + keys=["tool", "simple_tool", "key"], value="value" + ) + spec = ConfigSpec( + file_manager_by_relative_path={ + Path("pyproject.toml"): PyprojectTOMLManager(), + }, + resolution="first", + config_items=[ + ConfigItem( + managed=False, + root={ + Path("pyproject.toml"): ConfigEntry( + keys=["tool", "simple_tool"], + ) + }, + ) + ], + ) + + # Act + result = spec.is_present() + + # Assert + assert not result