habit-hooks-python 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ # Per-plugin Node tool deps (installed via pnpm install; see pnpm-workspace.yaml)
2
+ node_modules/
3
+ dist
4
+ coverage
5
+ .DS_Store
6
+ .idea
7
+ .claude-channel/
8
+ *.tgz
9
+ *.log
10
+ .vscode/
11
+ .venv/
12
+ __pycache__/
13
+ *.pyc
14
+ .pytest_cache/
15
+ .spec-runs/
16
+
17
+ # Workflow orchestration script (run from ~/.claude, never a repo deliverable)
18
+ .claude/workflows-build-overnight.js
19
+
20
+ # Agent worktrees (created by the harness inside the checkout)
21
+ .claude/worktrees/
22
+ /scratches/
23
+
24
+ # unsupervised-issues run signals (live mode switch + stop flag)
25
+ .unsupervised-issues.mode
26
+ .unsupervised-issues.stop
@@ -1,5 +1,5 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: habit-hooks-python
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: The Python Habit Hooks plugin
5
5
  Requires-Python: >=3.11
@@ -65,6 +65,112 @@ habit-sensors --all | jq 'sort_by(.smell)[] | {smell, language, key: (.issues[0]
65
65
  }
66
66
  ```
67
67
 
68
+ ## An empty scope runs no sensor, so ruff never scans the whole tree
69
+
70
+ A scoped run that resolves to **zero** files measured nothing, and no sensor may
71
+ run over it. `ruff` handed no paths falls back to its own default — scan the
72
+ current directory — so without this guard a non-source-only change reports every
73
+ legacy smell in the tree and fails the run (#93). Here `[files]` counts only
74
+ `**/*.py` as source (the plugin default) and `--file README.md` names a doc, so
75
+ the scope is empty even though `legacy.py` carries three real smells. The run
76
+ emits `[]` and exits 0, and the scope layer still says on stderr that it scanned
77
+ nothing.
78
+
79
+ 📄.habit-hooks/config.toml
80
+ ```toml
81
+ plugins = ["python"]
82
+
83
+ [sensors.deptry]
84
+ disabled = true
85
+ ```
86
+
87
+ 📄ruff.toml @plugins/python/src/habit_hooks_python/ruff.toml
88
+
89
+ 📄pyproject.toml
90
+ ```toml
91
+ [project]
92
+ name = "demo"
93
+ version = "0.0.0"
94
+ ```
95
+
96
+ 📄legacy.py
97
+ ```python
98
+ import os
99
+
100
+
101
+ def charge(a, b, c, d):
102
+ unused = 1
103
+ return a + b + c + d
104
+ ```
105
+
106
+ 📄README.md
107
+ ```text
108
+ # docs
109
+ ```
110
+
111
+ ```bash
112
+ habit-sensors --file README.md
113
+ ```
114
+
115
+ 🖥️ ✅
116
+ ```json
117
+ []
118
+ ```
119
+
120
+ 🚨
121
+ ```text
122
+ habit-sensors: --file 'README.md' is outside [files]; nothing scanned
123
+ ```
124
+
125
+ ## A sensor narrowed to no files is as empty a scope as a run with none
126
+
127
+ `[sensors.<name>] files` narrows the run's scope for that sensor alone, and it
128
+ can leave that sensor nothing while the run as a whole still measured something.
129
+ Its scope is then as empty as the case above's, with the same consequence — so
130
+ the guard is asked per sensor, not once for the run. Here `legacy.py` is in
131
+ scope and carries three real smells, but `ruff` is narrowed to `src/**/*.py`,
132
+ which holds none of them: ruff never runs, and never falls back to scanning the
133
+ tree it was steered away from.
134
+
135
+ 📄.habit-hooks/config.toml
136
+ ```toml
137
+ plugins = ["python"]
138
+
139
+ [sensors.ruff]
140
+ files = ["src/**/*.py"]
141
+
142
+ [sensors.deptry]
143
+ disabled = true
144
+ ```
145
+
146
+ 📄ruff.toml @plugins/python/src/habit_hooks_python/ruff.toml
147
+
148
+ 📄pyproject.toml
149
+ ```toml
150
+ [project]
151
+ name = "demo"
152
+ version = "0.0.0"
153
+ ```
154
+
155
+ 📄legacy.py
156
+ ```python
157
+ import os
158
+
159
+
160
+ def charge(a, b, c, d):
161
+ unused = 1
162
+ return a + b + c + d
163
+ ```
164
+
165
+ ```bash
166
+ habit-sensors --all
167
+ ```
168
+
169
+ 🖥️ ✅
170
+ ```json
171
+ []
172
+ ```
173
+
68
174
  ## ruff maps a syntax error to parse-error
69
175
 
70
176
  A file ruff cannot parse surfaces as `parse-error` (ruff reports it as
@@ -152,7 +258,13 @@ deptry needs a `pyproject.toml` to analyse; without one it exits non-zero
152
258
  instead of emitting findings. The sensor must surface that as a failure — a
153
259
  crashed tool is never a clean run. The sensor exits with a code outside the
154
260
  findings range, so `habit-sensors` raises, names the sensor on stderr, and exits
155
- 1 rather than printing an empty (false-clean) result.
261
+ 1 rather than printing an empty (false-clean) result. The failed run carries only
262
+ the reserved `incomplete-run` marker on stdout
263
+ ([habit-sensors.spec.md](../../../docs/habit-sensors.spec.md)).
264
+
265
+ The notice carries deptry's own diagnosis after that first line. That text is
266
+ deptry's to word and names absolute paths, so only the line naming the sensor is
267
+ asserted here.
156
268
 
157
269
  📄.habit-hooks/config.toml
158
270
  ```toml
@@ -169,17 +281,21 @@ def fetch(url):
169
281
  ```
170
282
 
171
283
  ```bash
172
- habit-sensors --all
284
+ habit-sensors --all | jq -c '[.[].smell]'
173
285
  ```
174
286
 
175
287
  🖥️ ❌ 1
176
288
  ```json
177
- []
289
+ ["incomplete-run"]
178
290
  ```
179
291
 
180
- 🚨
292
+ ```bash
293
+ habit-sensors --all 2>&1 >/dev/null | sed -n 1p
294
+ ```
295
+
296
+ 🖥️ ❌ 1
181
297
  ```text
182
- habit-sensors: sensor 'deptry' failed: python ${dir}/deptry_sensor.py
298
+ habit-sensors: sensor 'deptry' failed: ${python} ${dir}/deptry_sensor.py
183
299
  ```
184
300
 
185
301
  ## A crashing ruff fails the run, never reports clean
@@ -189,7 +305,8 @@ The `ruff` sensor pipes the tool into `jq`. A crashing `ruff` (here, a malformed
189
305
  naive pipe would let `jq` succeed on empty input and mask the crash as a false-
190
306
  clean run. The command sets `pipefail` so the tool's failing exit propagates
191
307
  through the pipe; `habit-sensors` then raises, names the sensor on stderr, and
192
- exits 1.
308
+ exits 1, carrying only the reserved `incomplete-run` marker on stdout
309
+ ([habit-sensors.spec.md](../../../docs/habit-sensors.spec.md)).
193
310
 
194
311
  📄.habit-hooks/config.toml
195
312
  ```toml
@@ -210,40 +327,24 @@ import os
210
327
  ```
211
328
 
212
329
  ```bash
213
- habit-sensors --all
330
+ habit-sensors --all | jq -c '[.[].smell]'
214
331
  ```
215
332
 
216
333
  🖥️ ❌ 1
217
334
  ```json
218
- []
335
+ ["incomplete-run"]
219
336
  ```
220
337
 
221
- 🚨
338
+ The notice quotes the sensor's whole command — multi-line here, so its first
339
+ line shows only where that command starts — and then ruff's own diagnosis, which
340
+ names the absolute path of the config it could not parse. Only the line naming
341
+ the sensor is stable enough to assert.
342
+
343
+ ```bash
344
+ habit-sensors --all 2>&1 >/dev/null | sed -n 1p
345
+ ```
346
+
347
+ 🖥️ ❌ 1
222
348
  ```text
223
349
  habit-sensors: sensor 'ruff' failed: set -o pipefail
