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.
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/PKG-INFO +1 -1
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/docs/python-plugin.spec.md +2 -2
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/pyproject.toml +1 -1
- habit_hooks_python-1.4.0rc2/src/habit_hooks_python/sensors/deptry.toml +1 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/sensors/deptry_sensor.py +16 -20
- habit_hooks_python-1.4.0rc2/src/habit_hooks_python/sensors/ruff.toml +1 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/sensors/ruff_sensor.py +16 -28
- habit_hooks_python-1.4.0rc2/tests/conftest.py +56 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/tests/test_a_crashing_ruff_fails_the_run.py +2 -2
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/tests/test_a_project_declaring_no_dependencies_is_clean.py +10 -6
- habit_hooks_python-1.4.0rc2/tests/test_a_sensor_spawns_the_tool_it_was_handed.py +78 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/tests/test_the_ruff_sensor_runs_the_real_tool.py +10 -8
- habit_hooks_python-1.4.0rc1/src/habit_hooks_python/sensors/deptry.toml +0 -1
- habit_hooks_python-1.4.0rc1/src/habit_hooks_python/sensors/ruff.toml +0 -1
- habit_hooks_python-1.4.0rc1/src/habit_hooks_python/sensors/tool_spawn.py +0 -77
- habit_hooks_python-1.4.0rc1/tests/conftest.py +0 -20
- habit_hooks_python-1.4.0rc1/tests/test_a_deptry_nobody_installed_is_named.py +0 -36
- habit_hooks_python-1.4.0rc1/tests/test_a_ruff_nobody_installed_is_named.py +0 -35
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/.gitignore +0 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/README.md +0 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/__init__.py +0 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/config.toml +0 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/guides/high-complexity.md +0 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/guides/swallowed-exception.md +0 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/ruff.toml +0 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/tests/test_the_python_files_leave_installed_packages_alone.py +0 -0
- {habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/tests/test_the_ruff_pipeline_maps_codes_to_smells.py +0 -0
|
@@ -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
|
```
|
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,
|
|
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
|
-
``
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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(
|
|
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
|
-
[
|
|
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(
|
|
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(
|
|
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"
|
|
File without changes
|
|
File without changes
|
{habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/__init__.py
RENAMED
|
File without changes
|
{habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/config.toml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{habit_hooks_python-1.4.0rc1 → habit_hooks_python-1.4.0rc2}/src/habit_hooks_python/ruff.toml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|