modern-python-guidance 0.4.4__tar.gz → 0.5.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.
Files changed (139) hide show
  1. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/CHANGELOG.md +25 -0
  2. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/PKG-INFO +29 -1
  3. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/README.md +28 -0
  4. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/docs/design.md +8 -4
  5. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/pyproject.toml +2 -1
  6. modern_python_guidance-0.5.0/rules/modern-python.md +31 -0
  7. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/__init__.py +1 -1
  8. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/cli.py +74 -1
  9. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/mcp_server.py +29 -5
  10. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/setup_cmd.py +38 -13
  11. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_cli_integration.py +83 -0
  12. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_cli_unit.py +104 -0
  13. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_guide_structure.py +19 -13
  14. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_mcp_unit.py +15 -5
  15. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_setup.py +15 -0
  16. modern_python_guidance-0.4.4/rules/modern-python.md +0 -70
  17. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/.github/workflows/check-python-release.yml +0 -0
  18. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/.github/workflows/ci.yml +0 -0
  19. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/.github/workflows/publish.yml +0 -0
  20. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/.gitignore +0 -0
  21. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/CONTRIBUTING.md +0 -0
  22. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/LICENSE +0 -0
  23. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/LICENSE-MIT +0 -0
  24. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/SECURITY.md +0 -0
  25. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/edge-cases/opus48_multiline.py +0 -0
  26. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/edge-cases/valid_alt_patterns.py +0 -0
  27. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-modern/pyproject.toml +0 -0
  28. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-modern/src/app.py +0 -0
  29. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-modern/src/config.py +0 -0
  30. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-modern/src/crawler.py +0 -0
  31. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-modern/src/models.py +0 -0
  32. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-modern/src/scanner.py +0 -0
  33. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-modern/src/utils.py +0 -0
  34. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-outdated/pyproject.toml +0 -0
  35. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-outdated/setup.py +0 -0
  36. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-outdated/src/app.py +0 -0
  37. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-outdated/src/config.py +0 -0
  38. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-outdated/src/crawler.py +0 -0
  39. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-outdated/src/models.py +0 -0
  40. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-outdated/src/scanner.py +0 -0
  41. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-a-outdated/src/utils.py +0 -0
  42. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-b-modern/myapp/models.py +0 -0
  43. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-b-modern/myapp/views.py +0 -0
  44. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-b-outdated/myapp/models.py +0 -0
  45. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-b-outdated/myapp/views.py +0 -0
  46. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-c-modern/tests/test_calculator.py +0 -0
  47. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/fixtures/variant-c-outdated/tests/test_calculator.py +0 -0
  48. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/mcp-config.json +0 -0
  49. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompt-v2.txt +0 -0
  50. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompt-v3-mcp.txt +0 -0
  51. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompt-v3.txt +0 -0
  52. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompt-v4-a.txt +0 -0
  53. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompt-v4-b.txt +0 -0
  54. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompt-v4-c.txt +0 -0
  55. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompt.txt +0 -0
  56. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompts/v5-a-detailed.txt +0 -0
  57. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompts/v5-a-normal.txt +0 -0
  58. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompts/v5-a-terse.txt +0 -0
  59. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompts/v5-b-detailed.txt +0 -0
  60. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompts/v5-b-normal.txt +0 -0
  61. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompts/v5-b-terse.txt +0 -0
  62. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompts/v5-c-detailed.txt +0 -0
  63. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompts/v5-c-normal.txt +0 -0
  64. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/prompts/v5-c-terse.txt +0 -0
  65. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/run-mcp.sh +0 -0
  66. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/run-v4.sh +0 -0
  67. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/run-v5.sh +0 -0
  68. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/run.sh +0 -0
  69. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/score-v2.sh +0 -0
  70. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/score-v3.sh +0 -0
  71. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/score-v4.sh +0 -0
  72. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/score.sh +0 -0
  73. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/score_v5.py +0 -0
  74. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/bench/test-scorer.sh +0 -0
  75. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/docs/benchmark-evaluation.md +0 -0
  76. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/docs/benchmark-procedure.md +0 -0
  77. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/docs/benchmark-v5.md +0 -0
  78. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/SKILL.md +0 -0
  79. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
  80. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
  81. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
  82. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -0
  83. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/data-structures/dict-merge-operator.md +0 -0
  84. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/data-structures/match-case-patterns.md +0 -0
  85. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/django/django-async-views.md +0 -0
  86. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/django/django-check-constraints.md +0 -0
  87. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/django/django-json-field.md +0 -0
  88. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
  89. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +0 -0
  90. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +0 -0
  91. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/httpx/httpx-async-client-reuse.md +0 -0
  92. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
  93. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
  94. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-model-api.md +0 -0
  95. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
  96. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
  97. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/pytest/pytest-parametrize.md +0 -0
  98. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/pytest/pytest-raises-match.md +0 -0
  99. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/pytest/pytest-tmp-path.md +0 -0
  100. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-2-style.md +0 -0
  101. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-async-session.md +0 -0
  102. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-mapped-column.md +0 -0
  103. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
  104. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/stdlib/pathlib-over-os-path.md +0 -0
  105. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
  106. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/stdlib/template-strings.md +0 -0
  107. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
  108. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
  109. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/toolchain/pyproject-toml-over-setup.md +0 -0
  110. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
  111. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
  112. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
  113. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/typing/deferred-annotations.md +0 -0
  114. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
  115. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
  116. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
  117. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
  118. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
  119. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
  120. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/__main__.py +0 -0
  121. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/check.py +0 -0
  122. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/compat.py +0 -0
  123. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/frontmatter.py +0 -0
  124. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/guide_index.py +0 -0
  125. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/retrieve.py +0 -0
  126. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/search.py +0 -0
  127. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/uninstall_cmd.py +0 -0
  128. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/src/modern_python_guidance/version_detect.py +0 -0
  129. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_check.py +0 -0
  130. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_compat.py +0 -0
  131. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_frontmatter.py +0 -0
  132. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_guide_index.py +0 -0
  133. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_mcp_server.py +0 -0
  134. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_retrieve.py +0 -0
  135. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_scorer_v5.py +0 -0
  136. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_search.py +0 -0
  137. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_skill_sync.py +0 -0
  138. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_uninstall.py +0 -0
  139. {modern_python_guidance-0.4.4 → modern_python_guidance-0.5.0}/tests/test_version_detect.py +0 -0
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.5.0] — 2026-06-06
6
+
7
+ ### Changed
8
+
9
+ - Rules file (`rules/modern-python.md`) rewritten from full content (70 lines) to thin format (~30 lines): category index with all 41 guide IDs, top-5 high-frequency one-liner patterns, and MCP/CLI call-to-action. When the Rules file freezes as a static copy in git-tracked workspaces (symlink-to-file degradation), the thin format causes minimal stale-content damage — guide IDs rarely change. Run `mpg setup` to update the Rules file. (closes #109)
10
+
11
+ ### Added
12
+
13
+ - `mpg hook claude-post-tool-use` subcommand: PostToolUse hook that reads stdin JSON from Claude Code, checks `.py` files for outdated patterns via `check_file()`, and surfaces findings as stderr feedback (exit 2). Non-Python files, missing files, and clean files produce no output (exit 0). No jq or shell wrapper required.
14
+ - `mpg check --quiet` flag: suppresses "No outdated patterns found." output on clean files in human format. JSON format is unaffected.
15
+ - `mpg setup` now prints a PostToolUse hook hint after successful setup.
16
+ - README: new "Recommended hooks" section with copy-pasteable `.claude/settings.json` example.
17
+ - 29 new tests (1028 total).
18
+
19
+ ## [0.4.5] — 2026-06-06
20
+
21
+ ### Fixed
22
+
23
+ - MCP `retrieve_guides` maxItems hardcoded to 41: replaced with `_guide_limit()` that derives the limit from actual guide count at runtime. `_get_tools()` dynamically injects `maxItems` and description into the `tools/list` schema. Adding new guides no longer requires updating `mcp_server.py`. (closes #98)
24
+ - `docs/design.md` out of sync with v0.4.4 implementation: consolidated overlapping non-goals, added `check`/`setup`/`uninstall` to CLI architecture diagram, added `check.py`, `setup_cmd.py`, `uninstall_cmd.py`, `mcp_server.py` to module responsibility table, fixed Layer 1 guide count from 16 to 18. (closes #99)
25
+
26
+ ### Added
27
+
28
+ - 1 new test (1000 total).
29
+
5
30
  ## [0.4.4] — 2026-06-05
6
31
 
7
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modern-python-guidance
3
- Version: 0.4.4
3
+ Version: 0.5.0
4
4
  Summary: Version-aware BAD/GOOD pattern guides that help AI coding agents generate modern Python
5
5
  Project-URL: Homepage, https://github.com/yottayoshida/modern-python-guidance
6
6
  Project-URL: Repository, https://github.com/yottayoshida/modern-python-guidance
@@ -176,6 +176,34 @@ mpg list --python-version 3.9
176
176
  # Excludes: TaskGroup (3.11+), match/case (3.10+), etc.
177
177
  ```
178
178
 
179
+ ## Recommended hooks
180
+
181
+ Add a [PostToolUse hook](https://docs.anthropic.com/en/docs/claude-code/hooks) to auto-check Python files whenever Claude edits them. Create or update `.claude/settings.json` in your project:
182
+
183
+ ```json
184
+ {
185
+ "hooks": {
186
+ "PostToolUse": [
187
+ {
188
+ "matcher": "tool == \"Edit\" || tool == \"Write\" || tool == \"MultiEdit\"",
189
+ "hooks": [
190
+ {
191
+ "type": "command",
192
+ "command": "mpg hook claude-post-tool-use"
193
+ }
194
+ ]
195
+ }
196
+ ]
197
+ }
198
+ }
199
+ ```
200
+
201
+ The hook reads stdin from Claude Code, checks any `.py` file for outdated patterns, and surfaces findings as inline feedback. Non-Python files and clean files produce no output.
202
+
203
+ Verify with `/hooks` in Claude Code to confirm the hook is active.
204
+
205
+ For manual CLI use, `mpg check --quiet <file>` provides the same check without the "no patterns found" message.
206
+
179
207
  ## Development
180
208
 
181
209
  ```bash
@@ -144,6 +144,34 @@ mpg list --python-version 3.9
144
144
  # Excludes: TaskGroup (3.11+), match/case (3.10+), etc.
145
145
  ```
146
146
 
147
+ ## Recommended hooks
148
+
149
+ Add a [PostToolUse hook](https://docs.anthropic.com/en/docs/claude-code/hooks) to auto-check Python files whenever Claude edits them. Create or update `.claude/settings.json` in your project:
150
+
151
+ ```json
152
+ {
153
+ "hooks": {
154
+ "PostToolUse": [
155
+ {
156
+ "matcher": "tool == \"Edit\" || tool == \"Write\" || tool == \"MultiEdit\"",
157
+ "hooks": [
158
+ {
159
+ "type": "command",
160
+ "command": "mpg hook claude-post-tool-use"
161
+ }
162
+ ]
163
+ }
164
+ ]
165
+ }
166
+ }
167
+ ```
168
+
169
+ The hook reads stdin from Claude Code, checks any `.py` file for outdated patterns, and surfaces findings as inline feedback. Non-Python files and clean files produce no output.
170
+
171
+ Verify with `/hooks` in Claude Code to confirm the hook is active.
172
+
173
+ For manual CLI use, `mpg check --quiet <file>` provides the same check without the "no patterns found" message.
174
+
147
175
  ## Development
148
176
 
149
177
  ```bash
@@ -14,16 +14,16 @@ LLMs frequently generate outdated Python patterns: `typing.List` instead of `lis
14
14
 
15
15
  ## Non-goals
16
16
 
17
- - Automated code transformation (this is a reference tool, not a codemod)
17
+ - Automated code transformation / codemod (the `check` command scans for outdated patterns via regex + tokenize, but does not rewrite code)
18
18
  - Language Server Protocol integration
19
- - Pattern detection in source code (no AST analysis)
20
19
 
21
20
  ## Architecture
22
21
 
23
22
  ```
24
23
  ┌─────────────────────────────────────────────────────────┐
25
24
  │ CLI (cli.py) │
26
- │ search │ retrieve │ list │ detect-version
25
+ │ search │ retrieve │ list │ detect-version check │
26
+ │ setup │ uninstall │
27
27
  ├─────────┴──────────┴──────┴─────────────────────────────┤
28
28
  │ │
29
29
  │ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
@@ -67,6 +67,10 @@ LLMs frequently generate outdated Python patterns: `typing.List` instead of `lis
67
67
  | `retrieve.py` | Renders guide content as JSON with version-match flag and token estimate |
68
68
  | `version_detect.py` | Detects target Python version from `--python-version` flag, `pyproject.toml` (`requires-python`), `.python-version` file, or default (3.11) |
69
69
  | `compat.py` | `version_compatible()` using `packaging.specifiers` and `token_estimate()` (chars / 4) |
70
+ | `check.py` | Scan a Python file for outdated patterns against guide definitions (regex + tokenize, not AST) |
71
+ | `setup_cmd.py` | Automate MCP server registration and Agent Skills symlink creation |
72
+ | `uninstall_cmd.py` | Reverse `mpg setup`: deregister the MCP server and remove the Skills symlink |
73
+ | `mcp_server.py` | MCP server — JSON-RPC 2.0 over stdio, zero external dependencies |
70
74
 
71
75
  ## Guide format
72
76
 
@@ -197,7 +201,7 @@ The CLI defaults to JSON when piped and human-readable when attached to a TTY. T
197
201
 
198
202
  | Layer | Scope | Categories | Count |
199
203
  |-------|-------|-----------|-------|
200
- | 1 — stdlib | Python standard library | typing, async, stdlib, data-structures | 16 |
204
+ | 1 — stdlib | Python standard library | typing, async, stdlib, data-structures | 18 |
201
205
  | 2 — frameworks | Third-party frameworks | pydantic, fastapi, httpx, django, sqlalchemy, pytest | 18 |
202
206
  | 3 — toolchain | Development tools | toolchain | 5 |
203
207
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "modern-python-guidance"
7
- version = "0.4.4"
7
+ version = "0.5.0"
8
8
  description = "Version-aware BAD/GOOD pattern guides that help AI coding agents generate modern Python"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0 OR MIT"
@@ -65,6 +65,7 @@ select = ["E", "F", "W", "I", "UP", "FURB", "B", "SIM", "RUF"]
65
65
 
66
66
  [tool.ruff.lint.per-file-ignores]
67
67
  "bench/*.py" = ["SIM102", "SIM110"]
68
+ "src/modern_python_guidance/setup_cmd.py" = ["E501"]
68
69
 
69
70
  [tool.pytest.ini_options]
70
71
  testpaths = ["tests"]
@@ -0,0 +1,31 @@
1
+ ---
2
+ paths: ["**/*.py", "*.py", "**/pyproject.toml", "**/requirements*.txt", "**/setup.py", "**/setup.cfg", "**/.python-version", "**/Pipfile"]
3
+ ---
4
+
5
+ # Modern Python Guidance
6
+
7
+ Pre-generation design patterns that Ruff cannot auto-fix. Apply these transformations when writing or reviewing Python code.
8
+
9
+ ## Embedded patterns (high-frequency, Ruff-uncovered)
10
+
11
+ - `from typing import List, Dict` → `list[str]`, `dict[str, int]` (>=3.9)
12
+ - `@validator("f")` → `@field_validator("f")` (Pydantic V2)
13
+ - `datetime.utcnow()` → `datetime.now(UTC)` (>=3.11)
14
+ - `session.query(User).filter()` → `session.execute(select(User).where())` (SQLAlchemy 2.0)
15
+ - `subprocess.run(f"cmd {arg}", shell=True)` → `subprocess.run(["cmd", arg], check=True)`
16
+
17
+ ## All 41 guides by category
18
+
19
+ - **async** (3): `async-timeout-context`, `exception-groups`, `taskgroup-over-gather`
20
+ - **data-structures** (3): `dataclass-modern`, `dict-merge-operator`, `match-case-patterns`
21
+ - **django** (3): `django-async-views`, `django-check-constraints`, `django-json-field`
22
+ - **fastapi** (3): `fastapi-annotated-depends`, `fastapi-lifespan`, `fastapi-typed-state`
23
+ - **httpx** (2): `httpx-async-client-reuse`, `httpx-streaming`
24
+ - **pydantic** (4): `pydantic-v2-config`, `pydantic-v2-model-api`, `pydantic-v2-serialization`, `pydantic-v2-validators`
25
+ - **pytest** (3): `pytest-parametrize`, `pytest-raises-match`, `pytest-tmp-path`
26
+ - **sqlalchemy** (3): `sqlalchemy-2-style`, `sqlalchemy-async-session`, `sqlalchemy-mapped-column`
27
+ - **stdlib** (5): `datetime-utc`, `pathlib-over-os-path`, `removeprefix-removesuffix`, `template-strings`, `tomllib-builtin`
28
+ - **toolchain** (5): `no-pickle`, `pyproject-toml-over-setup`, `ruff-over-flake8`, `safe-subprocess`, `uv-over-pip`
29
+ - **typing** (7): `deferred-annotations`, `override-decorator`, `paramspec-decorators`, `type-parameter-syntax`, `typeis-vs-typeguard`, `union-syntax`, `use-builtin-generics`
30
+
31
+ For full code examples, use `mpg retrieve <guide-id>` or MCP tool `retrieve_guides`.
@@ -1,3 +1,3 @@
1
1
  """Modern Python Guidance — version-aware BAD/GOOD pattern guides for AI coding agents."""
2
2
 
3
- __version__ = "0.4.4"
3
+ __version__ = "0.5.0"
@@ -130,6 +130,22 @@ def main(argv: list[str] | None = None) -> None:
130
130
  action="store_true",
131
131
  help="Always exit 0 even when patterns are found",
132
132
  )
133
+ p_check.add_argument(
134
+ "--quiet",
135
+ action="store_true",
136
+ help="Suppress output when no patterns are found (human format only)",
137
+ )
138
+
139
+ # hook
140
+ p_hook = subparsers.add_parser(
141
+ "hook",
142
+ help="Claude Code hook subcommands",
143
+ )
144
+ hook_sub = p_hook.add_subparsers(dest="hook_name")
145
+ hook_sub.add_parser(
146
+ "claude-post-tool-use",
147
+ help="PostToolUse hook: check .py files from stdin JSON",
148
+ )
133
149
 
134
150
  args = parser.parse_args(argv)
135
151
 
@@ -158,6 +174,8 @@ def main(argv: list[str] | None = None) -> None:
158
174
  _cmd_uninstall(args)
159
175
  elif args.command == "check":
160
176
  _cmd_check(args)
177
+ elif args.command == "hook":
178
+ _cmd_hook(args)
161
179
  except BrokenPipeError:
162
180
  sys.exit(0)
163
181
 
@@ -341,7 +359,7 @@ def _cmd_check(args: argparse.Namespace) -> None:
341
359
 
342
360
  if fmt == "json":
343
361
  _check_json(matches, args.file)
344
- else:
362
+ elif not (args.quiet and not matches):
345
363
  _check_human(matches)
346
364
 
347
365
  if matches and not args.exit_zero:
@@ -395,3 +413,58 @@ def _check_human(matches: list[CheckMatch]) -> None:
395
413
  f"\n{len(matches)} outdated pattern{ps} found ({unique} guide{gs}). "
396
414
  f"Run `mpg retrieve {ids}` for details."
397
415
  )
416
+
417
+
418
+ def _cmd_hook(args: argparse.Namespace) -> None:
419
+ if not args.hook_name:
420
+ print("usage: modern-python-guidance hook <name>", file=sys.stderr)
421
+ print("available hooks: claude-post-tool-use", file=sys.stderr)
422
+ sys.exit(2)
423
+ if args.hook_name == "claude-post-tool-use":
424
+ _hook_post_tool_use()
425
+ else:
426
+ print(f"unknown hook: {args.hook_name}", file=sys.stderr)
427
+ print("available hooks: claude-post-tool-use", file=sys.stderr)
428
+ sys.exit(2)
429
+
430
+
431
+ def _hook_post_tool_use() -> None:
432
+ try:
433
+ data = json.load(sys.stdin)
434
+ except (json.JSONDecodeError, ValueError):
435
+ sys.exit(0)
436
+
437
+ try:
438
+ file_path = data["tool_input"]["file_path"]
439
+ except (KeyError, TypeError):
440
+ sys.exit(0)
441
+
442
+ if not isinstance(file_path, str) or not file_path.lower().endswith(".py"):
443
+ sys.exit(0)
444
+
445
+ path = Path(file_path)
446
+ if not path.is_file():
447
+ sys.exit(0)
448
+
449
+ index = build_index()
450
+ try:
451
+ matches = check_file(path, index)
452
+ except CheckError:
453
+ sys.exit(0)
454
+
455
+ if not matches:
456
+ sys.exit(0)
457
+
458
+ for m in matches:
459
+ src = sanitize_line(m.source_line.strip())
460
+ print(
461
+ f"mpg: {m.guide_id} (line {m.line}): {src}",
462
+ file=sys.stderr,
463
+ )
464
+ guide_ids = sorted({m.guide_id for m in matches})
465
+ print(
466
+ f"mpg: {len(matches)} outdated pattern(s). "
467
+ f"Run `mpg retrieve {','.join(guide_ids)}` for modern alternatives.",
468
+ file=sys.stderr,
469
+ )
470
+ sys.exit(2)
@@ -26,6 +26,30 @@ def _get_index() -> GuideIndex:
26
26
  return _index
27
27
 
28
28
 
29
+ def _guide_limit() -> int:
30
+ return len(_get_index())
31
+
32
+
33
+ def _get_tools() -> list[dict]:
34
+ n = _guide_limit()
35
+ tools = list(TOOLS)
36
+ tools[1] = {
37
+ **TOOLS[1],
38
+ "inputSchema": {
39
+ **TOOLS[1]["inputSchema"],
40
+ "properties": {
41
+ **TOOLS[1]["inputSchema"]["properties"],
42
+ "guide_ids": {
43
+ **TOOLS[1]["inputSchema"]["properties"]["guide_ids"],
44
+ "maxItems": n,
45
+ "description": f"Guide IDs to retrieve (max {n})",
46
+ },
47
+ },
48
+ },
49
+ }
50
+ return tools
51
+
52
+
29
53
  # --- JSON-RPC framing (newline-delimited JSON) ---
30
54
 
31
55
 
@@ -122,8 +146,7 @@ TOOLS = [
122
146
  "guide_ids": {
123
147
  "type": "array",
124
148
  "items": {"type": "string"},
125
- "description": "Guide IDs to retrieve (max 41)",
126
- "maxItems": 41,
149
+ "description": "Guide IDs to retrieve",
127
150
  },
128
151
  "python_version": {
129
152
  "type": "string",
@@ -270,8 +293,9 @@ def _tool_retrieve(arguments: dict) -> dict:
270
293
  guide_ids = arguments.get("guide_ids", [])
271
294
  if not guide_ids:
272
295
  return _tool_result("guide_ids is required and must not be empty", is_error=True)
273
- if len(guide_ids) > 41:
274
- return _tool_result("guide_ids exceeds maximum of 41", is_error=True)
296
+ limit = _guide_limit()
297
+ if len(guide_ids) > limit:
298
+ return _tool_result(f"guide_ids exceeds maximum of {limit}", is_error=True)
275
299
 
276
300
  pv = arguments.get("python_version")
277
301
  err = _validate_python_version(pv)
@@ -371,7 +395,7 @@ def _handle_request(msg: dict) -> dict | None:
371
395
  return None if is_notification else result
372
396
 
373
397
  if method == "tools/list":
374
- result = _result_response(req_id, {"tools": TOOLS})
398
+ result = _result_response(req_id, {"tools": _get_tools()})
375
399
  return None if is_notification else result
376
400
 
377
401
  if method == "tools/call":
@@ -95,18 +95,40 @@ def _rules_file_path(project_dir: Path | None = None) -> Path:
95
95
  return root / ".claude" / "rules" / RULE_FILE_NAME
96
96
 
97
97
 
98
- def _build_rule_text() -> str:
99
- """Generate rule file content from SKILL.md body with rule-specific frontmatter.
98
+ _THIN_RULE_BODY = """\
99
+ # Modern Python Guidance
100
100
 
101
- Used by CI sync tests to verify the bundled ``rules/modern-python.md`` matches
102
- what would be generated from SKILL.md. Strips SKILL.md frontmatter and prepends
103
- rule-only frontmatter (no name/description keys).
104
- """
105
- skills_dir = _find_skills_dir()
106
- skill_md = (skills_dir / "SKILL.md").read_text(encoding="utf-8")
107
- parts = skill_md.split("---", 2)
108
- body = parts[2].lstrip("\n")
109
- return RULE_FRONTMATTER + "\n\n" + body
101
+ Pre-generation design patterns that Ruff cannot auto-fix. Apply these transformations when writing or reviewing Python code.
102
+
103
+ ## Embedded patterns (high-frequency, Ruff-uncovered)
104
+
105
+ - `from typing import List, Dict` → `list[str]`, `dict[str, int]` (>=3.9)
106
+ - `@validator("f")` → `@field_validator("f")` (Pydantic V2)
107
+ - `datetime.utcnow()` → `datetime.now(UTC)` (>=3.11)
108
+ - `session.query(User).filter()` → `session.execute(select(User).where())` (SQLAlchemy 2.0)
109
+ - `subprocess.run(f"cmd {arg}", shell=True)` → `subprocess.run(["cmd", arg], check=True)`
110
+
111
+ ## All 41 guides by category
112
+
113
+ - **async** (3): `async-timeout-context`, `exception-groups`, `taskgroup-over-gather`
114
+ - **data-structures** (3): `dataclass-modern`, `dict-merge-operator`, `match-case-patterns`
115
+ - **django** (3): `django-async-views`, `django-check-constraints`, `django-json-field`
116
+ - **fastapi** (3): `fastapi-annotated-depends`, `fastapi-lifespan`, `fastapi-typed-state`
117
+ - **httpx** (2): `httpx-async-client-reuse`, `httpx-streaming`
118
+ - **pydantic** (4): `pydantic-v2-config`, `pydantic-v2-model-api`, `pydantic-v2-serialization`, `pydantic-v2-validators`
119
+ - **pytest** (3): `pytest-parametrize`, `pytest-raises-match`, `pytest-tmp-path`
120
+ - **sqlalchemy** (3): `sqlalchemy-2-style`, `sqlalchemy-async-session`, `sqlalchemy-mapped-column`
121
+ - **stdlib** (5): `datetime-utc`, `pathlib-over-os-path`, `removeprefix-removesuffix`, `template-strings`, `tomllib-builtin`
122
+ - **toolchain** (5): `no-pickle`, `pyproject-toml-over-setup`, `ruff-over-flake8`, `safe-subprocess`, `uv-over-pip`
123
+ - **typing** (7): `deferred-annotations`, `override-decorator`, `paramspec-decorators`, `type-parameter-syntax`, `typeis-vs-typeguard`, `union-syntax`, `use-builtin-generics`
124
+
125
+ For full code examples, use `mpg retrieve <guide-id>` or MCP tool `retrieve_guides`.
126
+ """
127
+
128
+
129
+ def _build_rule_text() -> str:
130
+ """Generate thin rule file content with category index and MCP pointer."""
131
+ return RULE_FRONTMATTER + "\n\n" + _THIN_RULE_BODY
110
132
 
111
133
 
112
134
  def setup_mcp(
@@ -285,8 +307,11 @@ def run_setup(
285
307
  rules_ok = setup_rules(project_dir=project_dir, dry_run=dry_run)
286
308
 
287
309
  if mcp_ok and skills_ok and rules_ok:
288
- if not dry_run and do_mcp and do_skills:
289
- print("Ready. Start Claude Code to use mpg guides.")
310
+ if not dry_run:
311
+ if do_mcp and do_skills:
312
+ print("Ready. Start Claude Code to use mpg guides.")
313
+ print("Tip: Add a PostToolUse hook to auto-check Python files.")
314
+ print("See: https://github.com/yottayoshida/modern-python-guidance#recommended-hooks")
290
315
  return 0
291
316
 
292
317
  return 1
@@ -250,6 +250,89 @@ class TestCheck:
250
250
  old_matches = json.loads(r_old.stdout)["summary"]["total_matches"]
251
251
  assert old_matches <= all_matches
252
252
 
253
+ def test_check_quiet_clean_file(self, tmp_path):
254
+ p = tmp_path / "clean.py"
255
+ p.write_text("x: list[str] = []\n")
256
+ r = run_cli("check", str(p), "--quiet", "--format", "human")
257
+ assert r.returncode == 0
258
+ assert r.stdout == ""
259
+ assert r.stderr == ""
260
+
261
+ def test_check_quiet_with_matches(self, tmp_path):
262
+ p = tmp_path / "bad.py"
263
+ p.write_text("from typing import List\n")
264
+ r = run_cli("check", str(p), "--quiet", "--format", "human")
265
+ assert r.returncode == 1
266
+ assert "outdated pattern" in r.stdout
267
+
268
+
269
+ class TestHook:
270
+ def _run_hook(self, stdin_data: str) -> subprocess.CompletedProcess[str]:
271
+ return subprocess.run(
272
+ [*BIN, "hook", "claude-post-tool-use"],
273
+ input=stdin_data,
274
+ capture_output=True,
275
+ text=True,
276
+ timeout=10,
277
+ )
278
+
279
+ def test_hook_py_with_matches(self, tmp_path):
280
+ p = tmp_path / "bad.py"
281
+ p.write_text("from typing import List\n")
282
+ stdin = json.dumps({"tool_input": {"file_path": str(p)}})
283
+ r = self._run_hook(stdin)
284
+ assert r.returncode == 2
285
+ assert "mpg:" in r.stderr
286
+ assert r.stdout == ""
287
+
288
+ def test_hook_py_clean(self, tmp_path):
289
+ p = tmp_path / "clean.py"
290
+ p.write_text("x: list[str] = []\n")
291
+ stdin = json.dumps({"tool_input": {"file_path": str(p)}})
292
+ r = self._run_hook(stdin)
293
+ assert r.returncode == 0
294
+ assert r.stdout == ""
295
+ assert r.stderr == ""
296
+
297
+ def test_hook_non_py(self, tmp_path):
298
+ p = tmp_path / "file.js"
299
+ p.write_text("const x = 1;\n")
300
+ stdin = json.dumps({"tool_input": {"file_path": str(p)}})
301
+ r = self._run_hook(stdin)
302
+ assert r.returncode == 0
303
+
304
+ def test_hook_missing_file(self):
305
+ stdin = json.dumps({"tool_input": {"file_path": "/nonexistent/test.py"}})
306
+ r = self._run_hook(stdin)
307
+ assert r.returncode == 0
308
+
309
+ def test_hook_malformed_json(self):
310
+ r = self._run_hook("{bad json")
311
+ assert r.returncode == 0
312
+
313
+ def test_hook_missing_keys(self):
314
+ r = self._run_hook(json.dumps({"other": "data"}))
315
+ assert r.returncode == 0
316
+
317
+ def test_hook_uppercase_py(self, tmp_path):
318
+ p = tmp_path / "bad.PY"
319
+ p.write_text("from typing import List\n")
320
+ stdin = json.dumps({"tool_input": {"file_path": str(p)}})
321
+ r = self._run_hook(stdin)
322
+ assert r.returncode == 2
323
+ assert "mpg:" in r.stderr
324
+
325
+ def test_hook_bare_no_subcommand(self):
326
+ r = subprocess.run(
327
+ [*BIN, "hook"],
328
+ input="",
329
+ capture_output=True,
330
+ text=True,
331
+ timeout=10,
332
+ )
333
+ assert r.returncode == 2
334
+ assert "available hooks" in r.stderr
335
+
253
336
 
254
337
  class TestVersion:
255
338
  def test_version_flag(self):
@@ -223,6 +223,110 @@ class TestCmdDetectVersion:
223
223
  assert out # should return some version string
224
224
 
225
225
 
226
+ class TestCmdCheck:
227
+ def test_json_output(self, tmp_path, capsys):
228
+ p = tmp_path / "bad.py"
229
+ p.write_text("from typing import List\n")
230
+ with pytest.raises(SystemExit, match="1"):
231
+ main(argv=["check", str(p), "--format", "json"])
232
+ data = json.loads(capsys.readouterr().out)
233
+ assert data["summary"]["total_matches"] >= 1
234
+
235
+ def test_human_output(self, tmp_path, capsys):
236
+ p = tmp_path / "bad.py"
237
+ p.write_text("from typing import List\n")
238
+ with pytest.raises(SystemExit, match="1"):
239
+ main(argv=["check", str(p), "--format", "human"])
240
+ assert "outdated pattern" in capsys.readouterr().out
241
+
242
+ def test_clean_file(self, tmp_path, capsys):
243
+ p = tmp_path / "clean.py"
244
+ p.write_text("x: list[str] = []\n")
245
+ main(argv=["check", str(p), "--format", "human"])
246
+ assert "No outdated patterns" in capsys.readouterr().out
247
+
248
+ def test_file_not_found(self, tmp_path, capsys):
249
+ with pytest.raises(SystemExit, match="2"):
250
+ main(argv=["check", str(tmp_path / "gone.py"), "--format", "json"])
251
+
252
+ def test_quiet_suppresses_clean(self, tmp_path, capsys):
253
+ p = tmp_path / "clean.py"
254
+ p.write_text("x: list[str] = []\n")
255
+ main(argv=["check", str(p), "--quiet", "--format", "human"])
256
+ captured = capsys.readouterr()
257
+ assert captured.out == ""
258
+
259
+ def test_exit_zero(self, tmp_path, capsys):
260
+ p = tmp_path / "bad.py"
261
+ p.write_text("from typing import List\n")
262
+ main(argv=["check", str(p), "--exit-zero", "--format", "json"])
263
+ data = json.loads(capsys.readouterr().out)
264
+ assert data["summary"]["total_matches"] >= 1
265
+
266
+
267
+ class TestCmdHook:
268
+ def test_bare_hook_exits_2(self, capsys):
269
+ with pytest.raises(SystemExit, match="2"):
270
+ main(argv=["hook"])
271
+ assert "available hooks" in capsys.readouterr().err
272
+
273
+ def test_unknown_hook_exits_2(self, capsys):
274
+ with pytest.raises(SystemExit, match="2"):
275
+ main(argv=["hook", "nonexistent"])
276
+ assert "invalid choice" in capsys.readouterr().err
277
+
278
+ def test_post_tool_use_py_match(self, tmp_path, capsys, monkeypatch):
279
+ p = tmp_path / "bad.py"
280
+ p.write_text("from typing import List\n")
281
+ import io
282
+
283
+ stdin_data = json.dumps({"tool_input": {"file_path": str(p)}})
284
+ monkeypatch.setattr("sys.stdin", io.StringIO(stdin_data))
285
+ with pytest.raises(SystemExit, match="2"):
286
+ main(argv=["hook", "claude-post-tool-use"])
287
+ assert "mpg:" in capsys.readouterr().err
288
+
289
+ def test_post_tool_use_py_clean(self, tmp_path, monkeypatch):
290
+ p = tmp_path / "clean.py"
291
+ p.write_text("x: list[str] = []\n")
292
+ import io
293
+
294
+ stdin_data = json.dumps({"tool_input": {"file_path": str(p)}})
295
+ monkeypatch.setattr("sys.stdin", io.StringIO(stdin_data))
296
+ with pytest.raises(SystemExit, match="0"):
297
+ main(argv=["hook", "claude-post-tool-use"])
298
+
299
+ def test_post_tool_use_non_py(self, monkeypatch):
300
+ import io
301
+
302
+ stdin_data = json.dumps({"tool_input": {"file_path": "/tmp/x.js"}})
303
+ monkeypatch.setattr("sys.stdin", io.StringIO(stdin_data))
304
+ with pytest.raises(SystemExit, match="0"):
305
+ main(argv=["hook", "claude-post-tool-use"])
306
+
307
+ def test_post_tool_use_malformed(self, monkeypatch):
308
+ import io
309
+
310
+ monkeypatch.setattr("sys.stdin", io.StringIO("{bad"))
311
+ with pytest.raises(SystemExit, match="0"):
312
+ main(argv=["hook", "claude-post-tool-use"])
313
+
314
+ def test_post_tool_use_missing_keys(self, monkeypatch):
315
+ import io
316
+
317
+ monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps({"x": 1})))
318
+ with pytest.raises(SystemExit, match="0"):
319
+ main(argv=["hook", "claude-post-tool-use"])
320
+
321
+ def test_post_tool_use_missing_file(self, monkeypatch):
322
+ import io
323
+
324
+ stdin_data = json.dumps({"tool_input": {"file_path": "/nonexistent/z.py"}})
325
+ monkeypatch.setattr("sys.stdin", io.StringIO(stdin_data))
326
+ with pytest.raises(SystemExit, match="0"):
327
+ main(argv=["hook", "claude-post-tool-use"])
328
+
329
+
226
330
  class TestCmdSetupUninstall:
227
331
  def test_setup_dispatch(self):
228
332
  with patch("modern_python_guidance.setup_cmd.run_setup", return_value=0) as mock: