Skip to content

Commit

Permalink
Add Ruff as supported tool
Browse files Browse the repository at this point in the history
  • Loading branch information
sbrunner committed Oct 25, 2024
1 parent 57b0446 commit 6dba641
Show file tree
Hide file tree
Showing 11 changed files with 185 additions and 15 deletions.
9 changes: 9 additions & 0 deletions .prospector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,12 @@ bandit:
run: true
disable:
- B101 # Use of assert detected.

ruff:
run: true
options:
fix: true
disable:
- E501 # line too long
- S101 # Use of assert detected
- SIM105 # suppressible-exception (slow code)
13 changes: 11 additions & 2 deletions docs/profiles.rst
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ Individual Configuration Options

Each tool can be individually configured with a section beginning with the tool name
(in lowercase). Valid values are ``bandit``, ``dodgy``, ``frosted``, ``mccabe``, ``mypy``, ``pydocstyle``, ``pycodestyle``,
``pyflakes``, ``pylint``, ``pyright``, ``pyroma`` and ``vulture``.
``pyflakes``, ``pylint``, ``pyright``, ``pyroma``, ``vulture`` and ``ruff``.

Enabling and Disabling Tools
............................
Expand Down Expand Up @@ -416,17 +416,26 @@ The available options are:
+----------------+------------------------+----------------------------------------------+
| pyright | venv-path | Directory that contains virtual environments |
+----------------+------------------------+----------------------------------------------+
| ruff | -anything- | Options pass to ruff as argument |
| | | `True` => --<option> |
| | | `False` => ignoring |
| | | <string> => --<option>=<value> |
| | | <list> => --<option>=<comma separated value> |
| | | <dict> => --<option>=<comma separated key> |
| | | if sub value is true |
+----------------+------------------------+----------------------------------------------+

See `bandit options`_ for more details

See `pyright options`_ for more details


See `ruff options`_ for more details

.. _pylint options: https://pylint.readthedocs.io/en/latest/user_guide/run.html
.. _bandit options: https://bandit.readthedocs.io/en/latest/config.html
.. _mypy options: https://mypy.readthedocs.io/en/stable/command_line.html
.. _pyright options: https://microsoft.github.io/pyright/#/command-line
.. _ruff options: https://docs.astral.sh/ruff/configuration/#command-line-interface



Expand Down
11 changes: 11 additions & 0 deletions docs/supported_tools.rst
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,14 @@ To install and use::

pip install prospector[with_pyright]
prospector --with-tool pyright


`Ruff <https://docs.astral.sh/ruff/>`_
``````````````````````````````````````

An extremely fast Python linter, written in Rust.

To install and use::

pip install prospector[with_ruff]
prospector --with-tool ruff
32 changes: 30 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions prospector/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,9 @@ def exit_with_zero_on_success(self) -> bool:
def get_disabled_messages(self, tool_name: str) -> list[str]:
return self.profile.get_disabled_messages(tool_name)

def get_enabled_messages(self, tool_name: str) -> list[str]:
return self.profile.get_enabled_messages(tool_name)

def use_external_config(self, _: Any) -> bool:
# Currently there is only one single global setting for whether to use
# global config, but this could be extended in the future
Expand Down
14 changes: 13 additions & 1 deletion prospector/formatters/pylint.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,24 @@ def render_messages(self) -> list[str]:
# prospector/configuration.py:65: [missing-docstring(missing-docstring), build_default_sources] \
# Missing function docstring

template = "%(path)s:%(line)s: [%(code)s(%(source)s), %(function)s] %(message)s"
template_location = (
"%(path)s"
if message.location.line is None
else "%(path)s:%(line)s"
if message.location.character is None
else "%(path)s:%(line)s:%(character)s"
)
template_code = (
"%(code)s(%(source)s)" if message.location.function is None else "[%(code)s(%(source)s), %(function)s]"
)
template = f"{template_location}: {template_code}: %(message)s"

