Skip to content

Commit f1d6296

Browse files
TMHSDigitalclaude
andcommitted
feat: tools/bpy_lint.py checks code against every rule, and ships in the plugin
The rules and the porting table reached users only as prose, and the repo's own detectors covered about half the rules and scanned only this repo. bpy_lint is a stdlib scanner with one AST detector per rules/*.mdc (16), printing path:line: rule: message [skill] or --json, exit 0 clean / 1 findings / 2 usage, with a '# bpy-lint: ignore[rule] why' escape. It ships in plugin-dist (tools/ joins DIRS), and the porting skill's 'grep for the silent ones' step points at it. tests/test_bpy_lint.py requires a detector and an owning skill for every rule, and a bad fixture that fires plus a good one that stays quiet for each (falsified: disabling the principled detector fails it). Validate also keeps the shipped snippets and templates lint-clean. Detectors stay narrow on purpose: on examples/ and showcase/ the remaining hits are the deliberate pathology witnesses and cross-version shims. Closes #534 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXRvikhJZSVGBFM91Dv Signed-off-by: TM Hospitality Strategies <TMHospitalityStrategies@gmail.com>
1 parent a318b16 commit f1d6296

8 files changed

Lines changed: 562 additions & 2 deletions

File tree

‎.github/workflows/plugin-install.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ on:
2020
- "skills/**"
2121
- "claude/**"
2222
- "exports/**"
23+
- "tools/**"
2324
- "scripts/build_plugin_dist.py"
2425
- ".github/workflows/plugin-install.yml"
2526
pull_request:
@@ -31,6 +32,7 @@ on:
3132
- "skills/**"
3233
- "claude/**"
3334
- "exports/**"
35+
- "tools/**"
3436
- "scripts/build_plugin_dist.py"
3537
- ".github/workflows/plugin-install.yml"
3638

‎.github/workflows/validate.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,3 +451,9 @@ jobs:
451451

