habit-hooks-python 1.0.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,20 @@
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
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: habit-hooks-python
3
+ Version: 1.0.0
4
+ Summary: The Python Habit Hooks plugin
5
+ Requires-Python: >=3.11
@@ -0,0 +1,249 @@
1
+ # The python plugin — acceptance
2
+
3
+ The python plugin runs its sensors through the real `habit-sensors` pipeline.
4
+ These cases run the **actual** tools (`ruff`, `deptry`) against a fixture with a
5
+ known smell and assert the canonical finding comes out, mapped to the smell keys
6
+ in [smell-vocabulary.md](smell-vocabulary.md).
7
+
8
+ 📄.habit-hooks/config.toml
9
+ ```toml
10
+ plugins = ["python"]
11
+ ```
12
+
13
+ ## ruff adapter maps rule IDs to canonical smells
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`.
19
+
20
+ 📄ruff.toml @plugins/python/src/habit_hooks_python/ruff.toml
21
+
22
+ 📄pyproject.toml
23
+ ```toml
24
+ [project]
25
+ name = "demo"
26
+ version = "0.0.0"
27
+ ```
28
+
29
+ 📄billing.py
30
+ ```python
31
+ import os
32
+
33
+
34
+ def charge(a, b, c, d):
35
+ unused = 1
36
+ return a + b + c + d
37
+ ```
38
+
39
+ ```bash
40
+ habit-sensors --all | jq 'sort_by(.smell)[] | {smell, language, key: (.issues[0].key | sub(".*/"; "")), line: .issues[0].details.line, source: .issues[0].details.source}'
41
+ ```
42
+
43
+ 🖥️ ✅
44
+ ```json
45
+ {
46
+ "smell": "too-many-parameters",
47
+ "language": "python",
48
+ "key": "billing.py",
49
+ "line": 4,
50
+ "source": "ruff:PLR0913"
51
+ }
52
+ {
53
+ "smell": "unused-import",
54
+ "language": "python",
55
+ "key": "billing.py",
56
+ "line": 1,
57
+ "source": "ruff:F401"
58
+ }
59
+ {
60
+ "smell": "unused-variable",
61
+ "language": "python",
62
+ "key": "billing.py",
63
+ "line": 5,
64
+ "source": "ruff:F841"
65
+ }
66
+ ```
67
+
68
+ ## ruff maps a syntax error to parse-error
69
+
70
+ A file ruff cannot parse surfaces as `parse-error` (ruff reports it as
71
+ `invalid-syntax`), not a null smell, so a broken file is still coached rather
72
+ than silently mislabelled. This mirrors the TS plugin, where `eslint:fatal`
73
+ maps to `parse-error`.
74
+
75
+ 📄.habit-hooks/config.toml
76
+ ```toml
77
+ plugins = ["python"]
78
+
79
+ [sensors.deptry]
80
+ disabled = true
81
+ ```
82
+
83
+ 📄ruff.toml @plugins/python/src/habit_hooks_python/ruff.toml
84
+
85
+ 📄broken.py
86
+ ```python
87
+ def broken(:
88
+ return 1
89
+ ```
90
+
91
+ ```bash
92
+ habit-sensors --all | jq '.[] | {smell, source: .issues[0].details.source}'
93
+ ```
94
+
95
+ 🖥️ ✅
96
+ ```json
97
+ {
98
+ "smell": "parse-error",
99
+ "source": "ruff:invalid-syntax"
100
+ }
101
+ ```
102
+
103
+ ## deptry sensor maps DEP002 to unused-dependency
104
+
105
+ The `deptry` sensor runs deptry against a temp JSON report and shapes each
106
+ `DEP002` (a declared but unused dependency) into an `unused-dependency` finding,
107
+ one issue per module keyed by the module name.
108
+
109
+ 📄.habit-hooks/config.toml
110
+ ```toml
111
+ plugins = ["python"]
112
+
113
+ [sensors.ruff]
114
+ disabled = true
115
+ ```
116
+
117
+ 📄pyproject.toml
118
+ ```toml
119
+ [project]
120
+ name = "demo"
121
+ version = "0.0.0"
122
+ dependencies = ["requests", "rich"]
123
+ ```
124
+
125
+ 📄app.py
126
+ ```python
127
+ import requests
128
+
129
+
130
+ def fetch(url):
131
+ return requests.get(url).text
132
+ ```
133
+
134
+ ```bash
135
+ habit-sensors --all | jq '.[] | {smell, language, key: .issues[0].key, file: .issues[0].details.file, source: .issues[0].details.source}'
136
+ ```
137
+
138
+ 🖥️ ✅
139
+ ```json
140
+ {
141
+ "smell": "unused-dependency",
142
+ "language": "python",
143
+ "key": "rich",
144
+ "file": "pyproject.toml",
145
+ "source": "deptry:DEP002"
146
+ }
147
+ ```
148
+
149
+ ## A crashing deptry fails the run, never reports clean
150
+
151
+ deptry needs a `pyproject.toml` to analyse; without one it exits non-zero
152
+ instead of emitting findings. The sensor must surface that as a failure — a
153
+ crashed tool is never a clean run. The sensor exits with a code outside the
154
+ findings range, so `habit-sensors` raises, names the sensor on stderr, and exits
155
+ 1 rather than printing an empty (false-clean) result.
156
+
157
+ 📄.habit-hooks/config.toml
158
+ ```toml
159
+ plugins = ["python"]
160
+
161
+ [sensors.ruff]
162
+ disabled = true
163
+ ```
164
+
165
+ 📄app.py
166
+ ```python
167
+ def fetch(url):
168
+ return url
169
+ ```
170
+
171
+ ```bash
172
+ habit-sensors --all
173
+ ```
174
+
175
+ 🖥️ ❌ 1
176
+ ```json
177
+ []
178
+ ```
179
+
180
+ 🚨
181
+ ```text
182
+ habit-sensors: sensor 'deptry' failed: python ${dir}/deptry_sensor.py
183
+ ```
184
+
185
+ ## A crashing ruff fails the run, never reports clean
186
+
187
+ The `ruff` sensor pipes the tool into `jq`. A crashing `ruff` (here, a malformed
188
+ `ruff.toml` that makes the tool exit non-zero) prints nothing on stdout, so a
189
+ naive pipe would let `jq` succeed on empty input and mask the crash as a false-
190
+ clean run. The command sets `pipefail` so the tool's failing exit propagates
191
+ through the pipe; `habit-sensors` then raises, names the sensor on stderr, and
192
+ exits 1.
193
+
194
+ 📄.habit-hooks/config.toml
195
+ ```toml
196
+ plugins = ["python"]
197
+
198
+ [sensors.deptry]
199
+ disabled = true
200
+ ```
201
+
202
+ 📄ruff.toml
203
+ ```toml
204
+ this is not = valid ruff config
205
+ ```
206
+
207
+ 📄app.py
208
+ ```python
209
+ import os
210
+ ```
211
+
212
+ ```bash
213
+ habit-sensors --all
214
+ ```
215
+
216
+ 🖥️ ❌ 1
217
+ ```json
218
+ []
219
+ ```
220
+
221
+ 🚨
222
+ ```text
223
+ 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
+ ```
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "habit-hooks-python"
3
+ version = "1.0.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"]
@@ -0,0 +1 @@
1
+ """The python Habit Hooks plugin: package data discovered via the habit_hooks.plugins entry point."""
@@ -0,0 +1,5 @@
1
+ # Python plugin defaults.
2
+ language = "python"
3
+ files = ["**/*.py"]
4
+ sensors = ["ruff", "deptry"]
5
+ transformers = []
@@ -0,0 +1,12 @@
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
+
3
+ {% include "includes/line_level_issues.md" %}
4
+ Work through it in order:
5
+
6
+ 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.
7
+ 2. **Lift the guards out first.** Turn early-exit conditions into guard clauses (`if not user: return None`) so the happy path stays flat and left-aligned. Most of the count is preconditions wrapped around the real work.
8
+ 3. **Change the shape, do not just move it.** When every branch is the same kind of decision, reshape it: a long `if/elif` on a value is often a dict dispatch or polymorphism, a nested loop often a comprehension or a generator. When polymorphism is not the right fit and the branches are genuinely separate jobs, extract one named method per branch, each named for the responsibility it handles. The goal is fewer decisions in one place, not the same tangle split into `_part1` / `_part2` behind worse names.
9
+
10
+ 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
+
12
+ 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.
@@ -0,0 +1,12 @@
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
+
3
+ {% include "includes/line_level_issues.md" %}
4
+ Work through it in order:
5
+
6
+ 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.
7
+ 2. **Make the decision visible.** Recover from it, add context and re-raise (`raise ... from err`), or, at a boundary that has to stay alive, log the full traceback (`logging.exception(...)`) and continue. Logging and returning a default is a real option, not automatically a swallow.
8
+ 3. **The test is not whether you re-raised.** Ask one question: if this fires at 3am, will anyone know it happened, and will they know why? What turns a catch into a swallow is doing it blindly: a wide catch, no named error, nothing logged, the failure gone without a trace. If the answer is no, it is still a swallow, however you dressed it up.
9
+
10
+ Narrowing the type or adding `# noqa` only to quiet ruff is not a fix if the error is still discarded.
11
+
12
+ If you are unsure whether this catch is a real decision or a reflex, check with a human before you keep it.
@@ -0,0 +1,6 @@
1
+ [lint.mccabe]
2
+ max-complexity = 10
3
+
4
+ [lint.pylint]
5
+ max-args = 3
6
+ max-statements = 12
@@ -0,0 +1 @@
1
+ command = "python ${dir}/deptry_sensor.py"
@@ -0,0 +1,70 @@
1
+ """Run deptry and print ``unused-dependency`` findings.
2
+
3
+ deptry's stdout is unreliable when piped, so this wrapper runs it against a temp
4
+ JSON report, reads that report, and shapes each ``DEP002`` (a declared but unused
5
+ dependency) into the canonical finding.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+
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
+ )
23
+
24
+
25
+ def deptry_crashed(result: subprocess.CompletedProcess[str], report: Path) -> bool:
26
+ return result.returncode not in (0, 1) or not report.is_file()
27
+
28
+
29
+ def unused_dependencies(report: Path) -> list[dict]:
30
+ entries = json.loads(report.read_text())
31
+ return [entry for entry in entries if entry["error"]["code"] == "DEP002"]
32
+
33
+
34
+ def issue(entry: dict) -> dict:
35
+ return {
36
+ "key": entry["module"],
37
+ "details": {
38
+ "module": entry["module"],
39
+ "file": entry["location"]["file"],
40
+ "message": entry["error"]["message"],
41
+ "source": "deptry:DEP002",
42
+ },
43
+ }
44
+
45
+
46
+ def findings(entries: list[dict]) -> list[dict]:
47
+ if not entries:
48
+ return []
49
+ return [
50
+ {
51
+ "smell": "unused-dependency",
52
+ "details": {},
53
+ "issues": [issue(entry) for entry in entries],
54
+ }
55
+ ]
56
+
57
+
58
+ def main() -> int:
59
+ with tempfile.TemporaryDirectory() as tmp:
60
+ report = Path(tmp) / "deptry-report.json"
61
+ result = run_deptry(report)
62
+ if deptry_crashed(result, report):
63
+ sys.stderr.write(result.stderr)
64
+ return 2
65
+ print(json.dumps(findings(unused_dependencies(report))))
66
+ return 0
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())
@@ -0,0 +1,28 @@
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
+ """