habit-hooks-python 1.3.0__tar.gz → 1.4.0rc1__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 (27) hide show
  1. habit_hooks_python-1.4.0rc1/PKG-INFO +52 -0
  2. habit_hooks_python-1.4.0rc1/README.md +28 -0
  3. {habit_hooks_python-1.3.0 → habit_hooks_python-1.4.0rc1}/docs/python-plugin.spec.md +15 -14
  4. habit_hooks_python-1.4.0rc1/pyproject.toml +51 -0
  5. {habit_hooks_python-1.3.0 → habit_hooks_python-1.4.0rc1}/src/habit_hooks_python/config.toml +0 -2
  6. habit_hooks_python-1.4.0rc1/src/habit_hooks_python/sensors/deptry.toml +1 -0
  7. {habit_hooks_python-1.3.0 → habit_hooks_python-1.4.0rc1}/src/habit_hooks_python/sensors/deptry_sensor.py +7 -3
  8. habit_hooks_python-1.4.0rc1/src/habit_hooks_python/sensors/ruff.toml +1 -0
  9. habit_hooks_python-1.4.0rc1/src/habit_hooks_python/sensors/ruff_sensor.py +126 -0
  10. habit_hooks_python-1.4.0rc1/src/habit_hooks_python/sensors/tool_spawn.py +77 -0
  11. habit_hooks_python-1.4.0rc1/tests/conftest.py +20 -0
  12. habit_hooks_python-1.4.0rc1/tests/test_a_crashing_ruff_fails_the_run.py +55 -0
  13. {habit_hooks_python-1.3.0 → habit_hooks_python-1.4.0rc1}/tests/test_a_deptry_nobody_installed_is_named.py +2 -1
  14. {habit_hooks_python-1.3.0 → habit_hooks_python-1.4.0rc1}/tests/test_a_project_declaring_no_dependencies_is_clean.py +11 -7
  15. habit_hooks_python-1.4.0rc1/tests/test_a_ruff_nobody_installed_is_named.py +35 -0
  16. habit_hooks_python-1.4.0rc1/tests/test_the_ruff_pipeline_maps_codes_to_smells.py +109 -0
  17. habit_hooks_python-1.4.0rc1/tests/test_the_ruff_sensor_runs_the_real_tool.py +65 -0
  18. habit_hooks_python-1.3.0/PKG-INFO +0 -5
  19. habit_hooks_python-1.3.0/pyproject.toml +0 -16
  20. habit_hooks_python-1.3.0/src/habit_hooks_python/sensors/deptry.toml +0 -1
  21. habit_hooks_python-1.3.0/src/habit_hooks_python/sensors/ruff.toml +0 -28
  22. {habit_hooks_python-1.3.0 → habit_hooks_python-1.4.0rc1}/.gitignore +0 -0
  23. {habit_hooks_python-1.3.0 → habit_hooks_python-1.4.0rc1}/src/habit_hooks_python/__init__.py +0 -0
  24. {habit_hooks_python-1.3.0 → habit_hooks_python-1.4.0rc1}/src/habit_hooks_python/guides/high-complexity.md +0 -0
  25. {habit_hooks_python-1.3.0 → habit_hooks_python-1.4.0rc1}/src/habit_hooks_python/guides/swallowed-exception.md +0 -0
  26. {habit_hooks_python-1.3.0 → habit_hooks_python-1.4.0rc1}/src/habit_hooks_python/ruff.toml +0 -0
  27. {habit_hooks_python-1.3.0 → habit_hooks_python-1.4.0rc1}/tests/test_the_python_files_leave_installed_packages_alone.py +0 -0
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.5
2
+ Name: habit-hooks-python
3
+ Version: 1.4.0rc1
4
+ Summary: The Python Habit Hooks plugin
5
+ Project-URL: Homepage, https://habit-hooks.com
6
+ Project-URL: Repository, https://github.com/habit-hooks/habit-hooks
7
+ Project-URL: Issues, https://github.com/habit-hooks/habit-hooks/issues
8
+ Project-URL: Changelog, https://github.com/habit-hooks/habit-hooks/blob/main/CHANGELOG.md
9
+ License-Expression: MIT
10
+ Keywords: ai-coding-agents,ci,code-quality,code-review,code-smells,coding-agents,developer-tools,linter,python,refactoring,static-analysis,technical-debt
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+
25
+ # habit-hooks-python
26
+
27
+ The Python Habit Hooks plugin: wraps [`ruff`](https://docs.astral.sh/ruff/) for
28
+ structural smells (complexity, too many parameters, swallowed exceptions, …)
29
+ and [`deptry`](https://github.com/fpgmaas/deptry) for dependency issues.
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ pip install "habit-hooks[python]"
35
+ ```
36
+
37
+ ## Enable
38
+
39
+ ```toml
40
+ # .habit-hooks/config.toml
41
+ plugins = ["python", "generic"]
42
+ ```
43
+
44
+ Installing a plugin does not switch it on — it has to be named in
45
+ `plugins` before habit-hooks runs it.
46
+
47
+ ## Detectors
48
+
49
+ - [`ruff`](https://docs.astral.sh/ruff/), [`deptry`](https://github.com/fpgmaas/deptry) —
50
+ `pip install ruff deptry`
51
+
52
+ Part of [habit-hooks](https://github.com/habit-hooks/habit-hooks).
@@ -0,0 +1,28 @@
1
+ # habit-hooks-python
2
+
3
+ The Python Habit Hooks plugin: wraps [`ruff`](https://docs.astral.sh/ruff/) for
4
+ structural smells (complexity, too many parameters, swallowed exceptions, …)
5
+ and [`deptry`](https://github.com/fpgmaas/deptry) for dependency issues.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pip install "habit-hooks[python]"
11
+ ```
12
+
13
+ ## Enable
14
+
15
+ ```toml
16
+ # .habit-hooks/config.toml
17
+ plugins = ["python", "generic"]
18
+ ```
19
+
20
+ Installing a plugin does not switch it on — it has to be named in
21
+ `plugins` before habit-hooks runs it.
22
+
23
+ ## Detectors
24
+
25
+ - [`ruff`](https://docs.astral.sh/ruff/), [`deptry`](https://github.com/fpgmaas/deptry) —
26
+ `pip install ruff deptry`
27
+
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'
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' '${files}'
391
392
  ```
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "habit-hooks-python"
3
+ version = "1.4.0rc1"
4
+ description = "The Python Habit Hooks plugin"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ keywords = [
8
+ "code-quality",
9
+ "code-smells",
10
+ "static-analysis",
11
+ "linter",
12
+ "ci",
13
+ "code-review",
14
+ "technical-debt",
15
+ "refactoring",
16
+ "ai-coding-agents",
17
+ "coding-agents",
18
+ "developer-tools",
19
+ "python",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Environment :: Console",
24
+ "Intended Audience :: Developers",
25
+ "Operating System :: OS Independent",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Topic :: Software Development :: Quality Assurance",
31
+ "Topic :: Software Development :: Testing",
32
+ "Topic :: Utilities",
33
+ ]
34
+ requires-python = ">=3.11"
35
+ dependencies = []
36
+
37
+ [project.entry-points."habit_hooks.plugins"]
38
+ python = "habit_hooks_python"
39
+
40
+ [project.urls]
41
+ Homepage = "https://habit-hooks.com"
42
+ Repository = "https://github.com/habit-hooks/habit-hooks"
43
+ Issues = "https://github.com/habit-hooks/habit-hooks/issues"
44
+ Changelog = "https://github.com/habit-hooks/habit-hooks/blob/main/CHANGELOG.md"
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/habit_hooks_python"]
@@ -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"]
@@ -13,6 +13,8 @@ import sys
13
13
  import tempfile
14
14
  from pathlib import Path
15
15
 
16
+ from tool_spawn import run_tool
17
+
16
18
 
17
19
  def run_deptry(report: Path) -> subprocess.CompletedProcess[str]:
18
20
  """What deptry said, or what a shell says about a deptry nobody installed.
@@ -22,11 +24,13 @@ def run_deptry(report: Path) -> subprocess.CompletedProcess[str]:
22
24
  absent tool raised a ``FileNotFoundError`` out of here, making twenty lines
23
25
  of Python internals the sensor's diagnosis (#114). This wrapper is what looks
24
26
  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.
27
+ what the run recognises to name the missing tool. Looking is ``tool_spawn``'s,
28
+ which names the file — a console script a project's own venv installed, which
29
+ Windows spells ``deptry.exe`` there and habit-hooks' venv may not hold at all.
26
30
  """
27
31
  command = ["deptry", ".", "--json-output", str(report)]
28
32
  try:
29
- return subprocess.run(command, capture_output=True, text=True)
33
+ return run_tool(command)
30
34
  except FileNotFoundError:
31
35
  return subprocess.CompletedProcess(
32
36
  command, 127, "", "deptry: command not found\n"
@@ -52,7 +56,7 @@ def deptry_found_no_declaration(result: subprocess.CompletedProcess[str]) -> boo
52
56
 
53
57
 
54
58
  def unused_dependencies(report: Path) -> list[dict]:
55
- entries = json.loads(report.read_text())
59
+ entries = json.loads(report.read_text(encoding="utf-8"))
56
60
  return [entry for entry in entries if entry["error"]["code"] == "DEP002"]
57
61
 
58
62
 
@@ -0,0 +1 @@
1
+ argv = ["${python}", "${dir}/ruff_sensor.py", "${files}"]
@@ -0,0 +1,126 @@
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
+ from tool_spawn import run_tool
28
+
29
+ SELECTED_CODES = "C901,PLR0913,PLR0915,F841,F401,BLE001"
30
+
31
+ CODE_SMELLS = {
32
+ "C901": "high-complexity",
33
+ "PLR0913": "too-many-parameters",
34
+ "PLR0915": "oversized-function",
35
+ "F841": "unused-variable",
36
+ "F401": "unused-import",
37
+ "BLE001": "swallowed-exception",
38
+ "invalid-syntax": "parse-error",
39
+ }
40
+
41
+ # ruff's own contract: 0 is clean, 1 is "violations found" — both trustworthy.
42
+ # Anything else is ruff never having produced a real report (bad config, a
43
+ # missing file, an internal error), the same distinction part_output.py's
44
+ # TOOL_EXIT_CODES draws for a part run directly.
45
+ TOOL_EXIT_CODES = (0, 1)
46
+
47
+
48
+ def run_ruff(files: list[str]) -> subprocess.CompletedProcess[str]:
49
+ """What ruff said, or what a shell says about a ruff nobody installed.
50
+
51
+ ``pip install habit-hooks-python`` brings neither wrapped tool with it, so
52
+ this is the ordinary state of a machine that has just enabled the plugin —
53
+ and an absent tool raised a ``FileNotFoundError`` out of here, making
54
+ twenty lines of Python internals the sensor's diagnosis (#114). This
55
+ wrapper is what looks for ruff, so it answers the way the shell would
56
+ have, and that phrase is what the run recognises to name the missing
57
+ tool. Looking is ``tool_spawn``'s, which names the file — ``ruff.exe`` or
58
+ a ``.cmd`` shim on Windows, neither of which a bare ``["ruff", ...]``
59
+ spawn reliably reaches there.
60
+ """
61
+ command = [
62
+ "ruff",
63
+ "check",
64
+ "--output-format=json",
65
+ f"--select={SELECTED_CODES}",
66
+ *files,
67
+ ]
68
+ try:
69
+ return run_tool(command)
70
+ except FileNotFoundError:
71
+ return subprocess.CompletedProcess(
72
+ command, 127, "", "ruff: command not found\n"
73
+ )
74
+
75
+
76
+ def ruff_crashed(result: subprocess.CompletedProcess[str]) -> bool:
77
+ return result.returncode not in TOOL_EXIT_CODES
78
+
79
+
80
+ def violations(result: subprocess.CompletedProcess[str]) -> list[dict]:
81
+ text = result.stdout.strip()
82
+ return json.loads(text) if text else []
83
+
84
+
85
+ def issue(entry: dict) -> dict:
86
+ return {
87
+ "key": entry["filename"],
88
+ "details": {
89
+ "file": entry["filename"],
90
+ "line": entry["location"]["row"],
91
+ "column": entry["location"]["column"],
92
+ "message": entry["message"],
93
+ "source": "ruff:" + entry["code"],
94
+ },
95
+ }
96
+
97
+
98
+ def findings(entries: list[dict]) -> list[dict]:
99
+ by_smell: dict[str, list[dict]] = {}
100
+ for entry in entries:
101
+ smell = CODE_SMELLS.get(entry["code"])
102
+ if smell is None:
103
+ continue
104
+ by_smell.setdefault(smell, []).append(entry)
105
+ return [
106
+ {
107
+ "smell": smell,
108
+ "details": {},
109
+ "issues": [issue(entry) for entry in by_smell[smell]],
110
+ }
111
+ for smell in sorted(by_smell)
112
+ ]
113
+
114
+
115
+ def main() -> int:
116
+ files = sys.argv[1:]
117
+ result = run_ruff(files)
118
+ if ruff_crashed(result):
119
+ sys.stderr.write(result.stderr)
120
+ return 2
121
+ print(json.dumps(findings(violations(result))))
122
+ return 0
123
+
124
+
125
+ if __name__ == "__main__":
126
+ sys.exit(main())
@@ -0,0 +1,77 @@
1
+ """Spawn the third-party tool a sensor wraps, by the file rather than the name.
2
+
3
+ A local copy of what ``habit_hooks/sensors/spawn.py`` and its ``batch_shell`` do
4
+ for a part's own argv, and it is a copy on purpose: this plugin declares no
5
+ dependency on ``habit-hooks``, so a helper cannot import the core, and four
6
+ small copies beat a dependency that does not exist. The core's modules carry the
7
+ full reasoning; the two rules are Windows' own.
8
+
9
+ **A bare name is looked up first.** Windows' spawn appends ``.exe`` to a name and
10
+ nothing else, so the ``jscpd.CMD`` npm installs and the ``pmd.bat`` PMD ships are
11
+ found by a lookup and then unreachable by a spawn handed the name — the tool the
12
+ setup cleared, reported missing by the run. This is not a second answer to that
13
+ question: habit-hooks hands a helper a ``PATH`` of the project's own bins
14
+ (``sensors/spawn._path_env``), so ``shutil.which`` here asks exactly what cleared
15
+ the tool, along exactly the same path.
16
+
17
+ **A batch file's arguments are then ``cmd.exe``'s syntax.** ``CreateProcess``
18
+ runs a ``.bat`` or ``.cmd`` through that shell, which reads ``&``, ``|``, ``<``,
19
+ ``>``, ``^``, ``"``, ``%VAR%`` and a newline as its own (CVE-2024-24576; CPython
20
+ closed its half in 3.11.9, and ``>=3.11`` is supported). A sensor's arguments are
21
+ paths out of the work tree, so one is refused rather than escaped: no tool anyone
22
+ wraps needs those characters, and refusing needs no interpreter version to be
23
+ right.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import shutil
29
+ import subprocess
30
+
31
+ BATCH_SUFFIXES = (".bat", ".cmd")
32
+
33
+ # Everything ``cmd.exe`` reads as other than text: the separators and pipe, both
34
+ # redirections, its own escape character, the quote that ends a quoted run, the
35
+ # ``%`` that opens a variable, and the newline that ends the line it is reading.
36
+ CMD_SYNTAX = frozenset('&|<>^"%\n\r')
37
+
38
+ # Found, and refused rather than run — the code a shell keeps for a command it
39
+ # located and would not execute, and outside every wrapped tool's success set.
40
+ REFUSED_EXIT = 126
41
+
42
+
43
+ def run_tool(command: list[str]) -> subprocess.CompletedProcess[str]:
44
+ """What ``command`` said, spawned as the file this project runs for its name.
45
+
46
+ A name reaching no file is spawned as it stands, so the caller's own
47
+ ``FileNotFoundError`` remains the single answer for a tool nobody installed.
48
+ """
49
+ program = shutil.which(command[0]) or command[0]
50
+ unreadable = cmd_syntax(program, command[1:])
51
+ if unreadable is not None:
52
+ return subprocess.CompletedProcess(
53
+ command,
54
+ REFUSED_EXIT,
55
+ "",
56
+ f"{program}: cannot pass {unreadable!r} to a batch file — cmd.exe "
57
+ "would read it as its own syntax rather than as text\n",
58
+ )
59
+ return subprocess.run(
60
+ [program, *command[1:]],
61
+ capture_output=True,
62
+ encoding="utf-8",
63
+ errors="replace", # sensors.spawn's policy
64
+ )
65
+
66
+
67
+ def cmd_syntax(program: str, arguments: list[str]) -> str | None:
68
+ """The first of ``arguments`` ``cmd.exe`` would read as syntax, if it reads any.
69
+
70
+ ``None`` where ``program`` is not a batch file: nothing then stands between
71
+ the spawn and the program, and an ``&`` in a filename is a filename.
72
+ """
73
+ if not program.lower().endswith(BATCH_SUFFIXES):
74
+ return None
75
+ return next(
76
+ (argument for argument in arguments if CMD_SYNTAX & set(argument)), None
77
+ )
@@ -0,0 +1,20 @@
1
+ """Load the ruff sensor the way it is actually run — as a loose script.
2
+
3
+ The sensor spec spells ``${python} ${dir}/ruff_sensor.py``, so the
4
+ interpreter puts the helper's own directory first on ``sys.path`` and a unit
5
+ test does the same rather than reaching the code as
6
+ ``habit_hooks_python.sensors.ruff_sensor`` — a load path no run ever takes
7
+ (see "A plugin helper imports its neighbours as top-level modules" in
8
+ CLAUDE.md).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ SENSORS = (
17
+ Path(__file__).resolve().parents[1] / "src" / "habit_hooks_python" / "sensors"
18
+ )
19
+
20
+ sys.path.insert(0, str(SENSORS))
@@ -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) -> 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), "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
@@ -26,7 +26,8 @@ def test_a_deptry_nobody_installed_answers_the_way_a_shell_does(
26
26
  [sys.executable, str(SENSOR)],
27
27
  cwd=tmp_path,
28
28
  capture_output=True,
29
- text=True,
29
+ encoding="utf-8",
30
+ errors="replace",
30
31
  env={"PATH": "/nonexistent"},
31
32
  )
32
33
 
@@ -25,13 +25,14 @@ SENSOR = (
25
25
 
26
26
 
27
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")
28
+ (tmp_path / "app.py").write_text("import os\n", encoding="utf-8")
29
29
 
30
30
  result = subprocess.run(
31
31
  [sys.executable, str(SENSOR)],
32
32
  cwd=tmp_path,
33
33
  capture_output=True,
34
- text=True,
34
+ encoding="utf-8",
35
+ errors="replace",
35
36
  )
36
37
 
37
38
  assert result.returncode == 0
@@ -44,13 +45,14 @@ def test_deptry_still_answers_a_missing_declaration_with_that_error(
44
45
  ) -> None:
45
46
  """The sensor recognises deptry's exception by name, so a deptry that stops
46
47
  raising it must fail here rather than silently retire that branch."""
47
- (tmp_path / "app.py").write_text("import os\n")
48
+ (tmp_path / "app.py").write_text("import os\n", encoding="utf-8")
48
49
 
49
50
  result = subprocess.run(
50
51
  ["deptry", "."],
51
52
  cwd=tmp_path,
52
53
  capture_output=True,
53
- text=True,
54
+ encoding="utf-8",
55
+ errors="replace",
54
56
  )
55
57
 
56
58
  assert "DependencySpecificationNotFoundError" in result.stderr
@@ -59,15 +61,17 @@ def test_deptry_still_answers_a_missing_declaration_with_that_error(
59
61
  def test_deptry_failing_another_way_still_fails_the_run(tmp_path: Path) -> None:
60
62
  (tmp_path / "pyproject.toml").write_text(
61
63
  '[project]\nname = "demo"\nversion = "0.0.0"\ndependencies = []\n\n'
62
- "[tool.deptry]\nnot_a_real_option = true\n"
64
+ "[tool.deptry]\nnot_a_real_option = true\n",
65
+ encoding="utf-8",
63
66
  )
64
- (tmp_path / "app.py").write_text("import os\n")
67
+ (tmp_path / "app.py").write_text("import os\n", encoding="utf-8")
65
68
 
66
69
  result = subprocess.run(
67
70
  [sys.executable, str(SENSOR)],
68
71
  cwd=tmp_path,
69
72
  capture_output=True,
70
- text=True,
73
+ encoding="utf-8",
74
+ errors="replace",
71
75
  )
72
76
 
73
77
  assert result.returncode == 2
@@ -0,0 +1,35 @@
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. ruff is
5
+ spawned from Python now (it used to be a shell recipe, so the shell answered for
6
+ it), and an absent tool is a ``FileNotFoundError`` — twenty lines of internals
7
+ would otherwise become the sensor's diagnosis (#114). Mirrors
8
+ ``test_a_deptry_nobody_installed_is_named.py``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ SENSOR = (
18
+ Path(__file__).resolve().parents[1]
19
+ / "src/habit_hooks_python/sensors/ruff_sensor.py"
20
+ )
21
+
22
+
23
+ def test_a_ruff_nobody_installed_answers_the_way_a_shell_does(tmp_path: Path) -> None:
24
+ result = subprocess.run(
25
+ [sys.executable, str(SENSOR)],
26
+ cwd=tmp_path,
27
+ capture_output=True,
28
+ encoding="utf-8",
29
+ errors="replace",
30
+ env={"PATH": "/nonexistent"},
31
+ )
32
+
33
+ assert result.returncode == 2
34
+ assert result.stdout.strip() == ""
35
+ assert result.stderr.strip() == "ruff: command not found"
@@ -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,65 @@
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, *files: str) -> subprocess.CompletedProcess[str]:
22
+ return subprocess.run(
23
+ [sys.executable, str(SENSOR), *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) -> 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, "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(tmp_path: Path) -> None:
42
+ (tmp_path / "billing.py").write_text(
43
+ "import os\n\n\ndef charge():\n unused = 1\n return 0\n",
44
+ encoding="utf-8",
45
+ )
46
+
47
+ result = _run(tmp_path, "billing.py")
48
+
49
+ assert result.returncode == 0
50
+ findings = json.loads(result.stdout)
51
+ assert {finding["smell"] for finding in findings} == {
52
+ "unused-import",
53
+ "unused-variable",
54
+ }
55
+
56
+
57
+ def test_ruff_maps_a_syntax_error_to_parse_error(tmp_path: Path) -> None:
58
+ (tmp_path / "broken.py").write_text("def broken(:\n return 1\n", encoding="utf-8")
59
+
60
+ result = _run(tmp_path, "broken.py")
61
+
62
+ assert result.returncode == 0
63
+ findings = json.loads(result.stdout)
64
+ assert [finding["smell"] for finding in findings] == ["parse-error"]
65
+ assert findings[0]["issues"][0]["details"]["source"] == "ruff:invalid-syntax"
@@ -1,5 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: habit-hooks-python
3
- Version: 1.3.0
4
- Summary: The Python Habit Hooks plugin
5
- Requires-Python: >=3.11
@@ -1,16 +0,0 @@
1
- [project]
2
- name = "habit-hooks-python"
3
- version = "1.3.0"
4
- description = "The Python Habit Hooks plugin"
5
- requires-python = ">=3.11"
6
- dependencies = []
7
-
8
- [project.entry-points."habit_hooks.plugins"]
9
- python = "habit_hooks_python"
10
-
11
- [build-system]
12
- requires = ["hatchling"]
13
- build-backend = "hatchling.build"
14
-
15
- [tool.hatch.build.targets.wheel]
16
- packages = ["src/habit_hooks_python"]
@@ -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
- """