habit-hooks-python 1.3.1__tar.gz → 1.4.0rc2__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.
Files changed (24) hide show
  1. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/PKG-INFO +1 -2
  2. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/README.md +0 -1
  3. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/docs/python-plugin.spec.md +15 -14
  4. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/pyproject.toml +1 -1
  5. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/config.toml +0 -2
  6. habit_hooks_python-1.4.0rc2/src/habit_hooks_python/sensors/deptry.toml +1 -0
  7. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/sensors/deptry_sensor.py +18 -18
  8. habit_hooks_python-1.4.0rc2/src/habit_hooks_python/sensors/ruff.toml +1 -0
  9. habit_hooks_python-1.4.0rc2/src/habit_hooks_python/sensors/ruff_sensor.py +114 -0
  10. habit_hooks_python-1.4.0rc2/tests/conftest.py +56 -0
  11. habit_hooks_python-1.4.0rc2/tests/test_a_crashing_ruff_fails_the_run.py +55 -0
  12. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/tests/test_a_project_declaring_no_dependencies_is_clean.py +21 -13
  13. habit_hooks_python-1.4.0rc2/tests/test_a_sensor_spawns_the_tool_it_was_handed.py +78 -0
  14. habit_hooks_python-1.4.0rc2/tests/test_the_ruff_pipeline_maps_codes_to_smells.py +109 -0
  15. habit_hooks_python-1.4.0rc2/tests/test_the_ruff_sensor_runs_the_real_tool.py +67 -0
  16. habit_hooks_python-1.3.1/src/habit_hooks_python/sensors/deptry.toml +0 -1
  17. habit_hooks_python-1.3.1/src/habit_hooks_python/sensors/ruff.toml +0 -28
  18. habit_hooks_python-1.3.1/tests/test_a_deptry_nobody_installed_is_named.py +0 -35
  19. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/.gitignore +0 -0
  20. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/__init__.py +0 -0
  21. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/guides/high-complexity.md +0 -0
  22. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/guides/swallowed-exception.md +0 -0
  23. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/ruff.toml +0 -0
  24. {habit_hooks_python-1.3.1 → habit_hooks_python-1.4.0rc2}/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.1
3
+ Version: 1.4.0rc2
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 jq
16
- transform in its command groups the flat output into one finding per smell,
17
- stamping `source: "ruff:<code>"` on each issue. The shipped `ruff.toml` carries
18
- `max-args = 3`, so a four-argument function trips `PLR0913`.
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 pipes the tool into `jq`. A crashing `ruff` (here, a malformed
345
- `ruff.toml` that makes the tool exit non-zero) prints nothing on stdout, so a
346
- naive pipe would let `jq` succeed on empty input and mask the crash as a false-
347
- clean run. The command sets `pipefail` so the tool's failing exit propagates
348
- through the pipe; `habit-sensors` then raises, names the sensor on stderr, and
349
- exits 1, carrying only the reserved `incomplete-run` marker on stdout
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 multi-line here, so its first
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: set -o pipefail
391
+ habit-sensors: sensor 'ruff' failed: '${python}' '${dir}/ruff_sensor.py' '${detector:ruff}' '${files}'
391
392
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "habit-hooks-python"
3
- version = "1.3.1"
3
+ version = "1.4.0rc2"
4
4
  description = "The Python Habit Hooks plugin"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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, or what a shell says about a deptry nobody installed.
19
-
20
- ``pip install habit-hooks-python`` brings neither detector with it, so this
21
- is the ordinary state of a machine that has just enabled the plugin — and an
22
- absent tool raised a ``FileNotFoundError`` out of here, making twenty lines
23
- of Python internals the sensor's diagnosis (#114). This wrapper is what looks
24
- for deptry, so it answers the way the shell would have, and that phrase is
25
- what the run recognises to name the missing tool.
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
- command = ["deptry", ".", "--json-output", str(report)]
28
- try:
29
- return subprocess.run(command, capture_output=True, text=True)
30
- except FileNotFoundError:
31
- return subprocess.CompletedProcess(
32
- command, 127, "", "deptry: command not found\n"
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(tmp_path: Path) -> None:
28
- (tmp_path / "app.py").write_text("import os\n")
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
- text=True,
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
- ["deptry", "."],
53
+ [deptry, "."],
51
54
  cwd=tmp_path,
52
55
  capture_output=True,
53
- text=True,
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(tmp_path: Path) -> None:
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
- text=True,
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"