224
- ruff check --output-format=json --select=C901,PLR0913,PLR0915,F841,F401,BLE001 ${files} | jq '
225
- map(. + {smell: ({
226
- "C901": "high-complexity",
227
- "PLR0913": "too-many-parameters",
228
- "PLR0915": "oversized-function",
229
- "F841": "unused-variable",
230
- "F401": "unused-import",
231
- "BLE001": "swallowed-exception",
232
- "invalid-syntax": "parse-error"
233
- }[.code])})
234
- | group_by(.smell)
235
- | map({
236
- smell: .[0].smell,
237
- details: {},
238
- issues: map({
239
- key: .filename,
240
- details: {
241
- file: .filename,
242
- line: .location.row,
243
- column: .location.column,
244
- message: .message,
245
- source: ("ruff:" + .code)
246
- }
247
- })
248
- })'
249
350
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "habit-hooks-python"
3
- version = "1.0.0"
3
+ version = "1.1.0"
4
4
  description = "The Python Habit Hooks plugin"
5
5
  requires-python = ">=3.11"
6
6
  dependencies = []
@@ -1,6 +1,5 @@
1
1
  High cyclomatic complexity means one function is making too many decisions at once. The smell is not the number, it is that the function has quietly taken on more than one job. The count is the symptom, tangled responsibilities are the cause.
2
2
 
3
- {% include "includes/line_level_issues.md" %}
4
3
  Work through it in order:
5
4
 
6
5
  1. **Name what each branch is for.** Give every branch a one-sentence description of the responsibility it handles. If two branches describe the same thing, they belong together. If a branch has no clean name, that path probably wants its own function.
@@ -10,3 +9,5 @@ Work through it in order:
10
9
  Reducing the number without reducing the tangle is not a fix. Do not merge conditions with `and`/`or` just to drop a branch, and do not rewrite `if` statements as ternaries to slip under the check. ruff's mccabe does not count ternary expressions, so that trick lowers the score while a human still has to hold every condition in their head.
11
10
 
12
11
  The test is not whether the number dropped. It is whether someone reading the function for the first time can hold it in their head at once. If not, it is still doing too much.
12
+
13
+ {% include "includes/line_level_issues.md" %}
@@ -1,6 +1,5 @@
1
1
  A broad `except` (`except:`, `except Exception`, `except BaseException`) that discards the error silently is hiding a failure you have not understood, not handling one you planned for. Before you write it, name the specific error you expect and why. That one sentence is usually the fix.
2
2
 
3
- {% include "includes/line_level_issues.md" %}
4
3
  Work through it in order:
5
4
 
6
5
  1. **Catch only what you can name.** `ValueError`, `KeyError`, `TimeoutError`, whatever the call really raises. If you cannot name it, you are guessing, and every other error should stay free to surface where someone can see it.
@@ -10,3 +9,5 @@ Work through it in order:
10
9
  Narrowing the type or adding `# noqa` only to quiet ruff is not a fix if the error is still discarded.
11
10
 
12
11
  If you are unsure whether this catch is a real decision or a reflex, check with a human before you keep it.
12
+
13
+ {% include "includes/line_level_issues.md" %}
@@ -0,0 +1 @@
1
+ command = "${python} ${dir}/deptry_sensor.py"
@@ -15,11 +15,22 @@ from pathlib import Path
15
15
 
16
16
 
17
17
  def run_deptry(report: Path) -> subprocess.CompletedProcess[str]:
18
- return subprocess.run(
19
- ["deptry", ".", "--json-output", str(report)],
20
- capture_output=True,
21
- text=True,
22
- )
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.
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
+ )
23
34
 
24
35
 
25
36
  def deptry_crashed(result: subprocess.CompletedProcess[str], report: Path) -> bool:
@@ -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. 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"
@@ -1,20 +0,0 @@
1
- # Per-plugin Node tool deps (installed via npm ci; see plugins/*/package.json)
2
- node_modules/
3
- dist
4
- coverage
5
- .DS_Store
6
- .idea
7
- .claude-channel/
8
- *.tgz
9
- *.log
10
- .vscode/
11
- .venv/
12
- __pycache__/
13
- *.pyc
14
- .pytest_cache/
15
- .spec-runs/
16
-
17
- /ruff.toml
18
-
19
- # Workflow orchestration script (run from ~/.claude, never a repo deliverable)
20
- .claude/workflows-build-overnight.js
@@ -1 +0,0 @@
1
- command = "python ${dir}/deptry_sensor.py"