output.append(
template
% {
"path": self._make_path(message.location),
"line": message.location.line,
"character": message.location.character,
"source": message.source,
"code": message.code,
"function": message.location.function,
Expand Down
20 changes: 11 additions & 9 deletions prospector/profiles/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ def get_disabled_messages(self, tool_name: str) -> list[str]:
enable = getattr(self, tool_name)["enable"]
return list(set(disable) - set(enable))

def get_enabled_messages(self, tool_name: str) -> list[str]:
disable = getattr(self, tool_name)["disable"]
enable = getattr(self, tool_name)["enable"]
return list(set(enable) - set(disable))

def is_tool_enabled(self, name: str) -> bool:
enabled: Optional[bool] = getattr(self, name).get("run")
if enabled is not None:
Expand Down Expand Up @@ -189,24 +194,21 @@ def _ensure_list(value: Any) -> list[Any]:


def _simple_merge_dict(priority: dict[str, Any], base: dict[str, Any]) -> dict[str, Any]:
out = dict(base.items())
out.update(dict(priority.items()))
out = {**base, **priority}
keys = set(priority.keys()) | set(base.keys())
for key in keys:
if isinstance(base.get(key), dict) and isinstance(priority.get(key), dict):
out[key] = _simple_merge_dict(priority[key], base[key])
return out


def _merge_tool_config(priority: dict[str, Any], base: dict[str, Any]) -> dict[str, Any]:
out = dict(base.items())
out = {**base, **priority}

# add options that are missing, but keep existing options from the priority dictionary
# TODO: write a unit test for this :-|
out["options"] = _simple_merge_dict(priority.get("options", {}), base.get("options", {}))

# copy in some basic pieces
for key in ("run", "load-plugins"):
value = priority.get(key, base.get(key))
if value is not None:
out[key] = value

# anything enabled in the 'priority' dict is removed
# from 'disabled' in the base dict and vice versa
base_disabled: list[Any] = base.get("disable") or []
Expand Down
1 change: 1 addition & 0 deletions prospector/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def _optional_tool(
"pyright": _optional_tool("pyright"),
"mypy": _optional_tool("mypy"),
"bandit": _optional_tool("bandit"),
"ruff": _optional_tool("ruff"),
}


Expand Down
75 changes: 75 additions & 0 deletions prospector/tools/ruff/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import json
import subprocess # nosec
from typing import TYPE_CHECKING, Any

from ruff.__main__ import find_ruff_bin

from prospector.finder import FileFinder
from prospector.message import Location, Message
from prospector.tools.base import ToolBase

if TYPE_CHECKING:
from prospector.config import ProspectorConfig


class RuffTool(ToolBase):
def configure(self, prospector_config: "ProspectorConfig", _: Any) -> None:
self.ruff_bin = find_ruff_bin()
self.ruff_args = ["check", "--output-format=json"]

enabled = prospector_config.get_enabled_messages("ruff")
if enabled:
enabled_arg_value = ",".join(enabled)
self.ruff_args.append(f"--select={enabled_arg_value}")
disabled = prospector_config.get_disabled_messages("ruff")
if disabled:
disabled_arg_value = ",".join(disabled)
self.ruff_args.append(f"--ignore={disabled_arg_value}")

options = prospector_config.tool_options("ruff")
for key, value in options.items():
if value is True:
self.ruff_args.append(f"--{key}")
elif value is False:
pass
elif isinstance(value, list):
arg_value = ",".join(value)
self.ruff_args.append(f"--{key}={arg_value}")
# dict is like array but with a dict with true/false value to be able to merge profiles
elif isinstance(value, dict):
arg_value = ",".join(k for k, v in value.items() if v)
self.ruff_args.append(f"--{key}={arg_value}")
else:
self.ruff_args.append(f"--{key}={value}")

def run(self, found_files: FileFinder) -> list[Message]:
print([self.ruff_bin, *self.ruff_args])
messages = []
completed_process = subprocess.run( # noqa: S603
[self.ruff_bin, *self.ruff_args, *found_files.python_modules], capture_output=True
)
for message in json.loads(completed_process.stdout):
sub_message = {}
if message.get("url"):
sub_message["See"] = message["url"]
if message.get("fix") and message["fix"].get("applicability"):
sub_message["Fix applicability"] = message["fix"]["applicability"]
message_str = message.get("message", "")
if sub_message:
message_str += f" [{', '.join(f'{k}: {v}' for k, v in sub_message.items())}]"

messages.append(
Message(
"ruff",
message.get("code") or "unknown",
Location(
message.get("filename") or "unknown",
None,
None,
line=message.get("location", {}).get("row"),
character=message.get("location", {}).get("column"),
),
message_str,
)
)
return messages
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,16 @@ vulture = {version = ">=1.5", optional = true}
mypy = {version = ">=0.600", optional = true}
pyright = {version = ">=1.1.3", optional = true}
pyroma = {version = ">=2.4", optional = true}
ruff = {version = "*", optional = true}

[tool.poetry.extras]
with_bandit = ["bandit"]
with_mypy = ["mypy"]
with_pyright = ["pyright"]
with_pyroma = ["pyroma"]
with_vulture = ["vulture"]
with_everything = ["bandit", "mypy", "pyright", "pyroma", "vulture"]
with_ruff = ["ruff"]
with_everything = ["bandit", "mypy", "pyright", "pyroma", "vulture", "ruff"]

[tool.poetry.dev-dependencies]
coveralls = "^3.3.1"
Expand Down
18 changes: 18 additions & 0 deletions tests/tools/bandit/test_ruff_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from pathlib import Path
from unittest import TestCase
from unittest.mock import patch

from prospector.config import ProspectorConfig
from prospector.finder import FileFinder
from prospector.tools.ruff import RuffTool


class test_ruff_tool:
with patch("sys.argv", []):
config = ProspectorConfig()
ruff_tool = RuffTool()

found_files = FileFinder(Path(__file__).parent / "testpath/testfile.py")
ruff_tool.configure(config, found_files)
messages = ruff_tool.run(found_files)
assert {"S105", "S106", "S107"} == {message.code for message in messages}

0 comments on commit 6dba641

Please sign in to comment.