habit-hooks-python 1.4.0rc1__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 (27) hide show
  1. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/PKG-INFO +1 -1
  2. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/docs/python-plugin.spec.md +2 -2
  3. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/pyproject.toml +1 -1
  4. habit_hooks_python-1.4.0rc2/src/habit_hooks_python/sensors/deptry.toml +1 -0
  5. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/sensors/deptry_sensor.py +16 -20
  6. habit_hooks_python-1.4.0rc2/src/habit_hooks_python/sensors/ruff.toml +1 -0
  7. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/sensors/ruff_sensor.py +16 -28
  8. habit_hooks_python-1.4.0rc2/tests/conftest.py +56 -0
  9. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/tests/test_a_crashing_ruff_fails_the_run.py +2 -2
  10. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/tests/test_a_project_declaring_no_dependencies_is_clean.py +10 -6
  11. habit_hooks_python-1.4.0rc2/tests/test_a_sensor_spawns_the_tool_it_was_handed.py +78 -0
  12. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/tests/test_the_ruff_sensor_runs_the_real_tool.py +10 -8
  13. habit_hooks_python-1.4.0rc1/src/habit_hooks_python/sensors/deptry.toml +0 -1
  14. habit_hooks_python-1.4.0rc1/src/habit_hooks_python/sensors/ruff.toml +0 -1
  15. habit_hooks_python-1.4.0rc1/src/habit_hooks_python/sensors/tool_spawn.py +0 -77
  16. habit_hooks_python-1.4.0rc1/tests/conftest.py +0 -20
  17. habit_hooks_python-1.4.0rc1/tests/test_a_deptry_nobody_installed_is_named.py +0 -36
  18. habit_hooks_python-1.4.0rc1/tests/test_a_ruff_nobody_installed_is_named.py +0 -35
  19. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/.gitignore +0 -0
  20. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/README.md +0 -0
  21. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/__init__.py +0 -0
  22. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/config.toml +0 -0
  23. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/guides/high-complexity.md +0 -0
  24. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/guides/swallowed-exception.md +0 -0
  25. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/ruff.toml +0 -0
  26. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/tests/test_the_python_files_leave_installed_packages_alone.py +0 -0
  27. {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/tests/test_the_ruff_pipeline_maps_codes_to_smells.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: habit-hooks-python
3
- Version: 1.4.0rc1
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
@@ -337,7 +337,7 @@ habit-sensors --all 2>&1 >/dev/null | sed -n 1p
337
337
 
338
338
  🖥️ ❌ 1
339
339
  ```text
340
- habit-sensors: sensor 'deptry' failed: '${python}' '${dir}/deptry_sensor.py'
340
+ habit-sensors: sensor 'deptry' failed: '${python}' '${dir}/deptry_sensor.py' '${detector:deptry}'
341
341
  ```
342
342
 
343
343
  ## A crashing ruff fails the run, never reports clean
@@ -388,5 +388,5 @@ habit-sensors --all 2>&1 >/dev/null | sed -n 1p
388
388
 
389
389
  🖥️ ❌ 1
390
390
  ```text
391
- habit-sensors: sensor 'ruff' failed: '${python}' '${dir}/ruff_sensor.py' '${files}'
391
+ habit-sensors: sensor 'ruff' failed: '${python}' '${dir}/ruff_sensor.py' '${detector:ruff}' '${files}'
392
392
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "habit-hooks-python"
3
- version = "1.4.0rc1"
3
+ version = "1.4.0rc2"
4
4
  description = "The Python Habit Hooks plugin"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1 @@
1
+ argv = ["${python}", "${dir}/deptry_sensor.py", "${detector:deptry}"]
@@ -13,28 +13,23 @@ import sys
13
13
  import tempfile
14
14
  from pathlib import Path
15
15
 
16
- from tool_spawn import run_tool
17
16
 
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.
18
19
 
19
- def run_deptry(report: Path) -> subprocess.CompletedProcess[str]:
20
- """What deptry said, or what a shell says about a deptry nobody installed.
21
-
22
- ``pip install habit-hooks-python`` brings neither detector with it, so this
23
- is the ordinary state of a machine that has just enabled the plugin — and an
24
- absent tool raised a ``FileNotFoundError`` out of here, making twenty lines
25
- of Python internals the sensor's diagnosis (#114). This wrapper is what looks
26
- for deptry, so it answers the way the shell would have, and that phrase is
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.
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).
30
26
  """
31
- command = ["deptry", ".", "--json-output", str(report)]
32
- try:
33
- return run_tool(command)
34
- except FileNotFoundError:
35
- return subprocess.CompletedProcess(
36
- command, 127, "", "deptry: command not found\n"
37
- )
27
+ return subprocess.run(
28
+ [deptry, ".", "--json-output", str(report)],
29
+ capture_output=True,
30
+ encoding="utf-8",
31
+ errors="replace",
32
+ )
38
33
 
39
34
 
40
35
  def deptry_crashed(result: subprocess.CompletedProcess[str], report: Path) -> bool:
@@ -85,9 +80,10 @@ def findings(entries: list[dict]) -> list[dict]:
85
80
 
86
81
 
87
82
  def main() -> int:
83
+ deptry = sys.argv[1]
88
84
  with tempfile.TemporaryDirectory() as tmp:
89
85
  report = Path(tmp) / "deptry-report.json"
90
- result = run_deptry(report)
86
+ result = run_deptry(deptry, report)
91
87
  if deptry_crashed(result, report):
92
88
  if deptry_found_no_declaration(result):
93
89
  print(json.dumps([]))
@@ -0,0 +1 @@
1
+ argv = ["${python}", "${dir}/ruff_sensor.py", "${detector:ruff}", "${files}"]
@@ -24,8 +24,6 @@ import json
24
24
  import subprocess
25
25
  import sys
26
26
 
27
- from tool_spawn import run_tool
28
-
29
27
  SELECTED_CODES = "C901,PLR0913,PLR0915,F841,F401,BLE001"
30
28
 
31
29
  CODE_SMELLS = {
@@ -45,32 +43,21 @@ CODE_SMELLS = {
45
43
  TOOL_EXIT_CODES = (0, 1)
46
44
 
47
45
 
48
- def run_ruff(files: list[str]) -> subprocess.CompletedProcess[str]:
49
- """What ruff said, or what a shell says about a ruff nobody installed.
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.
50
48
 
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.
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).
60
54
  """
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
- )
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
+ )
74
61
 
75
62
 
76
63
  def ruff_crashed(result: subprocess.CompletedProcess[str]) -> bool:
@@ -113,8 +100,9 @@ def findings(entries: list[dict]) -> list[dict]:
113
100
 
114
101
 
115
102
  def main() -> int:
116
- files = sys.argv[1:]
117
- result = run_ruff(files)
103
+ ruff = sys.argv[1]
104
+ files = sys.argv[2:]
105
+ result = run_ruff(ruff, files)
118
106
  if ruff_crashed(result):
119
107
  sys.stderr.write(result.stderr)
120
108
  return 2
@@ -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
@@ -36,14 +36,14 @@ def test_any_other_exit_code_is_a_crash(returncode: int) -> None:
36
36
  assert ruff_crashed(_result(returncode)) is True
37
37
 
38
38
 
39
- def test_a_broken_ruff_toml_fails_the_run(tmp_path: Path) -> None:
39
+ def test_a_broken_ruff_toml_fails_the_run(tmp_path: Path, ruff: str) -> None:
40
40
  (tmp_path / "ruff.toml").write_text(
41
41
  "this is not = valid ruff config\n", encoding="utf-8"
42
42
  )
43
43
  (tmp_path / "app.py").write_text("import os\n", encoding="utf-8")
44
44
 
45
45
  result = subprocess.run(
46
- [sys.executable, str(SENSOR), "app.py"],
46
+ [sys.executable, str(SENSOR), ruff, "app.py"],
47
47
  cwd=tmp_path,
48
48
  capture_output=True,
49
49
  encoding="utf-8",
@@ -24,11 +24,13 @@ SENSOR = (
24
24
  )
25
25
 
26
26
 
27
- def test_a_project_declaring_no_dependencies_is_a_clean_run(tmp_path: Path) -> None:
27
+ def test_a_project_declaring_no_dependencies_is_a_clean_run(
28
+ tmp_path: Path, deptry: str
29
+ ) -> None:
28
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",
@@ -41,14 +43,14 @@ def test_a_project_declaring_no_dependencies_is_a_clean_run(tmp_path: Path) -> N
41
43
 
42
44
 
43
45
  def test_deptry_still_answers_a_missing_declaration_with_that_error(
44
- tmp_path: Path,
46
+ tmp_path: Path, deptry: str
45
47
  ) -> None:
46
48
  """The sensor recognises deptry's exception by name, so a deptry that stops
47
49
  raising it must fail here rather than silently retire that branch."""
48
50
  (tmp_path / "app.py").write_text("import os\n", encoding="utf-8")
49
51
 
50
52
  result = subprocess.run(
51
- ["deptry", "."],
53
+ [deptry, "."],
52
54
  cwd=tmp_path,
53
55
  capture_output=True,
54
56
  encoding="utf-8",
@@ -58,7 +60,9 @@ def test_deptry_still_answers_a_missing_declaration_with_that_error(
58
60
  assert "DependencySpecificationNotFoundError" in result.stderr
59
61
 
60
62
 
61
- 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:
62
66
  (tmp_path / "pyproject.toml").write_text(
63
67
  '[project]\nname = "demo"\nversion = "0.0.0"\ndependencies = []\n\n'
64
68
  "[tool.deptry]\nnot_a_real_option = true\n",
@@ -67,7 +71,7 @@ def test_deptry_failing_another_way_still_fails_the_run(tmp_path: Path) -> None:
67
71
  (tmp_path / "app.py").write_text("import os\n", encoding="utf-8")
68
72
 
69
73
  result = subprocess.run(
70
- [sys.executable, str(SENSOR)],
74
+ [sys.executable, str(SENSOR), deptry],
71
75
  cwd=tmp_path,
72
76
  capture_output=True,
73
77
  encoding="utf-8",
@@ -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
+ ]
@@ -18,9 +18,9 @@ SENSOR = (
18
18
  )
19
19
 
20
20
 
21
- def _run(project: Path, *files: str) -> subprocess.CompletedProcess[str]:
21
+ def _run(project: Path, ruff: str, *files: str) -> subprocess.CompletedProcess[str]:
22
22
  return subprocess.run(
23
- [sys.executable, str(SENSOR), *files],
23
+ [sys.executable, str(SENSOR), ruff, *files],
24
24
  cwd=project,
25
25
  capture_output=True,
26
26
  encoding="utf-8",
@@ -28,23 +28,25 @@ def _run(project: Path, *files: str) -> subprocess.CompletedProcess[str]:
28
28
  )
29
29
 
30
30
 
31
- def test_a_clean_file_is_a_clean_run(tmp_path: Path) -> None:
31
+ def test_a_clean_file_is_a_clean_run(tmp_path: Path, ruff: str) -> None:
32
32
  (tmp_path / "clean.py").write_text("def add(a, b):\n return a + b\n", encoding="utf-8")
33
33
 
34
- result = _run(tmp_path, "clean.py")
34
+ result = _run(tmp_path, ruff, "clean.py")
35
35
 
36
36
  assert result.returncode == 0
37
37
  assert result.stderr == ""
38
38
  assert json.loads(result.stdout) == []
39
39
 
40
40
 
41
- def test_a_real_violation_comes_out_as_the_mapped_smell(tmp_path: Path) -> None:
41
+ def test_a_real_violation_comes_out_as_the_mapped_smell(
42
+ tmp_path: Path, ruff: str
43
+ ) -> None:
42
44
  (tmp_path / "billing.py").write_text(
43
45
  "import os\n\n\ndef charge():\n unused = 1\n return 0\n",
44
46
  encoding="utf-8",
45
47
  )
46
48
 
47
- result = _run(tmp_path, "billing.py")
49
+ result = _run(tmp_path, ruff, "billing.py")
48
50
 
49
51
  assert result.returncode == 0
50
52
  findings = json.loads(result.stdout)
@@ -54,10 +56,10 @@ def test_a_real_violation_comes_out_as_the_mapped_smell(tmp_path: Path) -> None:
54
56
  }
55
57
 
56
58
 
57
- def test_ruff_maps_a_syntax_error_to_parse_error(tmp_path: Path) -> None:
59
+ def test_ruff_maps_a_syntax_error_to_parse_error(tmp_path: Path, ruff: str) -> None:
58
60
  (tmp_path / "broken.py").write_text("def broken(:\n return 1\n", encoding="utf-8")
59
61
 
60
- result = _run(tmp_path, "broken.py")
62
+ result = _run(tmp_path, ruff, "broken.py")
61
63
 
62
64
  assert result.returncode == 0
63
65
  findings = json.loads(result.stdout)
@@ -1 +0,0 @@
1
- argv = ["${python}", "${dir}/deptry_sensor.py"]
@@ -1 +0,0 @@
1
- argv = ["${python}", "${dir}/ruff_sensor.py", "${files}"]
@@ -1,77 +0,0 @@
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
- )
@@ -1,20 +0,0 @@
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))
@@ -1,36 +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
- encoding="utf-8",
30
- errors="replace",
31
- env={"PATH": "/nonexistent"},
32
- )
33
-
34
- assert result.returncode != 0
35
- assert result.stdout.strip() == ""
36
- assert result.stderr.strip() == "deptry: command not found"
@@ -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. 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"