modern-python-guidance 0.4.2__tar.gz → 0.4.4__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 (138) hide show
  1. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/.github/workflows/ci.yml +1 -1
  2. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/CHANGELOG.md +20 -0
  3. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/PKG-INFO +4 -3
  4. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/README.md +3 -2
  5. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/docs/design.md +3 -4
  6. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/pyproject.toml +1 -1
  7. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/__init__.py +1 -1
  8. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/check.py +22 -0
  9. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/compat.py +2 -2
  10. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/frontmatter.py +9 -1
  11. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/mcp_server.py +24 -1
  12. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/version_detect.py +50 -15
  13. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_check.py +56 -0
  14. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_compat.py +12 -0
  15. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_frontmatter.py +43 -0
  16. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_guide_structure.py +9 -0
  17. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_mcp_server.py +31 -0
  18. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_mcp_unit.py +112 -0
  19. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_version_detect.py +86 -6
  20. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/.github/workflows/check-python-release.yml +0 -0
  21. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/.github/workflows/publish.yml +0 -0
  22. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/.gitignore +0 -0
  23. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/CONTRIBUTING.md +0 -0
  24. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/LICENSE +0 -0
  25. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/LICENSE-MIT +0 -0
  26. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/SECURITY.md +0 -0
  27. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/edge-cases/opus48_multiline.py +0 -0
  28. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/edge-cases/valid_alt_patterns.py +0 -0
  29. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/pyproject.toml +0 -0
  30. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/app.py +0 -0
  31. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/config.py +0 -0
  32. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/crawler.py +0 -0
  33. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/models.py +0 -0
  34. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/scanner.py +0 -0
  35. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/utils.py +0 -0
  36. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/pyproject.toml +0 -0
  37. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/setup.py +0 -0
  38. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/app.py +0 -0
  39. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/config.py +0 -0
  40. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/crawler.py +0 -0
  41. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/models.py +0 -0
  42. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/scanner.py +0 -0
  43. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/utils.py +0 -0
  44. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-b-modern/myapp/models.py +0 -0
  45. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-b-modern/myapp/views.py +0 -0
  46. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-b-outdated/myapp/models.py +0 -0
  47. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-b-outdated/myapp/views.py +0 -0
  48. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-c-modern/tests/test_calculator.py +0 -0
  49. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-c-outdated/tests/test_calculator.py +0 -0
  50. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/mcp-config.json +0 -0
  51. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v2.txt +0 -0
  52. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v3-mcp.txt +0 -0
  53. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v3.txt +0 -0
  54. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v4-a.txt +0 -0
  55. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v4-b.txt +0 -0
  56. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v4-c.txt +0 -0
  57. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt.txt +0 -0
  58. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-a-detailed.txt +0 -0
  59. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-a-normal.txt +0 -0
  60. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-a-terse.txt +0 -0
  61. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-b-detailed.txt +0 -0
  62. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-b-normal.txt +0 -0
  63. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-b-terse.txt +0 -0
  64. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-c-detailed.txt +0 -0
  65. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-c-normal.txt +0 -0
  66. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-c-terse.txt +0 -0
  67. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/run-mcp.sh +0 -0
  68. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/run-v4.sh +0 -0
  69. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/run-v5.sh +0 -0
  70. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/run.sh +0 -0
  71. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/score-v2.sh +0 -0
  72. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/score-v3.sh +0 -0
  73. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/score-v4.sh +0 -0
  74. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/score.sh +0 -0
  75. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/score_v5.py +0 -0
  76. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/test-scorer.sh +0 -0
  77. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/docs/benchmark-evaluation.md +0 -0
  78. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/docs/benchmark-procedure.md +0 -0
  79. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/docs/benchmark-v5.md +0 -0
  80. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/rules/modern-python.md +0 -0
  81. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/SKILL.md +0 -0
  82. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
  83. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
  84. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
  85. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -0
  86. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/data-structures/dict-merge-operator.md +0 -0
  87. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/data-structures/match-case-patterns.md +0 -0
  88. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/django/django-async-views.md +0 -0
  89. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/django/django-check-constraints.md +0 -0
  90. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/django/django-json-field.md +0 -0
  91. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
  92. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +0 -0
  93. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +0 -0
  94. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/httpx/httpx-async-client-reuse.md +0 -0
  95. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
  96. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
  97. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-model-api.md +0 -0
  98. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
  99. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
  100. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pytest/pytest-parametrize.md +0 -0
  101. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pytest/pytest-raises-match.md +0 -0
  102. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pytest/pytest-tmp-path.md +0 -0
  103. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-2-style.md +0 -0
  104. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-async-session.md +0 -0
  105. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-mapped-column.md +0 -0
  106. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
  107. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/stdlib/pathlib-over-os-path.md +0 -0
  108. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
  109. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/stdlib/template-strings.md +0 -0
  110. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
  111. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
  112. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/toolchain/pyproject-toml-over-setup.md +0 -0
  113. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
  114. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
  115. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
  116. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/deferred-annotations.md +0 -0
  117. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
  118. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
  119. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
  120. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
  121. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
  122. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
  123. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/__main__.py +0 -0
  124. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/cli.py +0 -0
  125. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/guide_index.py +0 -0
  126. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/retrieve.py +0 -0
  127. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/search.py +0 -0
  128. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/setup_cmd.py +0 -0
  129. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/uninstall_cmd.py +0 -0
  130. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_cli_integration.py +0 -0
  131. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_cli_unit.py +0 -0
  132. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_guide_index.py +0 -0
  133. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_retrieve.py +0 -0
  134. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_scorer_v5.py +0 -0
  135. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_search.py +0 -0
  136. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_setup.py +0 -0
  137. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_skill_sync.py +0 -0
  138. {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_uninstall.py +0 -0
@@ -14,7 +14,7 @@ jobs:
14
14
  runs-on: ubuntu-latest
15
15
  strategy:
16
16
  matrix:
17
- python-version: ["3.11", "3.12", "3.13"]
17
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
18
18
 
19
19
  steps:
20
20
  - uses: actions/checkout@v4
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.4.4] — 2026-06-05
6
+
7
+ ### Added
8
+
9
+ - Poetry constraint parsing: `detect_version()` now extracts the minimum Python version from `[tool.poetry.dependencies].python` instead of only logging a warning. Supported forms: caret (`^3.10`), tilde (`~3.11`), PEP 440 (`>=3.10,<3.14`), and dict-form (`{version = "^3.10"}`). Union operators (`||`) and unsupported formats warn and fall through to `.python-version` / default. (closes #95)
10
+ - Python 3.14 added to CI test matrix as a regular (non-allowed-failure) entry. Python 3.14 has been GA since 2025-10-07; pyproject.toml classifiers already declared support. (closes #94)
11
+ - 12 new tests (999 total).
12
+
13
+ ## [0.4.3] — 2026-06-04
14
+
15
+ ### Fixed
16
+
17
+ - MCP server crash on malformed `params` and `arguments`: non-dict values now return JSON-RPC -32602 instead of `TypeError`. `serve()` catch-all returns -32603 on unexpected errors. Notification messages (no `id`) are silently dropped per JSON-RPC 2.0 spec. (closes #91)
18
+ - `mpg check` false positives in multi-line docstrings: `check_file()` now uses `tokenize` to identify multi-line string token ranges and skips those lines. Single-line strings on code lines are still scanned. Tokenize failure (syntax errors, indentation errors) falls back to scanning all lines. (closes #92)
19
+ - Invalid PEP 440 specifiers in guide `python:` field silently treated as all-version compatible: `_build_meta()` now validates with `SpecifierSet` at parse time, raising `FrontmatterError`. Runtime `version_compatible()` narrows except from `(InvalidSpecifier, Exception)` to `(InvalidSpecifier, InvalidVersion)`. (closes #93)
20
+
21
+ ### Added
22
+
23
+ - 53 new tests (987 total).
24
+
5
25
  ## [0.4.2] — 2026-06-04
6
26
 
7
27
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modern-python-guidance
3
- Version: 0.4.2
3
+ Version: 0.4.4
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
@@ -166,8 +166,9 @@ Guides specify their minimum Python version. The CLI auto-detects your project's
166
166
 
167
167
  1. `--python-version` flag
168
168
  2. `pyproject.toml` `requires-python`
169
- 3. `.python-version` file
170
- 4. Default: 3.11
169
+ 3. `pyproject.toml` Poetry `python` constraint (`^3.10`, `~3.11`, `>=3.10,<3.14`)
170
+ 4. `.python-version` file
171
+ 5. Default: 3.11
171
172
 
172
173
  ```bash
173
174
  # Only shows guides compatible with Python 3.9
@@ -134,8 +134,9 @@ Guides specify their minimum Python version. The CLI auto-detects your project's
134
134
 
135
135
  1. `--python-version` flag
136
136
  2. `pyproject.toml` `requires-python`
137
- 3. `.python-version` file
138
- 4. Default: 3.11
137
+ 3. `pyproject.toml` Poetry `python` constraint (`^3.10`, `~3.11`, `>=3.10,<3.14`)
138
+ 4. `.python-version` file
139
+ 5. Default: 3.11
139
140
 
140
141
  ```bash
141
142
  # Only shows guides compatible with Python 3.9
@@ -150,10 +150,9 @@ Fuzzy results are marked with `fuzzy: true` in the output.
150
150
 
151
151
  1. `--python-version` CLI flag (explicit override)
152
152
  2. `pyproject.toml` `[project].requires-python` (PEP 621)
153
- 3. `.python-version` file (pyenv/asdf convention)
154
- 4. Default: `3.11`
155
-
156
- Poetry's caret syntax (`^3.11`) is detected but not parsed — the tool logs a warning and suggests using `--python-version` or adding `[project].requires-python`.
153
+ 3. `pyproject.toml` `[tool.poetry.dependencies].python` — caret (`^3.10`), tilde (`~3.11`), and PEP 440 (`>=3.10,<3.14`) constraints are parsed to extract the minimum version. Dict-form (`{version = "^3.10"}`) is also supported. Union operators (`||`) are not supported and fall through with a warning.
154
+ 4. `.python-version` file (pyenv/asdf convention)
155
+ 5. Default: `3.11`
157
156
 
158
157
  ## Output format
159
158
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "modern-python-guidance"
7
- version = "0.4.2"
7
+ version = "0.4.4"
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"
@@ -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.2"
3
+ __version__ = "0.4.4"
@@ -2,7 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import io
5
6
  import re
7
+ import tokenize as _tokenize
6
8
  from dataclasses import dataclass
7
9
  from pathlib import Path
8
10
 
@@ -33,6 +35,23 @@ class CheckMatch:
33
35
  snippet: str
34
36
 
35
37
 
38
+ def _string_lines(text: str) -> frozenset[int]:
39
+ """Line numbers belonging to multi-line STRING tokens (docstrings etc.)."""
40
+ skip: set[int] = set()
41
+ try:
42
+ tokens = _tokenize.generate_tokens(io.StringIO(text).readline)
43
+ string_types = {_tokenize.STRING}
44
+ fstring_mid = getattr(_tokenize, "FSTRING_MIDDLE", None)
45
+ if fstring_mid is not None:
46
+ string_types.add(fstring_mid)
47
+ for tok in tokens:
48
+ if tok.type in string_types and tok.end[0] > tok.start[0]:
49
+ skip.update(range(tok.start[0], tok.end[0] + 1))
50
+ except (_tokenize.TokenError, SyntaxError):
51
+ return frozenset()
52
+ return frozenset(skip)
53
+
54
+
36
55
  def check_file(
37
56
  path: Path,
38
57
  index: GuideIndex,
@@ -48,8 +67,11 @@ def check_file(
48
67
  if not patterns:
49
68
  return []
50
69
 
70
+ skip = _string_lines(text)
51
71
  matches: list[CheckMatch] = []
52
72
  for lineno, line in enumerate(text.splitlines(), 1):
73
+ if lineno in skip:
74
+ continue
53
75
  stripped = line.strip()
54
76
  if not stripped or stripped.startswith("#"):
55
77
  continue
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import re
6
6
 
7
7
  from packaging.specifiers import InvalidSpecifier, SpecifierSet
8
- from packaging.version import Version
8
+ from packaging.version import InvalidVersion, Version
9
9
 
10
10
  VERSION_RE = re.compile(r"^\d+\.\d+$")
11
11
 
@@ -14,7 +14,7 @@ def version_compatible(guide_python: str, target: str) -> bool:
14
14
  try:
15
15
  spec = SpecifierSet(guide_python)
16
16
  return Version(f"{target}.0") in spec
17
- except (InvalidSpecifier, Exception):
17
+ except (InvalidSpecifier, InvalidVersion):
18
18
  return True
19
19
 
20
20
 
@@ -13,6 +13,8 @@ import re
13
13
  from dataclasses import dataclass, field
14
14
  from typing import Any
15
15
 
16
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
17
+
16
18
  _KEY_RE = re.compile(r"^([a-z][a-z0-9_-]*)\s*:\s*(.*)")
17
19
  _LIST_ITEM_RE = re.compile(r"^ - (.+)")
18
20
  _FENCE = "---"
@@ -123,6 +125,12 @@ def _build_meta(raw: dict[str, Any]) -> GuideMeta:
123
125
  if isinstance(raw[str_field], list):
124
126
  raise FrontmatterError(f"'{str_field}' must be a scalar value, not a list")
125
127
 
128
+ python_value = str(raw["python"])
129
+ try:
130
+ SpecifierSet(python_value)
131
+ except InvalidSpecifier as e:
132
+ raise FrontmatterError(f"invalid python specifier '{python_value}': {e}") from e
133
+
126
134
  freq = raw["frequency"]
127
135
  if freq not in VALID_FREQUENCIES:
128
136
  raise FrontmatterError(f"invalid frequency '{freq}', must be one of {VALID_FREQUENCIES}")
@@ -171,7 +179,7 @@ def _build_meta(raw: dict[str, Any]) -> GuideMeta:
171
179
  category=str(raw["category"]),
172
180
  layer=layer,
173
181
  tags=[str(t) for t in tags],
174
- python=str(raw["python"]),
182
+ python=python_value,
175
183
  frequency=freq,
176
184
  aliases=[str(a) for a in aliases_raw],
177
185
  pep=pep,
@@ -347,6 +347,15 @@ def _handle_request(msg: dict) -> dict | None:
347
347
  params = msg.get("params", {})
348
348
  is_notification = "id" not in msg
349
349
 
350
+ if not isinstance(params, dict):
351
+ if not is_notification:
352
+ return _error_response(
353
+ req_id,
354
+ -32602,
355
+ f"Invalid params: expected object, got {type(params).__name__}",
356
+ )
357
+ return None
358
+
350
359
  if method == "notifications/initialized":
351
360
  return None
352
361
 
@@ -368,6 +377,13 @@ def _handle_request(msg: dict) -> dict | None:
368
377
  if method == "tools/call":
369
378
  tool_name = params.get("name", "")
370
379
  arguments = params.get("arguments", {})
380
+ if not isinstance(arguments, dict):
381
+ result = _error_response(
382
+ req_id,
383
+ -32602,
384
+ f"Invalid arguments: expected object, got {type(arguments).__name__}",
385
+ )
386
+ return None if is_notification else result
371
387
  tool_result = _handle_tool_call(tool_name, arguments)
372
388
  result = _result_response(req_id, tool_result)
373
389
  return None if is_notification else result
@@ -390,7 +406,14 @@ def serve(*, stdin: object = None, stdout: object = None) -> None:
390
406
  if msg is None:
391
407
  break
392
408
 
393
- response = _handle_request(msg)
409
+ try:
410
+ response = _handle_request(msg)
411
+ except Exception:
412
+ log.exception("Unexpected error handling request")
413
+ if isinstance(msg, dict) and "id" in msg:
414
+ response = _error_response(msg.get("id"), -32603, "Internal error")
415
+ else:
416
+ continue
394
417
  if response is not None:
395
418
  _write_message(response, stdout)
396
419
 
@@ -3,8 +3,9 @@
3
3
  Precedence chain:
4
4
  1. CLI --python-version flag (explicit override)
5
5
  2. pyproject.toml [project].requires-python (PEP 621)
6
- 3. .python-version file (pyenv/asdf)
7
- 4. Default: 3.11
6
+ 3. pyproject.toml [tool.poetry.dependencies].python (caret/tilde/PEP 440)
7
+ 4. .python-version file (pyenv/asdf)
8
+ 5. Default: 3.11
8
9
  """
9
10
 
10
11
  from __future__ import annotations
@@ -24,6 +25,7 @@ DEFAULT_VERSION = "3.11"
24
25
  _KNOWN_MINORS = [Version(f"3.{minor}") for minor in range(7, 20)]
25
26
 
26
27
  _POETRY_CARET_RE = re.compile(r"\^(\d+\.\d+)")
28
+ _POETRY_TILDE_RE = re.compile(r"~(\d+\.\d+)")
27
29
 
28
30
 
29
31
  def detect_version(
@@ -68,21 +70,54 @@ def _from_pyproject(path: Path) -> str | None:
68
70
 
69
71
  poetry_python = data.get("tool", {}).get("poetry", {}).get("dependencies", {}).get("python")
70
72
  if poetry_python:
71
- m = _POETRY_CARET_RE.search(str(poetry_python))
72
- if m:
73
- log.warning(
74
- "Poetry caret version '%s' is not PEP 440 — cannot parse precisely. "
75
- "Use --python-version or add [project].requires-python to pyproject.toml.",
76
- poetry_python,
77
- )
78
- else:
79
- log.warning(
80
- "Poetry python constraint '%s' detected but not supported. "
81
- "Use --python-version or add [project].requires-python.",
82
- poetry_python,
83
- )
73
+ result = _parse_poetry_python(poetry_python)
74
+ if result is not None:
75
+ return result
76
+
77
+ return None
78
+
79
+
80
+ def _parse_poetry_python(value: str | dict) -> str | None:
81
+ if isinstance(value, dict):
82
+ value = value.get("version")
83
+ if not value:
84
+ log.warning("Poetry python constraint has no 'version' key")
85
+ return None
86
+
87
+ if not isinstance(value, str):
88
+ log.warning("Poetry python constraint has unexpected type %s", type(value).__name__)
89
+ return None
90
+
91
+ poetry_str = value
92
+
93
+ if "||" in poetry_str:
94
+ log.warning(
95
+ "Poetry union constraint '%s' is not supported. "
96
+ "Use --python-version or add [project].requires-python.",
97
+ poetry_str,
98
+ )
84
99
  return None
85
100
 
101
+ m = _POETRY_CARET_RE.search(poetry_str)
102
+ if m:
103
+ log.info("Parsed Poetry caret constraint '%s' → %s", poetry_str, m.group(1))
104
+ return m.group(1)
105
+
106
+ m = _POETRY_TILDE_RE.search(poetry_str)
107
+ if m:
108
+ log.info("Parsed Poetry tilde constraint '%s' → %s", poetry_str, m.group(1))
109
+ return m.group(1)
110
+
111
+ result = _min_version_from_specifier(poetry_str)
112
+ if result is not None:
113
+ log.info("Parsed Poetry PEP 440 constraint '%s' → %s", poetry_str, result)
114
+ return result
115
+
116
+ log.warning(
117
+ "Poetry python constraint '%s' detected but not supported. "
118
+ "Use --python-version or add [project].requires-python.",
119
+ poetry_str,
120
+ )
86
121
  return None
87
122
 
88
123
 
@@ -276,6 +276,62 @@ class TestEdgeCases:
276
276
  assert len(patterns) == 0
277
277
 
278
278
 
279
+ class TestStringLineFiltering:
280
+ def test_docstring_not_matched(self, tmp_path: Path, index: GuideIndex):
281
+ p = tmp_path / "docstring.py"
282
+ p.write_text(
283
+ "def example():\n"
284
+ ' """Use datetime.utcnow() for timestamps.\n'
285
+ "\n"
286
+ " Also from typing import List is common.\n"
287
+ ' """\n'
288
+ " return 1\n",
289
+ encoding="utf-8",
290
+ )
291
+ matches = check_file(p, index)
292
+ assert matches == []
293
+
294
+ def test_inline_string_on_code_line_still_matched(self, tmp_path: Path, index: GuideIndex):
295
+ p = tmp_path / "inline.py"
296
+ p.write_text(
297
+ 'from typing import List\nx = "some string"\n',
298
+ encoding="utf-8",
299
+ )
300
+ matches = check_file(p, index)
301
+ ids = {m.guide_id for m in matches}
302
+ assert "use-builtin-generics" in ids
303
+
304
+ def test_tokenize_failure_falls_back(self, tmp_path: Path, index: GuideIndex):
305
+ p = tmp_path / "broken.py"
306
+ p.write_text(
307
+ 'from typing import List\nx = """unterminated\n',
308
+ encoding="utf-8",
309
+ )
310
+ matches = check_file(p, index)
311
+ ids = {m.guide_id for m in matches}
312
+ assert "use-builtin-generics" in ids
313
+
314
+ def test_indentation_error_falls_back(self, tmp_path: Path, index: GuideIndex):
315
+ p = tmp_path / "indent.py"
316
+ p.write_text(
317
+ "from typing import List\nif True:\n x = 1\n y = 2\n",
318
+ encoding="utf-8",
319
+ )
320
+ matches = check_file(p, index)
321
+ ids = {m.guide_id for m in matches}
322
+ assert "use-builtin-generics" in ids
323
+
324
+ def test_single_line_string_not_skipped(self, tmp_path: Path, index: GuideIndex):
325
+ p = tmp_path / "singleline.py"
326
+ p.write_text(
327
+ "from typing import List # 'example'\n",
328
+ encoding="utf-8",
329
+ )
330
+ matches = check_file(p, index)
331
+ ids = {m.guide_id for m in matches}
332
+ assert "use-builtin-generics" in ids
333
+
334
+
279
335
  class TestFreqRank:
280
336
  def test_all_frequencies_covered(self):
281
337
  assert "high" in FREQ_RANK
@@ -33,6 +33,18 @@ class TestVersionCompatible:
33
33
  def test_patch_level_target(self):
34
34
  assert version_compatible(">=3.9", "3.11.1") is True
35
35
 
36
+ def test_unexpected_error_propagates(self, monkeypatch):
37
+ def _boom(self, *a, **kw):
38
+ raise RuntimeError("unexpected")
39
+
40
+ fake = type("Boom", (), {"__init__": _boom, "__contains__": _boom})
41
+ monkeypatch.setattr(
42
+ "modern_python_guidance.compat.SpecifierSet",
43
+ fake,
44
+ )
45
+ with pytest.raises(RuntimeError, match="unexpected"):
46
+ version_compatible(">=3.9", "3.12")
47
+
36
48
 
37
49
  class TestTokenEstimate:
38
50
  def test_empty_string(self):
@@ -346,6 +346,49 @@ Body.
346
346
  parse_frontmatter(text)
347
347
 
348
348
 
349
+ def test_invalid_python_specifier_rejected():
350
+ text = """\
351
+ ---
352
+ id: bad-spec
353
+ title: Bad Specifier
354
+ category: typing
355
+ layer: 1
356
+ tags:
357
+ - test
358
+ python: "not-a-valid-specifier"
359
+ frequency: high
360
+ ---
361
+
362
+ Body.
363
+ """
364
+ with pytest.raises(FrontmatterError, match="invalid python specifier"):
365
+ parse_frontmatter(text)
366
+
367
+
368
+ @pytest.mark.parametrize(
369
+ "specifier",
370
+ [">=3.9", ">=3.11", ">=3.9,<3.13", ">=3.11,<3.14", ""],
371
+ ids=["ge39", "ge311", "range39_13", "range311_14", "empty"],
372
+ )
373
+ def test_valid_python_specifiers_accepted(specifier: str):
374
+ text = f"""\
375
+ ---
376
+ id: valid-spec
377
+ title: Valid Specifier
378
+ category: typing
379
+ layer: 1
380
+ tags:
381
+ - test
382
+ python: "{specifier}"
383
+ frequency: high
384
+ ---
385
+
386
+ Body.
387
+ """
388
+ meta, _ = parse_frontmatter(text)
389
+ assert meta.python == specifier
390
+
391
+
349
392
  def test_detect_patterns_non_list_rejected():
350
393
  text = """\
351
394
  ---
@@ -210,6 +210,15 @@ class TestDetectPatterns:
210
210
  )
211
211
 
212
212
 
213
+ class TestPythonSpecifiers:
214
+ def test_all_guides_have_valid_specifiers(self, guide_file: Path):
215
+ from packaging.specifiers import SpecifierSet
216
+
217
+ text = guide_file.read_text(encoding="utf-8")
218
+ meta, _ = parse_frontmatter(text)
219
+ SpecifierSet(meta.python)
220
+
221
+
213
222
  class TestGuideInventory:
214
223
  def test_no_duplicate_ids(self):
215
224
  seen: dict[str, Path] = {}
@@ -386,6 +386,37 @@ class TestProtocol:
386
386
  assert result["isError"] is True
387
387
 
388
388
 
389
+ class TestMalformedParams:
390
+ def test_non_dict_params_returns_error_and_server_continues(self):
391
+ responses = _run_mcp(
392
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": "bad"},
393
+ {"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}},
394
+ )
395
+ assert len(responses) == 2
396
+ assert responses[0]["error"]["code"] == -32602
397
+ assert responses[0]["id"] == 1
398
+ assert "expected object" in responses[0]["error"]["message"]
399
+ assert "protocolVersion" in responses[1]["result"]
400
+
401
+ def test_non_dict_arguments_returns_error_and_server_continues(self):
402
+ responses = _run_mcp(
403
+ *_init_handshake(),
404
+ {
405
+ "jsonrpc": "2.0",
406
+ "id": 1,
407
+ "method": "tools/call",
408
+ "params": {"name": "search_guides", "arguments": "not-a-dict"},
409
+ },
410
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
411
+ )
412
+ error = responses[1].get("error")
413
+ assert error is not None
414
+ assert error["code"] == -32602
415
+ assert "expected object" in error["message"]
416
+ assert responses[2]["id"] == 2
417
+ assert "tools" in responses[2]["result"]
418
+
419
+
389
420
  class TestStdoutPollution:
390
421
  def test_no_non_jsonrpc_output(self):
391
422
  stdin_data = _build_session(
@@ -362,6 +362,49 @@ class TestHandleRequest:
362
362
  assert resp["error"]["code"] == -32600
363
363
  assert "expected JSON object" in resp["error"]["message"]
364
364
 
365
+ @pytest.mark.parametrize(
366
+ "params",
367
+ [None, "hello", "", [1, 2], 42, 0, 3.14, True, False],
368
+ ids=["none", "string", "empty-string", "list", "int", "zero", "float", "true", "false"],
369
+ )
370
+ def test_non_dict_params_returns_invalid_params(self, params):
371
+ msg = {"jsonrpc": "2.0", "id": 10, "method": "initialize", "params": params}
372
+ resp = mcp._handle_request(msg)
373
+ assert resp["jsonrpc"] == "2.0"
374
+ assert resp["id"] == 10
375
+ assert resp["error"]["code"] == -32602
376
+ assert "expected object" in resp["error"]["message"]
377
+
378
+ def test_non_dict_params_notification_returns_none(self):
379
+ msg = {"jsonrpc": "2.0", "method": "initialize", "params": "bad"}
380
+ assert mcp._handle_request(msg) is None
381
+
382
+ def test_non_dict_arguments_returns_invalid_params(self):
383
+ msg = {
384
+ "jsonrpc": "2.0",
385
+ "id": 11,
386
+ "method": "tools/call",
387
+ "params": {"name": "search_guides", "arguments": "bad"},
388
+ }
389
+ resp = mcp._handle_request(msg)
390
+ assert resp["id"] == 11
391
+ assert resp["error"]["code"] == -32602
392
+ assert "expected object" in resp["error"]["message"]
393
+
394
+ def test_non_dict_arguments_notification_returns_none(self):
395
+ msg = {
396
+ "jsonrpc": "2.0",
397
+ "method": "tools/call",
398
+ "params": {"name": "search_guides", "arguments": [1, 2]},
399
+ }
400
+ assert mcp._handle_request(msg) is None
401
+
402
+ def test_unknown_method_with_bad_params_returns_invalid_params(self):
403
+ """Centralized params check runs before method dispatch."""
404
+ msg = {"jsonrpc": "2.0", "id": 12, "method": "nonexistent", "params": 42}
405
+ resp = mcp._handle_request(msg)
406
+ assert resp["error"]["code"] == -32602
407
+
365
408
 
366
409
  # --- serve ---
367
410
 
@@ -421,3 +464,72 @@ class TestServe:
421
464
  assert responses[0]["id"] is None
422
465
  assert responses[1]["id"] == 1
423
466
  assert "protocolVersion" in responses[1]["result"]
467
+
468
+ def test_non_dict_params_recovery(self):
469
+ lines = (
470
+ json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": "bad"})
471
+ + "\n"
472
+ + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}})
473
+ + "\n"
474
+ )
475
+ sin = io.StringIO(lines)
476
+ sout = io.StringIO()
477
+ mcp.serve(stdin=sin, stdout=sout)
478
+ responses = [json.loads(line) for line in sout.getvalue().strip().split("\n")]
479
+ assert len(responses) == 2
480
+ assert responses[0]["error"]["code"] == -32602
481
+ assert responses[0]["id"] == 1
482
+ assert responses[1]["id"] == 2
483
+ assert "protocolVersion" in responses[1]["result"]
484
+
485
+ def test_catch_all_returns_internal_error(self, monkeypatch):
486
+ def boom(msg):
487
+ raise RuntimeError("unexpected")
488
+
489
+ monkeypatch.setattr(mcp, "_handle_request", boom)
490
+ req = {"jsonrpc": "2.0", "id": 99, "method": "initialize", "params": {}}
491
+ sin = io.StringIO(json.dumps(req) + "\n")
492
+ sout = io.StringIO()
493
+ mcp.serve(stdin=sin, stdout=sout)
494
+ resp = json.loads(sout.getvalue().strip())
495
+ assert resp["id"] == 99
496
+ assert resp["error"]["code"] == -32603
497
+ assert resp["error"]["message"] == "Internal error"
498
+
499
+ def test_catch_all_notification_no_response(self, monkeypatch):
500
+ def boom(msg):
501
+ raise RuntimeError("unexpected")
502
+
503
+ monkeypatch.setattr(mcp, "_handle_request", boom)
504
+ req = {"jsonrpc": "2.0", "method": "initialize", "params": {}}
505
+ sin = io.StringIO(json.dumps(req) + "\n")
506
+ sout = io.StringIO()
507
+ mcp.serve(stdin=sin, stdout=sout)
508
+ assert sout.getvalue() == ""
509
+
510
+ def test_catch_all_then_recovery(self, monkeypatch):
511
+ call_count = 0
512
+
513
+ original = mcp._handle_request
514
+
515
+ def boom_once(msg):
516
+ nonlocal call_count
517
+ call_count += 1
518
+ if call_count == 1:
519
+ raise RuntimeError("first call fails")
520
+ return original(msg)
521
+
522
+ monkeypatch.setattr(mcp, "_handle_request", boom_once)
523
+ lines = (
524
+ json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
525
+ + "\n"
526
+ + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}})
527
+ + "\n"
528
+ )
529
+ sin = io.StringIO(lines)
530
+ sout = io.StringIO()
531
+ mcp.serve(stdin=sin, stdout=sout)
532
+ responses = [json.loads(line) for line in sout.getvalue().strip().split("\n")]
533
+ assert len(responses) == 2
534
+ assert responses[0]["error"]["code"] == -32603
535
+ assert "protocolVersion" in responses[1]["result"]