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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ enable = [
forbidden_calls = ["typing.cast"]
```

Adjust the list to match the project, then lint the repository:
Adjust the list to match the project, then check the repository. Rattle uses
the invoked Python environment; install custom-rule dependencies there. For a
uv-managed project, install Rattle as a project dependency and use
`uv run python -m rattle check .`.

```bash
rattle check .
Expand Down
5 changes: 4 additions & 1 deletion docs/guide/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
$ rattle COMMAND [OPTIONS] ...
```

Use `--help` on any command to print its supported options.
Use `--help` on any command to print its supported options. Commands use the
invoked Python environment, which must contain any custom-rule dependencies.
For a uv project with Rattle installed as a project dependency, use
`uv run python -m rattle check .`.

## Check options

Expand Down
8 changes: 4 additions & 4 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ Rattle accepts three selector forms:
concrete rule (`module:rule-name`)
- exact built-in rule names such as `use-f-string`

Rules bundled with Rattle, or available in the environment's `site-packages`,
can be referenced as a rule collection, as a group by their fully-qualified package
name, individually by adding a colon and the kebab-case rule name, or by the
built-in rule name:
Rules bundled with Rattle, or available in the invoked interpreter's
`site-packages`, can be referenced as a rule collection, by their fully-qualified
package name, individually by adding a colon and the kebab-case rule name, or by
the built-in rule name. Rule dependencies must also be available to that interpreter:

```toml
enable = [
Expand Down
9 changes: 7 additions & 2 deletions docs/guide/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ To enable this, install the `lsp` extras, for example:
$ pip install "rattle-lint[lsp]"
```

Then configure an LSP client to launch and connect to the Rattle LSP server.
Configure the LSP client to launch Rattle in a Python environment containing
the `lsp` extras and any custom-rule dependencies. For project-managed
dependencies, use the project environment's Python followed by `-m rattle lsp`.
See the {ref}`lsp_command` command reference for usage details.

Examples of client setup:
Expand Down Expand Up @@ -48,7 +50,10 @@ lspconfig.rattle.setup({})
Rattle can be included as a hook for [pre-commit](https://pre-commit.com).

Once you have [installed pre-commit](https://pre-commit.com/#installation),
add one of Rattle's hooks to `.pre-commit-config.yaml`.
add one of Rattle's hooks to `.pre-commit-config.yaml`. Python hooks run in
pre-commit's environment; add installed rule packages and their dependencies
with `additional_dependencies`. Local rule sources are resolved from the
repository configuration.

To run lint rules on commit:

Expand Down
11 changes: 8 additions & 3 deletions docs/guide/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ Install Rattle from PyPI:
$ pip install rattle-lint
```

Install custom-rule dependencies in the same Python environment as Rattle.
For a uv-managed project, install Rattle as a project dependency and run
`uv run python -m rattle check .`.

By default, Rattle runs with no enabled lint rules. Enable rule collections in
`pyproject.toml`:

Expand Down Expand Up @@ -238,9 +242,10 @@ module path relative to the directory containing the config file. This allows
Rattle to locate and import the rule without installing a plugin into the
environment.

If your custom rule imports other libraries from the repo, those imports must
be relative imports, and they must stay within the same directory tree as the
configuration file.
If your custom rule imports other modules from the repo, use relative imports
within the configuration directory tree, or configure `enable-root-import`.
Third-party rule dependencies must be installed in the Python environment
running Rattle.
```

Once enabled, Rattle can run the new rule against the codebase:
Expand Down
5 changes: 4 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ enable = [
forbidden_calls = ["typing.cast"]
```

Adjust the list to match the project, then lint the repository:
Adjust the list to match the project, then check the repository. Rattle uses
the invoked Python environment; install custom-rule dependencies there. For a
uv-managed project, install Rattle as a project dependency and use
`uv run python -m rattle check .`.

```bash
rattle check .
Expand Down
4 changes: 3 additions & 1 deletion src/rattle/.agents/skills/create-rattle-lint-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ editing configuration.

Enable local rules with import selectors and use canonical kebab-case names in
configuration and CLI commands. Target options at one concrete rule, never a
package or module selector.
package or module selector. Install rule dependencies in the Python environment
running Rattle. For a uv project with Rattle installed as a project dependency,
use `uv run python -m rattle check .`.

Complete this step when `rattle rules <path>` resolves the intended rule,
settings, and path scope without enabling unrelated rules.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ or applying path-specific scope.

## Installation and layout

Install Rattle with:
Install Rattle and any rule dependencies in the same Python environment.
For a uv project with Rattle installed as a project dependency, use
`uv run python -m rattle check .`. To install Rattle directly:

```bash
pip install "rattle-lint>=2.1.0"
Expand Down
9 changes: 1 addition & 8 deletions src/rattle/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@
from rattle.cli.commands.lsp import lsp
from rattle.cli.commands.rules import rules_command
from rattle.cli.commands.validate import validate_command
from rattle.cli.environment import (
_project_rattle_for_reexec,
_reexec_project_rattle,
_version,
)
from rattle.cli.environment import _version
from rattle.cli.options import usage_error
from rattle.config import CollectionError, ConfigError

Expand Down Expand Up @@ -130,9 +126,6 @@ def main(args: list[str] | None = None) -> object:
"""Run the rattle CLI."""
if args is None:
args = sys.argv[1:]
project_rattle = _project_rattle_for_reexec(args)
if project_rattle is not None:
_reexec_project_rattle(project_rattle, args)

return build_app().run(args=_coalesce_repeated_list_options(args))

Expand Down
70 changes: 0 additions & 70 deletions src/rattle/cli/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,11 @@
import logging
import os
import sys
from pathlib import Path

from rattle.__version__ import __version__
from rattle.pyproject import TOMLDecodeError, load_pyproject

UV_REEXEC_ENV = "RATTLE_UV_RUN_REEXEC"
UV_REEXEC_DISABLE_ENV = "RATTLE_NO_UV_RUN_REEXEC"
DEBUG_ENV = "RATTLE_DEBUG"
METRICS_ENV = "RATTLE_METRICS"
UV_PROJECT_MARKERS = ("uv.lock",)
UV_REEXEC_COMMANDS = frozenset({"check", "rules", "validate", "lsp", "explain"})
TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})


Expand All @@ -28,67 +22,3 @@ def _env_flag(name: str) -> bool:
def _configure_logging() -> None:
level = logging.DEBUG if _env_flag(DEBUG_ENV) else logging.WARNING
logging.basicConfig(level=level, stream=sys.stderr, format="%(levelname)s: %(message)s")


def _find_uv_project_root(path: Path) -> Path | None:
for directory in (path, *path.parents):
if any((directory / marker).is_file() for marker in UV_PROJECT_MARKERS):
return directory

pyproject = directory / "pyproject.toml"
if not pyproject.is_file():
continue

try:
data = load_pyproject(pyproject)
except (OSError, UnicodeDecodeError, TOMLDecodeError):
continue

tool = data.get("tool", {})
if isinstance(tool, dict) and "uv" in tool:
return directory

dependency_groups = data.get("dependency-groups")
if isinstance(dependency_groups, dict):
return directory

return None


def _project_environment(root: Path) -> Path:
configured = os.environ.get("UV_PROJECT_ENVIRONMENT")
if not configured:
return root / ".venv"

environment = Path(configured)
return environment if environment.is_absolute() else root / environment


def _project_rattle(root: Path) -> Path | None:
environment = _project_environment(root)
if environment.resolve() == Path(sys.prefix).resolve():
return None

executable = environment / ("Scripts/rattle.exe" if os.name == "nt" else "bin/rattle")
return executable if executable.is_file() else None


def _project_rattle_for_reexec(args: list[str]) -> Path | None:
if os.environ.get(UV_REEXEC_ENV) or os.environ.get(UV_REEXEC_DISABLE_ENV):
return None

command = next((arg for arg in args if not arg.startswith("-")), None)
if command not in UV_REEXEC_COMMANDS:
return None

root = _find_uv_project_root(Path.cwd())
if root is None:
return None

return _project_rattle(root)


def _reexec_project_rattle(executable: Path, args: list[str]) -> None:
env = os.environ.copy()
env[UV_REEXEC_ENV] = "1"
os.execve(executable, [str(executable), *args], env) # noqa: S606
34 changes: 30 additions & 4 deletions src/rattle/rule_loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,35 @@ def local_rule_loader(rule: QualifiedRule) -> Iterator[None]:
sys.modules.pop(key, None)


def _rule_import_error(rule: QualifiedRule, error: ImportError) -> CollectionError:
module = f"{RATTLE_LOCAL_MODULE}{rule.module}" if rule.local else rule.module
if isinstance(error, ModuleNotFoundError) and error.name:
if module == error.name or module.startswith(f"{error.name}."):
reason = f"Rule module {rule.module!r} was not found."
action = "Check the configured rule selector and module location."
else:
missing = error.name
if missing.startswith(f"{RATTLE_LOCAL_MODULE}."):
missing = missing.removeprefix(RATTLE_LOCAL_MODULE)
action = "Check the rule's local import paths."
else:
action = (
"Make the rule's dependencies available in this Python environment, "
"or run Rattle in the environment containing them."
)
reason = f"Dependency module {missing!r} was not found while importing {rule.module!r}."
else:
reason = f"{type(error).__name__} while importing {rule.module!r}."
action = "Check the rule's imports and compatibility in this Python environment."

location = f" from {rule.root}" if rule.local else ""
return CollectionError(
f"could not import rule(s) {rule}{location}\n"
f"{reason}\nPython interpreter: {sys.executable}\n{action}",
rule,
)


def find_rules(rule: QualifiedRule) -> Iterable[type[LintRule]]:
"""
Import the rule's qualified module name and return a list of collected rule classes.
Expand Down Expand Up @@ -270,10 +299,7 @@ def find_rules(rule: QualifiedRule) -> Iterable[type[LintRule]]:
yield module_rules[name]

except ImportError as e:
if rule.local:
raise CollectionError(f"could not import rule(s) {rule} from {rule.root}", rule) from e
else:
raise CollectionError(f"could not import rule(s) {rule}", rule) from e
raise _rule_import_error(rule, e) from e


def walk_module(module: ModuleType) -> dict[str, type[LintRule]]:
Expand Down
89 changes: 89 additions & 0 deletions tests/config/test_rule_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from textwrap import dedent
Expand Down Expand Up @@ -443,3 +444,91 @@ def test_builtin_collections_resolve_rules() -> None:
]:
rules = collect_rules(Config(enable=[QualifiedRule(collection)], python_version=None))
assert len(rules) > 0, f"Collection {collection} resolved 0 rules"


@pytest.mark.parametrize(
("module", "source", "reason", "cause_type"),
[
(
".rules",
None,
"Rule module '.rules' was not found",
ModuleNotFoundError,
),
(
"missing_rule_collection",
None,
"Rule module 'missing_rule_collection' was not found",
ModuleNotFoundError,
),
(
"missing_rule_collection.rules",
None,
"Rule module 'missing_rule_collection.rules' was not found",
ModuleNotFoundError,
),
(
".rules",
"import missing_rule_dependency\n",
"Dependency module 'missing_rule_dependency' was not found",
ModuleNotFoundError,
),
(
".rules",
"from .missing_support import VALUE\n",
"Dependency module '.missing_support' was not found",
ModuleNotFoundError,
),
(
".rules",
'raise ImportError("private import detail")\n',
"ImportError while importing '.rules'",
ImportError,
),
],
)
def test_rule_import_errors_explain_failure_and_preserve_cause(
tmp_path: Path,
module: str,
source: str | None,
reason: str,
cause_type: type[ImportError],
) -> None:
if source is not None:
(tmp_path / "rules.py").write_text(source, encoding="utf-8")
rule = QualifiedRule(module, local="." if module.startswith(".") else None, root=tmp_path)

with pytest.raises(config.CollectionError) as caught:
list(config.find_rules(rule))

error = caught.value
assert error.rule == rule
assert reason in str(error)
assert f"Python interpreter: {sys.executable}" in str(error)
assert type(error.__cause__) is cause_type
assert "private import detail" not in str(error)
if cause_type is ImportError:
assert error.__cause__.args == ("private import detail",)
assert "Dependency module" not in str(error)


@pytest.mark.parametrize("strict", [False, True], ids=["non-strict", "strict"])
def test_rule_import_errors_preserve_registry_loading_policy(
tmp_path: Path, caplog: pytest.LogCaptureFixture, strict: bool
) -> None:
(tmp_path / "rules.py").write_text("import missing_rule_dependency\n", encoding="utf-8")
rule = QualifiedRule(".rules", local=".", root=tmp_path)

if strict:
with pytest.raises(config.CollectionError) as caught:
config.RuleRegistry.load([rule], root=tmp_path, strict=True)
assert caplog.records == []
else:
registry = config.RuleRegistry.load([rule], root=tmp_path, strict=False)
with pytest.raises(config.CollectionError) as caught:
registry.resolve(rule)
assert "Dependency module 'missing_rule_dependency' was not found" in caplog.text
assert registry.imported_rules[rule] == ()

assert "Dependency module 'missing_rule_dependency' was not found" in str(caught.value)
assert isinstance(caught.value.__cause__, ModuleNotFoundError)
Loading
Loading