habit-hooks-python 1.3.1__tar.gz → 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/PKG-INFO +1 -2
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/README.md +0 -1
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/docs/python-plugin.spec.md +15 -14
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/pyproject.toml +1 -1
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/src/habit_hooks_python/config.toml +0 -2
- habit_hooks_python-1.4.0/src/habit_hooks_python/sensors/deptry.toml +1 -0
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/src/habit_hooks_python/sensors/deptry_sensor.py +18 -18
- habit_hooks_python-1.4.0/src/habit_hooks_python/sensors/ruff.toml +1 -0
- habit_hooks_python-1.4.0/src/habit_hooks_python/sensors/ruff_sensor.py +114 -0
- habit_hooks_python-1.4.0/tests/conftest.py +56 -0
- habit_hooks_python-1.4.0/tests/test_a_crashing_ruff_fails_the_run.py +55 -0
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/tests/test_a_project_declaring_no_dependencies_is_clean.py +21 -13
- habit_hooks_python-1.4.0/tests/test_a_sensor_spawns_the_tool_it_was_handed.py +78 -0
- habit_hooks_python-1.4.0/tests/test_the_ruff_pipeline_maps_codes_to_smells.py +109 -0
- habit_hooks_python-1.4.0/tests/test_the_ruff_sensor_runs_the_real_tool.py +67 -0
- habit_hooks_python-1.3.1/src/habit_hooks_python/sensors/deptry.toml +0 -1
- habit_hooks_python-1.3.1/src/habit_hooks_python/sensors/ruff.toml +0 -28
- habit_hooks_python-1.3.1/tests/test_a_deptry_nobody_installed_is_named.py +0 -35
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/.gitignore +0 -0
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/src/habit_hooks_python/__init__.py +0 -0
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/src/habit_hooks_python/guides/high-complexity.md +0 -0
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/src/habit_hooks_python/guides/swallowed-exception.md +0 -0
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/src/habit_hooks_python/ruff.toml +0 -0
- {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0}/tests/test_the_python_files_leave_installed_packages_alone.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: habit-hooks-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: The Python Habit Hooks plugin
|
|
5
5
|
Project-URL: Homepage, https://habit-hooks.com
|
|
6
6
|
Project-URL: Repository, https://github.com/habit-hooks/habit-hooks
|
|
@@ -48,6 +48,5 @@ Installing a plugin does not switch it on — it has to be named in
|
|
|
48
48
|
|
|
49
49
|
- [`ruff`](https://docs.astral.sh/ruff/), [`deptry`](https://github.com/fpgmaas/deptry) —
|
|
50
50
|
`pip install ruff deptry`
|
|
51
|
-
- `jq` — from your system package manager (e.g. `brew install jq`)
|
|
52
51
|
|
|
53
52
|
Part of [habit-hooks](https://github.com/habit-hooks/habit-hooks).
|
|
@@ -24,6 +24,5 @@ Installing a plugin does not switch it on — it has to be named in
|
|
|
24
24
|
|
|
25
25
|
- [`ruff`](https://docs.astral.sh/ruff/), [`deptry`](https://github.com/fpgmaas/deptry) —
|
|
26
26
|
`pip install ruff deptry`
|
|
27
|
-
- `jq` — from your system package manager (e.g. `brew install jq`)
|
|
28
27
|
|
|
29
28
|
Part of [habit-hooks](https://github.com/habit-hooks/habit-hooks).
|
|
@@ -12,10 +12,11 @@ plugins = ["python"]
|
|
|
12
12
|
|
|
13
13
|
## ruff adapter maps rule IDs to canonical smells
|
|
14
14
|
|
|
15
|
-
The `ruff` adapter selects `C901,PLR0913,PLR0915,F841,F401,BLE001` and a
|
|
16
|
-
|
|
17
|
-
stamping `source: "ruff:<code>"` on each
|
|
18
|
-
`max-args = 3`, so a four-argument
|
|
15
|
+
The `ruff` adapter selects `C901,PLR0913,PLR0915,F841,F401,BLE001` and a
|
|
16
|
+
Python helper beside the sensor spec (`ruff_sensor.py`) groups the flat
|
|
17
|
+
output into one finding per smell, stamping `source: "ruff:<code>"` on each
|
|
18
|
+
issue. The shipped `ruff.toml` carries `max-args = 3`, so a four-argument
|
|
19
|
+
function trips `PLR0913`.
|
|
19
20
|
|
|
20
21
|
📄ruff.toml @plugins/python/src/habit_hooks_python/ruff.toml
|
|
21
22
|
|
|
@@ -336,17 +337,18 @@ habit-sensors --all 2>&1 >/dev/null | sed -n 1p
|
|
|
336
337
|
|
|
337
338
|
🖥️ ❌ 1
|
|
338
339
|
```text
|
|
339
|
-
habit-sensors: sensor 'deptry' failed: ${python} ${dir}/deptry_sensor.py
|
|
340
|
+
habit-sensors: sensor 'deptry' failed: '${python}' '${dir}/deptry_sensor.py' '${detector:deptry}'
|
|
340
341
|
```
|
|
341
342
|
|
|
342
343
|
## A crashing ruff fails the run, never reports clean
|
|
343
344
|
|
|
344
|
-
The `ruff` sensor
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
exits 1, carrying only the reserved
|
|
345
|
+
The `ruff` sensor runs a Python helper (`ruff_sensor.py`) rather than piping
|
|
346
|
+
the tool into `jq`. A crashing `ruff` (here, a malformed `ruff.toml` that makes
|
|
347
|
+
the tool exit non-zero) is a genuine crash, not a violation: ruff's own
|
|
348
|
+
contract is 0 clean, 1 violations found, and the helper treats any other exit
|
|
349
|
+
as broken, exiting 2 itself with nothing on stdout. `habit-sensors` then
|
|
350
|
+
raises, names the sensor on stderr, and exits 1, carrying only the reserved
|
|
351
|
+
`incomplete-run` marker on stdout
|
|
350
352
|
([habit-sensors.spec.md](../../../docs/habit-sensors.spec.md)).
|
|
351
353
|
|
|
352
354
|
📄.habit-hooks/config.toml
|
|
@@ -376,8 +378,7 @@ habit-sensors --all | jq -c '[.[].smell]'
|
|
|
376
378
|
["incomplete-run"]
|
|
377
379
|
```
|
|
378
380
|
|
|
379
|
-
The notice quotes the sensor's whole command
|
|
380
|
-
line shows only where that command starts — and then ruff's own diagnosis, which
|
|
381
|
+
The notice quotes the sensor's whole command, then ruff's own diagnosis, which
|
|
381
382
|
names the absolute path of the config it could not parse. Only the line naming
|
|
382
383
|
the sensor is stable enough to assert.
|
|
383
384
|
|
|
@@ -387,5 +388,5 @@ habit-sensors --all 2>&1 >/dev/null | sed -n 1p
|
|
|
387
388
|
|
|
388
389
|
🖥️ ❌ 1
|
|
389
390
|
```text
|
|
390
|
-
habit-sensors: sensor 'ruff' failed:
|
|
391
|
+
habit-sensors: sensor 'ruff' failed: '${python}' '${dir}/ruff_sensor.py' '${detector:ruff}' '${files}'
|
|
391
392
|
```
|
|
@@ -8,9 +8,7 @@ files = ["**/*.py", "!**/site-packages/**", "!**/.venv/**", "!**/venv/**"]
|
|
|
8
8
|
sensors = ["ruff", "deptry"]
|
|
9
9
|
transformers = []
|
|
10
10
|
|
|
11
|
-
# The ruff sensor pipes its JSON through jq, so a python-only project needs it.
|
|
12
11
|
detectors = [
|
|
13
12
|
{ name = "ruff", kind = "command", install = "pip install ruff" },
|
|
14
13
|
{ name = "deptry", kind = "command", install = "pip install deptry" },
|
|
15
|
-
{ name = "jq", kind = "command", install = "brew install jq" },
|
|
16
14
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
argv = ["${python}", "${dir}/deptry_sensor.py", "${detector:deptry}"]
|
|
@@ -14,23 +14,22 @@ import tempfile
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def run_deptry(report: Path) -> subprocess.CompletedProcess[str]:
|
|
18
|
-
"""What deptry said,
|
|
19
|
-
|
|
20
|
-
``
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
def run_deptry(deptry: str, report: Path) -> subprocess.CompletedProcess[str]:
|
|
18
|
+
"""What deptry said, spawned as the file this sensor was handed for it.
|
|
19
|
+
|
|
20
|
+
``sensors/deptry.toml`` names ``${detector:deptry}``, so the run resolves
|
|
21
|
+
deptry to a file before this helper starts and passes it as the first
|
|
22
|
+
argument — a console script the project's own venv installed, which
|
|
23
|
+
habit-hooks' venv may not hold at all and Windows spells ``deptry.exe``. A
|
|
24
|
+
deptry nobody installed never reaches here: the run answers for it as the
|
|
25
|
+
missing command it is, in one line rather than a traceback (#114).
|
|
26
26
|
"""
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
27
|
+
return subprocess.run(
|
|
28
|
+
[deptry, ".", "--json-output", str(report)],
|
|
29
|
+
capture_output=True,
|
|
30
|
+
encoding="utf-8",
|
|
31
|
+
errors="replace",
|
|
32
|
+
)
|
|
34
33
|
|
|
35
34
|
|
|
36
35
|
def deptry_crashed(result: subprocess.CompletedProcess[str], report: Path) -> bool:
|
|
@@ -52,7 +51,7 @@ def deptry_found_no_declaration(result: subprocess.CompletedProcess[str]) -> boo
|
|
|
52
51
|
|
|
53
52
|
|
|
54
53
|
def unused_dependencies(report: Path) -> list[dict]:
|
|
55
|
-
entries = json.loads(report.read_text())
|
|
54
|
+
entries = json.loads(report.read_text(encoding="utf-8"))
|
|
56
55
|
return [entry for entry in entries if entry["error"]["code"] == "DEP002"]
|
|
57
56
|
|
|
58
57
|
|
|
@@ -81,9 +80,10 @@ def findings(entries: list[dict]) -> list[dict]:
|
|
|
81
80
|
|
|
82
81
|
|
|
83
82
|
def main() -> int:
|
|
83
|
+
deptry = sys.argv[1]
|
|
84
84
|
with tempfile.TemporaryDirectory() as tmp:
|
|
85
85
|
report = Path(tmp) / "deptry-report.json"
|
|
86
|
-
result = run_deptry(report)
|
|
86
|
+
result = run_deptry(deptry, report)
|
|
87
87
|
if deptry_crashed(result, report):
|
|
88
88
|
if deptry_found_no_declaration(result):
|
|
89
89
|
print(json.dumps([]))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
argv = ["${python}", "${dir}/ruff_sensor.py", "${detector:ruff}", "${files}"]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Run ruff and print canonical findings, mapped from rule code to smell.
|
|
2
|
+
|
|
3
|
+
ruff's own JSON is one entry per violation; this groups them by mapped smell
|
|
4
|
+
and shapes each into the canonical finding — the job the old
|
|
5
|
+
``ruff | jq`` pipeline did, without needing ``jq`` installed (dropping the
|
|
6
|
+
plugin's only other declared detector, and the shell it took to pipe through
|
|
7
|
+
it).
|
|
8
|
+
|
|
9
|
+
An entry's ``code`` not in :data:`CODE_SMELLS` has no smell to report it
|
|
10
|
+
under (a code outside ``--select`` besides ``invalid-syntax``, which ruff
|
|
11
|
+
reports regardless of ``--select``). It is dropped rather than crashed on or
|
|
12
|
+
forwarded under ruff's own name — the same choice the knip sensor makes for a
|
|
13
|
+
key with no smell mapped (see "A sensor emits vocabulary smells only" in
|
|
14
|
+
CLAUDE.md): a key nobody catalogued has no guide and no severity, so
|
|
15
|
+
forwarding it can only fail a run and decline to explain why. Dropping is
|
|
16
|
+
also safe *by construction* rather than by ``--select`` pinning the set, which
|
|
17
|
+
is what the old jq filter depended on (indexing a jq object with a key that
|
|
18
|
+
maps to nothing is a jq crash, not a miss — issue #83).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
SELECTED_CODES = "C901,PLR0913,PLR0915,F841,F401,BLE001"
|
|
28
|
+
|
|
29
|
+
CODE_SMELLS = {
|
|
30
|
+
"C901": "high-complexity",
|
|
31
|
+
"PLR0913": "too-many-parameters",
|
|
32
|
+
"PLR0915": "oversized-function",
|
|
33
|
+
"F841": "unused-variable",
|
|
34
|
+
"F401": "unused-import",
|
|
35
|
+
"BLE001": "swallowed-exception",
|
|
36
|
+
"invalid-syntax": "parse-error",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# ruff's own contract: 0 is clean, 1 is "violations found" — both trustworthy.
|
|
40
|
+
# Anything else is ruff never having produced a real report (bad config, a
|
|
41
|
+
# missing file, an internal error), the same distinction part_output.py's
|
|
42
|
+
# TOOL_EXIT_CODES draws for a part run directly.
|
|
43
|
+
TOOL_EXIT_CODES = (0, 1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_ruff(ruff: str, files: list[str]) -> subprocess.CompletedProcess[str]:
|
|
47
|
+
"""What ruff said, spawned as the file this sensor was handed for it.
|
|
48
|
+
|
|
49
|
+
``sensors/ruff.toml`` names ``${detector:ruff}``, so the run resolves ruff
|
|
50
|
+
to a file before this helper starts and passes it as the first argument —
|
|
51
|
+
the ``ruff.exe`` a bare ``["ruff", ...]`` spawn misses on Windows. A ruff
|
|
52
|
+
nobody installed never reaches here at all: the run answers for it as the
|
|
53
|
+
missing command it is, in one line rather than a traceback (#114).
|
|
54
|
+
"""
|
|
55
|
+
return subprocess.run(
|
|
56
|
+
[ruff, "check", "--output-format=json", f"--select={SELECTED_CODES}", *files],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
errors="replace",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ruff_crashed(result: subprocess.CompletedProcess[str]) -> bool:
|
|
64
|
+
return result.returncode not in TOOL_EXIT_CODES
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def violations(result: subprocess.CompletedProcess[str]) -> list[dict]:
|
|
68
|
+
text = result.stdout.strip()
|
|
69
|
+
return json.loads(text) if text else []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def issue(entry: dict) -> dict:
|
|
73
|
+
return {
|
|
74
|
+
"key": entry["filename"],
|
|
75
|
+
"details": {
|
|
76
|
+
"file": entry["filename"],
|
|
77
|
+
"line": entry["location"]["row"],
|
|
78
|
+
"column": entry["location"]["column"],
|
|
79
|
+
"message": entry["message"],
|
|
80
|
+
"source": "ruff:" + entry["code"],
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def findings(entries: list[dict]) -> list[dict]:
|
|
86
|
+
by_smell: dict[str, list[dict]] = {}
|
|
87
|
+
for entry in entries:
|
|
88
|
+
smell = CODE_SMELLS.get(entry["code"])
|
|
89
|
+
if smell is None:
|
|
90
|
+
continue
|
|
91
|
+
by_smell.setdefault(smell, []).append(entry)
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
"smell": smell,
|
|
95
|
+
"details": {},
|
|
96
|
+
"issues": [issue(entry) for entry in by_smell[smell]],
|
|
97
|
+
}
|
|
98
|
+
for smell in sorted(by_smell)
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main() -> int:
|
|
103
|
+
ruff = sys.argv[1]
|
|
104
|
+
files = sys.argv[2:]
|
|
105
|
+
result = run_ruff(ruff, files)
|
|
106
|
+
if ruff_crashed(result):
|
|
107
|
+
sys.stderr.write(result.stderr)
|
|
108
|
+
return 2
|
|
109
|
+
print(json.dumps(findings(violations(result))))
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
sys.exit(main())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""What every test here needs of the run it stands in for.
|
|
2
|
+
|
|
3
|
+
**A helper loads as a loose script.** The sensor specs spell
|
|
4
|
+
``${python} ${dir}/<helper>.py``, so the interpreter puts the helper's own
|
|
5
|
+
directory first on ``sys.path``, and a unit test does the same rather than
|
|
6
|
+
reaching the code as ``habit_hooks_python.sensors.ruff_sensor`` — a load path no
|
|
7
|
+
run ever takes (see "A plugin helper imports its neighbours as top-level
|
|
8
|
+
modules" in CLAUDE.md).
|
|
9
|
+
|
|
10
|
+
**A helper is handed its tool as a file.** The specs name theirs with
|
|
11
|
+
``${detector:<tool>}``, which the run resolves against the project's own bins
|
|
12
|
+
before the helper is spawned (``project_paths.tool_executable``). A test
|
|
13
|
+
spawning a helper directly stands in for the run, so it asks that same question
|
|
14
|
+
and hands over a real file rather than a name. Absent is a failure rather than a
|
|
15
|
+
skip: ``uv sync`` brings both, so a machine without one is a suite that has
|
|
16
|
+
quietly stopped gating.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
SENSORS = (
|
|
28
|
+
Path(__file__).resolve().parents[1] / "src" / "habit_hooks_python" / "sensors"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, str(SENSORS))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(scope="session")
|
|
35
|
+
def ruff() -> str:
|
|
36
|
+
"""The file this machine runs ruff by, as the sensor's first argument."""
|
|
37
|
+
return _installed("ruff")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture(scope="session")
|
|
41
|
+
def deptry() -> str:
|
|
42
|
+
"""The file this machine runs deptry by, as the sensor's first argument."""
|
|
43
|
+
return _installed("deptry")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _installed(tool: str) -> str:
|
|
47
|
+
"""The file this machine runs ``tool`` by.
|
|
48
|
+
|
|
49
|
+
``shutil.which`` rather than the name on disk: a console script is
|
|
50
|
+
``<tool>.exe`` in a Windows venv, which a lookup finds and a spawn handed
|
|
51
|
+
the bare name cannot reach.
|
|
52
|
+
"""
|
|
53
|
+
found = shutil.which(tool)
|
|
54
|
+
if found is None:
|
|
55
|
+
pytest.fail(f"{tool} is not on PATH — run 'uv sync'")
|
|
56
|
+
return found
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""A broken ``ruff`` must not read as clean — it must fail the sensor.
|
|
2
|
+
|
|
3
|
+
``ruff_crashed`` trusts exactly ruff's own contract (0 clean, 1 violations
|
|
4
|
+
found); the boundary is exercised directly here, and
|
|
5
|
+
``test_a_broken_ruff_toml_fails_the_run`` proves the whole helper honours it
|
|
6
|
+
against the real tool, mirroring
|
|
7
|
+
``test_deptry_failing_another_way_still_fails_the_run``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
from ruff_sensor import ruff_crashed
|
|
18
|
+
|
|
19
|
+
SENSOR = (
|
|
20
|
+
Path(__file__).resolve().parents[1]
|
|
21
|
+
/ "src/habit_hooks_python/sensors/ruff_sensor.py"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _result(returncode: int) -> subprocess.CompletedProcess[str]:
|
|
26
|
+
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout="", stderr="")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize("returncode", [0, 1])
|
|
30
|
+
def test_ruff_s_own_exit_codes_are_trusted(returncode: int) -> None:
|
|
31
|
+
assert ruff_crashed(_result(returncode)) is False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("returncode", [2, 127, -9])
|
|
35
|
+
def test_any_other_exit_code_is_a_crash(returncode: int) -> None:
|
|
36
|
+
assert ruff_crashed(_result(returncode)) is True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_a_broken_ruff_toml_fails_the_run(tmp_path: Path, ruff: str) -> None:
|
|
40
|
+
(tmp_path / "ruff.toml").write_text(
|
|
41
|
+
"this is not = valid ruff config\n", encoding="utf-8"
|
|
42
|
+
)
|
|
43
|
+
(tmp_path / "app.py").write_text("import os\n", encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
[sys.executable, str(SENSOR), ruff, "app.py"],
|
|
47
|
+
cwd=tmp_path,
|
|
48
|
+
capture_output=True,
|
|
49
|
+
encoding="utf-8",
|
|
50
|
+
errors="replace",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
assert result.returncode == 2
|
|
54
|
+
assert result.stdout.strip() == ""
|
|
55
|
+
assert "ruff.toml" in result.stderr
|
|
@@ -24,14 +24,17 @@ SENSOR = (
|
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def test_a_project_declaring_no_dependencies_is_a_clean_run(
|
|
28
|
-
|
|
27
|
+
def test_a_project_declaring_no_dependencies_is_a_clean_run(
|
|
28
|
+
tmp_path: Path, deptry: str
|
|
29
|
+
) -> None:
|
|
30
|
+
(tmp_path / "app.py").write_text("import os\n", encoding="utf-8")
|
|
29
31
|
|
|
30
32
|
result = subprocess.run(
|
|
31
|
-
[sys.executable, str(SENSOR)],
|
|
33
|
+
[sys.executable, str(SENSOR), deptry],
|
|
32
34
|
cwd=tmp_path,
|
|
33
35
|
capture_output=True,
|
|
34
|
-
|
|
36
|
+
encoding="utf-8",
|
|
37
|
+
errors="replace",
|
|
35
38
|
)
|
|
36
39
|
|
|
37
40
|
assert result.returncode == 0
|
|
@@ -40,34 +43,39 @@ def test_a_project_declaring_no_dependencies_is_a_clean_run(tmp_path: Path) -> N
|
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
def test_deptry_still_answers_a_missing_declaration_with_that_error(
|
|
43
|
-
tmp_path: Path,
|
|
46
|
+
tmp_path: Path, deptry: str
|
|
44
47
|
) -> None:
|
|
45
48
|
"""The sensor recognises deptry's exception by name, so a deptry that stops
|
|
46
49
|
raising it must fail here rather than silently retire that branch."""
|
|
47
|
-
(tmp_path / "app.py").write_text("import os\n")
|
|
50
|
+
(tmp_path / "app.py").write_text("import os\n", encoding="utf-8")
|
|
48
51
|
|
|
49
52
|
result = subprocess.run(
|
|
50
|
-
[
|
|
53
|
+
[deptry, "."],
|
|
51
54
|
cwd=tmp_path,
|
|
52
55
|
capture_output=True,
|
|
53
|
-
|
|
56
|
+
encoding="utf-8",
|
|
57
|
+
errors="replace",
|
|
54
58
|
)
|
|
55
59
|
|
|
56
60
|
assert "DependencySpecificationNotFoundError" in result.stderr
|
|
57
61
|
|
|
58
62
|
|
|
59
|
-
def test_deptry_failing_another_way_still_fails_the_run(
|
|
63
|
+
def test_deptry_failing_another_way_still_fails_the_run(
|
|
64
|
+
tmp_path: Path, deptry: str
|
|
65
|
+
) -> None:
|
|
60
66
|
(tmp_path / "pyproject.toml").write_text(
|
|
61
67
|
'[project]\nname = "demo"\nversion = "0.0.0"\ndependencies = []\n\n'
|
|
62
|
-
"[tool.deptry]\nnot_a_real_option = true\n"
|
|
68
|
+
"[tool.deptry]\nnot_a_real_option = true\n",
|
|
69
|
+
encoding="utf-8",
|
|
63
70
|
)
|
|
64
|
-
(tmp_path / "app.py").write_text("import os\n")
|
|
71
|
+
(tmp_path / "app.py").write_text("import os\n", encoding="utf-8")
|
|
65
72
|
|
|
66
73
|
result = subprocess.run(
|
|
67
|
-
[sys.executable, str(SENSOR)],
|
|
74
|
+
[sys.executable, str(SENSOR), deptry],
|
|
68
75
|
cwd=tmp_path,
|
|
69
76
|
capture_output=True,
|
|
70
|
-
|
|
77
|
+
encoding="utf-8",
|
|
78
|
+
errors="replace",
|
|
71
79
|
)
|
|
72
80
|
|
|
73
81
|
assert result.returncode == 2
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""A sensor spawns the tool it was handed, never a name it looks up again.
|
|
2
|
+
|
|
3
|
+
``sensors/ruff.toml`` and ``sensors/deptry.toml`` each name their tool with
|
|
4
|
+
``${detector:<name>}``, so the run resolves it to a file and passes that file as
|
|
5
|
+
the helper's first argument. Handing over the file is the whole point: a bare
|
|
6
|
+
name is looked up again by whatever spawns it, and Windows' own lookup adds
|
|
7
|
+
``.exe`` and nothing else — where a project's venv holds ``deptry.exe`` and npm
|
|
8
|
+
installs a Node tool as a ``.cmd`` shim.
|
|
9
|
+
|
|
10
|
+
A ``PATH`` that cannot answer the tool's own name is what proves the helper
|
|
11
|
+
takes it: a helper still spelling that name would find nothing to spawn, while
|
|
12
|
+
one handed the file runs a tool no search path leads to.
|
|
13
|
+
|
|
14
|
+
A tool nobody installed is no longer this plugin's to answer for. The run
|
|
15
|
+
resolves the name before the helper is spawned and answers for an absent one
|
|
16
|
+
itself, as the ordinary missing command it is
|
|
17
|
+
(``tests/test_a_tool_a_part_cannot_run.py``).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
import json
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from tool_lookup import where_the_bare_name_reaches_nothing
|
|
27
|
+
|
|
28
|
+
SENSORS = (
|
|
29
|
+
Path(__file__).resolve().parents[1] / "src" / "habit_hooks_python" / "sensors"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run(
|
|
34
|
+
helper: str, tool: str, project: Path, *arguments: str
|
|
35
|
+
) -> subprocess.CompletedProcess[str]:
|
|
36
|
+
"""The helper's own run, where nothing answers the tool's bare name."""
|
|
37
|
+
return subprocess.run(
|
|
38
|
+
[sys.executable, str(SENSORS / helper), *arguments],
|
|
39
|
+
cwd=project,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
encoding="utf-8",
|
|
42
|
+
errors="replace",
|
|
43
|
+
env=where_the_bare_name_reaches_nothing(tool),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_the_ruff_sensor_spawns_the_ruff_it_was_handed(
|
|
48
|
+
tmp_path: Path, ruff: str
|
|
49
|
+
) -> None:
|
|
50
|
+
(tmp_path / "billing.py").write_text("import os\n", encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
result = _run("ruff_sensor.py", "ruff", tmp_path, ruff, "billing.py")
|
|
53
|
+
|
|
54
|
+
assert result.returncode == 0
|
|
55
|
+
assert result.stderr == ""
|
|
56
|
+
assert [finding["smell"] for finding in json.loads(result.stdout)] == [
|
|
57
|
+
"unused-import"
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_the_deptry_sensor_spawns_the_deptry_it_was_handed(
|
|
62
|
+
tmp_path: Path, deptry: str
|
|
63
|
+
) -> None:
|
|
64
|
+
(tmp_path / "pyproject.toml").write_text(
|
|
65
|
+
'[project]\nname = "demo"\nversion = "0.0.0"\ndependencies = ["rich"]\n',
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
)
|
|
68
|
+
(tmp_path / "app.py").write_text(
|
|
69
|
+
"def fetch(url):\n return url\n", encoding="utf-8"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
result = _run("deptry_sensor.py", "deptry", tmp_path, deptry)
|
|
73
|
+
|
|
74
|
+
assert result.returncode == 0
|
|
75
|
+
assert result.stderr == ""
|
|
76
|
+
assert [finding["smell"] for finding in json.loads(result.stdout)] == [
|
|
77
|
+
"unused-dependency"
|
|
78
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""``findings()`` reproduces the old ``ruff | jq`` pipeline exactly: map each
|
|
2
|
+
violation's ``code`` to a smell, group by smell (alphabetically, as jq's
|
|
3
|
+
``group_by`` sorts), and shape each group into the canonical finding.
|
|
4
|
+
|
|
5
|
+
These exercise the pure mapping logic with synthetic ruff-JSON-shaped entries
|
|
6
|
+
— the same style ``plugins/java/tests/test_class_level_metric_violations_are_dropped.py``
|
|
7
|
+
uses for ``pmd_sensor.findings`` — rather than spawning the real tool, which
|
|
8
|
+
``test_the_ruff_sensor_runs_the_real_tool.py`` does instead.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from ruff_sensor import CODE_SMELLS, findings, violations
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _entry(code: str, filename: str = "a.py", row: int = 1) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
"code": code,
|
|
22
|
+
"filename": filename,
|
|
23
|
+
"location": {"row": row, "column": 1},
|
|
24
|
+
"message": "m",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_zero_entries_is_no_findings() -> None:
|
|
29
|
+
assert findings([]) == []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_one_entry_becomes_one_finding_with_one_issue() -> None:
|
|
33
|
+
entry = {
|
|
34
|
+
"code": "F401",
|
|
35
|
+
"filename": "a.py",
|
|
36
|
+
"location": {"row": 3, "column": 8},
|
|
37
|
+
"message": "`os` unused",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
assert findings([entry]) == [
|
|
41
|
+
{
|
|
42
|
+
"smell": "unused-import",
|
|
43
|
+
"details": {},
|
|
44
|
+
"issues": [
|
|
45
|
+
{
|
|
46
|
+
"key": "a.py",
|
|
47
|
+
"details": {
|
|
48
|
+
"file": "a.py",
|
|
49
|
+
"line": 3,
|
|
50
|
+
"column": 8,
|
|
51
|
+
"message": "`os` unused",
|
|
52
|
+
"source": "ruff:F401",
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_many_entries_group_by_smell_sorted_alphabetically() -> None:
|
|
61
|
+
"""Insertion order is deliberately the reverse of the alphabetical group
|
|
62
|
+
order jq's ``group_by`` produces, so a test that merely preserved input
|
|
63
|
+
order would still pass unless the sort is checked."""
|
|
64
|
+
unused_variable = _entry("F841", filename="b.py")
|
|
65
|
+
high_complexity = _entry("C901", filename="a.py")
|
|
66
|
+
|
|
67
|
+
result = findings([unused_variable, high_complexity])
|
|
68
|
+
|
|
69
|
+
assert [finding["smell"] for finding in result] == [
|
|
70
|
+
"high-complexity",
|
|
71
|
+
"unused-variable",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_two_issues_for_the_same_smell_share_one_finding() -> None:
|
|
76
|
+
first = _entry("F401", filename="a.py", row=1)
|
|
77
|
+
second = _entry("F401", filename="a.py", row=2)
|
|
78
|
+
|
|
79
|
+
result = findings([first, second])
|
|
80
|
+
|
|
81
|
+
assert len(result) == 1
|
|
82
|
+
assert [issue["details"]["line"] for issue in result[0]["issues"]] == [1, 2]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize("code", sorted(CODE_SMELLS))
|
|
86
|
+
def test_every_mapped_code_reaches_its_own_smell(code: str) -> None:
|
|
87
|
+
result = findings([_entry(code)])
|
|
88
|
+
|
|
89
|
+
assert [finding["smell"] for finding in result] == [CODE_SMELLS[code]]
|
|
90
|
+
assert result[0]["issues"][0]["details"]["source"] == f"ruff:{code}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_a_code_with_no_smell_mapped_is_dropped_not_crashed() -> None:
|
|
94
|
+
"""A code outside the plugin's vocabulary has no guide and no catalogue
|
|
95
|
+
severity, so it is dropped rather than forwarded under ruff's own name —
|
|
96
|
+
see "A sensor emits vocabulary smells only" in CLAUDE.md. Indexing a dict
|
|
97
|
+
with ``.get`` rather than jq's ``{...}[.code]`` is what makes this safe by
|
|
98
|
+
construction instead of a crash (issue #83)."""
|
|
99
|
+
mapped = _entry("F401", filename="a.py")
|
|
100
|
+
unmapped = _entry("PLR9999", filename="b.py")
|
|
101
|
+
|
|
102
|
+
assert findings([unmapped]) == []
|
|
103
|
+
assert [f["smell"] for f in findings([mapped, unmapped])] == ["unused-import"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_empty_stdout_is_no_findings() -> None:
|
|
107
|
+
result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
108
|
+
|
|
109
|
+
assert violations(result) == []
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The helper wired to the real ``ruff``, mirroring
|
|
2
|
+
``test_a_project_declaring_no_dependencies_is_clean.py``'s real-tool style for
|
|
3
|
+
deptry. The pure mapping is covered by
|
|
4
|
+
``test_the_ruff_pipeline_maps_codes_to_smells.py``; this proves ruff's own
|
|
5
|
+
output still has the shape that mapping expects.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
SENSOR = (
|
|
16
|
+
Path(__file__).resolve().parents[1]
|
|
17
|
+
/ "src/habit_hooks_python/sensors/ruff_sensor.py"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run(project: Path, ruff: str, *files: str) -> subprocess.CompletedProcess[str]:
|
|
22
|
+
return subprocess.run(
|
|
23
|
+
[sys.executable, str(SENSOR), ruff, *files],
|
|
24
|
+
cwd=project,
|
|
25
|
+
capture_output=True,
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
errors="replace",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_a_clean_file_is_a_clean_run(tmp_path: Path, ruff: str) -> None:
|
|
32
|
+
(tmp_path / "clean.py").write_text("def add(a, b):\n return a + b\n", encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
result = _run(tmp_path, ruff, "clean.py")
|
|
35
|
+
|
|
36
|
+
assert result.returncode == 0
|
|
37
|
+
assert result.stderr == ""
|
|
38
|
+
assert json.loads(result.stdout) == []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_a_real_violation_comes_out_as_the_mapped_smell(
|
|
42
|
+
tmp_path: Path, ruff: str
|
|
43
|
+
) -> None:
|
|
44
|
+
(tmp_path / "billing.py").write_text(
|
|
45
|
+
"import os\n\n\ndef charge():\n unused = 1\n return 0\n",
|
|
46
|
+
encoding="utf-8",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
result = _run(tmp_path, ruff, "billing.py")
|
|
50
|
+
|
|
51
|
+
assert result.returncode == 0
|
|
52
|
+
findings = json.loads(result.stdout)
|
|
53
|
+
assert {finding["smell"] for finding in findings} == {
|
|
54
|
+
"unused-import",
|
|
55
|
+
"unused-variable",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_ruff_maps_a_syntax_error_to_parse_error(tmp_path: Path, ruff: str) -> None:
|
|
60
|
+
(tmp_path / "broken.py").write_text("def broken(:\n return 1\n", encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
result = _run(tmp_path, ruff, "broken.py")
|
|
63
|
+
|
|
64
|
+
assert result.returncode == 0
|
|
65
|
+
findings = json.loads(result.stdout)
|
|
66
|
+
assert [finding["smell"] for finding in findings] == ["parse-error"]
|
|
67
|
+
assert findings[0]["issues"][0]["details"]["source"] == "ruff:invalid-syntax"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
command = "${python} ${dir}/deptry_sensor.py"
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
command = """
|
|
2
|
-
set -o pipefail
|
|
3
|
-
ruff check --output-format=json --select=C901,PLR0913,PLR0915,F841,F401,BLE001 ${files} | jq '
|
|
4
|
-
map(. + {smell: ({
|
|
5
|
-
"C901": "high-complexity",
|
|
6
|
-
"PLR0913": "too-many-parameters",
|
|
7
|
-
"PLR0915": "oversized-function",
|
|
8
|
-
"F841": "unused-variable",
|
|
9
|
-
"F401": "unused-import",
|
|
10
|
-
"BLE001": "swallowed-exception",
|
|
11
|
-
"invalid-syntax": "parse-error"
|
|
12
|
-
}[.code])})
|
|
13
|
-
| group_by(.smell)
|
|
14
|
-
| map({
|
|
15
|
-
smell: .[0].smell,
|
|
16
|
-
details: {},
|
|
17
|
-
issues: map({
|
|
18
|
-
key: .filename,
|
|
19
|
-
details: {
|
|
20
|
-
file: .filename,
|
|
21
|
-
line: .location.row,
|
|
22
|
-
column: .location.column,
|
|
23
|
-
message: .message,
|
|
24
|
-
source: ("ruff:" + .code)
|
|
25
|
-
}
|
|
26
|
-
})
|
|
27
|
-
})'
|
|
28
|
-
"""
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
"""A detector this plugin does not bring with it must still answer in one line.
|
|
2
|
-
|
|
3
|
-
``pip install habit-hooks-python`` installs neither ruff nor deptry, so a machine
|
|
4
|
-
that has just enabled the plugin is the ordinary case, not the edge one. The ruff
|
|
5
|
-
sensor is a shell command, so the shell answers for it; deptry is spawned from
|
|
6
|
-
Python, where an absent tool is a ``FileNotFoundError`` and twenty lines of
|
|
7
|
-
internals would otherwise become the sensor's diagnosis (#114).
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import subprocess
|
|
13
|
-
import sys
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
SENSOR = (
|
|
17
|
-
Path(__file__).resolve().parents[1]
|
|
18
|
-
/ "src/habit_hooks_python/sensors/deptry_sensor.py"
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_a_deptry_nobody_installed_answers_the_way_a_shell_does(
|
|
23
|
-
tmp_path: Path,
|
|
24
|
-
) -> None:
|
|
25
|
-
result = subprocess.run(
|
|
26
|
-
[sys.executable, str(SENSOR)],
|
|
27
|
-
cwd=tmp_path,
|
|
28
|
-
capture_output=True,
|
|
29
|
-
text=True,
|
|
30
|
-
env={"PATH": "/nonexistent"},
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
assert result.returncode != 0
|
|
34
|
-
assert result.stdout.strip() == ""
|
|
35
|
-
assert result.stderr.strip() == "deptry: command not found"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|