452452
- name: Extension template CI workflow contract (#531)
453453
run: python3 tests/test_template_ci.py -v
454+
455+
- name: bpy_lint detectors match the rules (#534)
456+
run: python3 tests/test_bpy_lint.py -v
457+
458+
- name: Shipped snippets and templates pass bpy_lint
459+
run: python3 tools/bpy_lint.py snippets templates

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ snippets/<snippet-name>.py - Standalone code patterns, 31 total
2424
examples/<name>/ - Runnable smoke-gated examples, 69 total (+ gallery.json)
2525
showcase/<name>/ - Budget-conformance props, 80 pieces, each with a gallery `category` (sibling of examples/; see showcase/README.md § Categories)
2626
scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json
27+
tools/bpy_lint.py - Stdlib scanner, one detector per rule; ships in plugin-dist (tests/test_bpy_lint.py keeps rules and detectors in step)
2728
scripts/site/ - Vendored landing-page build (Jinja2); tokens.css is the shared palette
2829
tests/check_site_links.py - Internal link/anchor/alt gate over the built site (docs/)
2930
docs/gallery/ - Committed generated gallery pages + hero renders

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,17 @@ With the Cursor plugin installed, the `.mdc` files in `rules/` apply automatical
229229

230230
Cursor: the plugin covers the rules (see [Quick start](#quick-start)); copying `rules/*.mdc` into a project's `.cursor/rules/` is only the per-project alternative without the plugin. Claude Code: the plugin ships the rules as the `blender-rules` skill.
231231

232+
## Checking code against the rules
233+
234+
`tools/bpy_lint.py` (stdlib only, shipped in the plugin) flags the pattern each rule names, with the owning skill:
235+
236+
```bash
237+
python tools/bpy_lint.py my_addon/ # path:line: rule: message [skill: ...]
238+
python tools/bpy_lint.py my_addon/ --json # for agents and CI
239+
```
240+
241+
Exit 0 means no findings, 1 findings, 2 usage. Silence a reviewed hit with `# bpy-lint: ignore[rule-name] <why>` on the line or the line above. The detectors are deliberately narrow: a clean run is not proof the code is right, so still run it on 4.5 LTS and 5.2 LTS.
242+
232243
## Using the templates
233244

234245
`templates/extension-addon-template/` is a working Blender extension. Copy the directory, edit `blender_manifest.toml` (id, version, name, maintainer, and `copyright`, which ships as the template author's), and install via Edit > Preferences > Get Extensions > Install From Disk. The template registers an Operator, a Panel, and a PropertyGroup, and demonstrates the `register_classes_factory` pattern with symmetric `register()` and `unregister()`.

‎scripts/build_plugin_dist.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
# the Blender Lab MCP server (launched with uvx from a pinned upstream commit;
3636
# GPL, so referenced, never vendored) and its skill. It is opt-in, so the core
3737
# plugin starts no server (#483).
38-
DIRS = ("skills", "claude", "exports", "rules", "snippets", "templates", "live-session")
38+
# tools/ carries bpy_lint.py, the stdlib scanner for the rules (#534).
39+
DIRS = ("skills", "claude", "exports", "rules", "snippets", "templates", "tools", "live-session")
3940
# The Cursor manifest ships too, so Cursor users can install the same slim
4041
# tree as a local plugin (~/.cursor/plugins/local/) instead of the full repo.
4142
# assets/favicon.svg is the Cursor manifest's "logo" and the Codex manifest's

‎skills/porting-to-blender-5/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ This skill is an index. Each row points at the skill, rule or runnable example t
2828
2. **Read the first traceback, fix it, run again.** Breaks hide behind each other: an add-on that dies importing `bgl` has not reached its `action.fcurves` call yet.
2929
3. **Map the error to the table below** by the attribute or key it names.
3030
4. **Re-run on 4.5 LTS** if you still support it. Several fixes need a version branch (rows marked *branch*). Branch on the `bpy.app.version` tuple, never on `version_string` (see the `version-branch-skeleton.py` snippet).
31-
5. **Grep for the silent ones.** Some breaks raise nothing: they quietly do the wrong thing (rows marked *silent*). Search the codebase for those patterns even if every run is green.
31+
5. **Grep for the silent ones.** Some breaks raise nothing: they quietly do the wrong thing (rows marked *silent*). Search the codebase for those patterns even if every run is green. [`tools/bpy_lint.py`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/tools/bpy_lint.py) (stdlib, `python tools/bpy_lint.py <add-on dir>`) flags every pattern the rules name, with the owning skill.
3232

3333
## The breaks the pack covers
3434

‎tests/test_bpy_lint.py‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Fixtures for tools/bpy_lint.py (#534): every rule fires on its anti-pattern and
2+
stays quiet on the fix. No Blender required.
3+
4+
python tests/test_bpy_lint.py -v
5+
"""
6+
from __future__ import annotations
7+
8+
import contextlib
9+
import io
10+
import json
11+
import os
12+
import sys
13+
import tempfile
14+
import unittest
15+
from pathlib import Path
16+
17+
REPO = Path(__file__).resolve().parent.parent
18+
sys.path.insert(0, str(REPO / "tools"))
19+
20+
import bpy_lint as bl # noqa: E402
21+
22+
# rule -> (bad source, good source). Each bad case is the rule's own "Wrong"
23+
# pattern; each good case is its "Right" pattern.
24+
CASES = {
25+
"no-bgl": ("import bgl\nbgl.glEnable(bgl.GL_BLEND)\n",
26+
"import gpu\ngpu.state.blend_set('ALPHA')\n"),
27+
"no-legacy-vse-sequences": ("se.sequences.new_effect(name='A')\n",
28+
"coll = se.strips if hasattr(se, 'strips') else se.sequences\nse.strips_all\n"),
29+
"no-removed-normals-api": ("me.use_auto_smooth = True\nme.calc_normals_split()\n",
30+
"me.set_sharp_from_angle(angle=0.5)\n"),
31+
"prefer-temp-override-over-context-copy": (
32+
"ctx = bpy.context.copy()\nbpy.ops.object.join(ctx)\n",
33+
"with bpy.context.temp_override(active_object=o):\n bpy.ops.object.join()\n"),
34+
"type-annotate-props-and-defend-context": (
35+
"class P(bpy.types.PropertyGroup):\n x = bpy.props.FloatProperty()\n"
36+
"obj = bpy.context.active_object\nobj.name = 'a'\n",
37+
"class P(bpy.types.PropertyGroup):\n x: bpy.props.FloatProperty()\n"
38+
"obj = bpy.context.active_object\nif obj is None:\n raise SystemExit(3)\n"),
39+
"use-foreach-set-for-bulk-data": ("for v in me.vertices:\n v.co.z = 0.0\n",
40+
"me.vertices.foreach_set('co', flat)\n"),
41+
"prefer-data-over-ops-in-loops": (
42+
"for o in objs:\n bpy.ops.object.shade_smooth()\n",
43+
"for o in objs:\n o.data.shade_smooth()\nbpy.ops.export_scene.gltf.get_rna_type()\n"),
44+
"always-free-bmesh": ("def f(me):\n bm = bmesh.new()\n bm.from_mesh(me)\n",
45+
"def f(me):\n bm = bmesh.new()\n try:\n bm.from_mesh(me)\n"
46+
" finally:\n bm.free()\n"),
47+
"no-legacy-action-fcurves": ("fc = obj.animation_data.action.fcurves.new('location', index=2)\n",
48+
"fc = cbag.fcurves.new('location', index=2)\n"),
49+
"principled-v2-socket-names": ('bsdf.inputs["Emission"].default_value = (1, 1, 1, 1)\nbsdf.inputs[17]\n',
50+
'bsdf.inputs["Emission Color"].default_value = (1, 1, 1, 1)\nmix.inputs[6]\n'),
51+
"no-nodes-modifier-idprop-inputs": (
52+
'mod["Socket_2"] = 2.5\n',
53+
'if bpy.app.version >= (5, 2, 0):\n getattr(mod.properties.inputs, ident).value = 2.5\n'
54+
'else:\n mod[ident] = 2.5\n'),
55+
"branch-eevee-engine-id": (
56+
"scene.render.engine = 'BLENDER_EEVEE_NEXT'\n",
57+
"def eevee():\n if bpy.app.version >= (5, 0, 0):\n return 'BLENDER_EEVEE'\n"
58+
" return 'BLENDER_EEVEE_NEXT'\n"),
59+
"validate-imported-mesh-scale": (
60+
"bpy.ops.import_scene.gltf(filepath=p)\nbm = bmesh.new()\n",
61+
"bpy.ops.import_scene.gltf(filepath=p)\nscene.unit_settings.scale_length = 1.0\n"
62+
"bpy.ops.object.transform_apply(scale=True)\nbm = bmesh.new()\n"),
63+
"no-unapplied-modifiers-on-export": (
64+
"o.modifiers.new('S', 'SUBSURF')\nbpy.ops.export_scene.gltf(filepath=p)\n",
65+
"o.modifiers.new('S', 'SUBSURF')\nbpy.ops.export_scene.gltf(filepath=p, export_apply=True)\n"),
66+
"use-correct-axis-rna-per-exporter": (
67+
"bpy.ops.export_scene.gltf(filepath=p, axis_forward='-Z')\n",
68+
"bpy.ops.export_scene.gltf(filepath=p, export_yup=True)\n"
69+
"bpy.ops.export_scene.fbx(filepath=q, axis_forward='-Z', axis_up='Y')\n"),
70+
}
71+
72+
73+
def rules_hit(src: str, path: str = "x.py") -> set[str]:
74+
return {f.rule for f in bl.lint_source(path, src)}
75+
76+
77+
class EveryRuleHasADetector(unittest.TestCase):
78+
def test_rules_dir_and_detectors_match(self):
79+
stems = {p.stem for p in (REPO / "rules").glob("*.mdc")}
80+
self.assertEqual(set(bl.DETECTORS), stems)
81+
self.assertEqual(set(bl.SKILL), stems)
82+
for rule, skill in bl.SKILL.items():
83+
self.assertTrue((REPO / "skills" / skill / "SKILL.md").is_file(), (rule, skill))
84+
85+
def test_every_detector_has_a_fixture(self):
86+
self.assertEqual(set(CASES) | {"target-extensions-platform-format"}, set(bl.DETECTORS))
87+
88+
89+
class Fixtures(unittest.TestCase):
90+
def test_bad_fires_and_good_is_quiet(self):
91+
for rule, (bad, good) in CASES.items():
92+
with self.subTest(rule=rule):
93+
self.assertIn(rule, rules_hit(bad), "bad case not flagged")
94+
self.assertNotIn(rule, rules_hit(good), "good case flagged")
95+
96+
def test_bl_info_without_manifest(self):
97+
src = "bl_info = {'name': 'x', 'blender': (4, 5, 0)}\n"
98+
with tempfile.TemporaryDirectory() as td:
99+
init = os.path.join(td, "__init__.py")
100+
self.assertIn("target-extensions-platform-format", rules_hit(src, init))
101+
Path(td, "blender_manifest.toml").write_text("id = 'x'\n", encoding="utf-8")
102+
self.assertNotIn("target-extensions-platform-format", rules_hit(src, init))
103+
104+
def test_ignore_comment(self):
105+
src = "import bgl # bpy-lint: ignore[no-bgl] 4.5-only fallback\n"
106+
self.assertNotIn("no-bgl", rules_hit(src))
107+
self.assertIn("no-bgl", rules_hit("import bgl # bpy-lint: ignore[always-free-bmesh]\n"))
108+
109+
def test_syntax_error_is_reported_not_raised(self):
110+
self.assertEqual(rules_hit("def (:\n"), {"syntax"})
111+
112+
113+
class Cli(unittest.TestCase):
114+
def run_cli(self, src, *extra):
115+
with tempfile.TemporaryDirectory() as td:
116+
p = Path(td, "a.py")
117+
p.write_text(src, encoding="utf-8")
118+
out, err = io.StringIO(), io.StringIO()
119+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
120+
code = bl.main([str(p), *extra])
121+
return code, out.getvalue()
122+
123+
def test_exit_codes(self):
124+
self.assertEqual(self.run_cli("x = 1\n")[0], 0)
125+
self.assertEqual(self.run_cli("import bgl\n")[0], 1)
126+
127+
def test_json_output(self):
128+
code, out = self.run_cli("import bgl\n", "--json")
129+
rows = json.loads(out)
130+
self.assertEqual(code, 1)
131+
self.assertEqual(rows[0]["rule"], "no-bgl")
132+
self.assertEqual(rows[0]["skill"], "gpu-drawing-and-overlays")
133+
134+
def test_rule_filter(self):
135+
code, out = self.run_cli("import bgl\nme.use_auto_smooth = True\n", "--rule", "no-bgl")
136+
self.assertIn("no-bgl", out)
137+
self.assertNotIn("no-removed-normals-api", out)
138+
139+
140+
if __name__ == "__main__":
141+
unittest.main()

0 commit comments

Comments
 (0)