modern-python-guidance 0.3.3__tar.gz → 0.3.5__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 (131) hide show
  1. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/.github/workflows/ci.yml +5 -2
  2. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/CHANGELOG.md +21 -1
  3. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/CONTRIBUTING.md +15 -3
  4. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/PKG-INFO +2 -1
  5. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/score_v5.py +6 -3
  6. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/pyproject.toml +16 -1
  7. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/__init__.py +1 -1
  8. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/cli.py +22 -8
  9. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/frontmatter.py +1 -3
  10. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/mcp_server.py +8 -5
  11. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/search.py +25 -17
  12. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/version_detect.py +2 -6
  13. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/tests/test_cli_integration.py +27 -5
  14. modern_python_guidance-0.3.5/tests/test_guide_structure.py +129 -0
  15. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/tests/test_mcp_server.py +157 -70
  16. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/tests/test_retrieve.py +10 -2
  17. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/tests/test_search.py +6 -23
  18. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/tests/test_setup.py +13 -6
  19. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/tests/test_skill_sync.py +2 -6
  20. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/tests/test_version_detect.py +6 -18
  21. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/.github/workflows/check-python-release.yml +0 -0
  22. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/.github/workflows/publish.yml +0 -0
  23. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/.gitignore +0 -0
  24. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/LICENSE +0 -0
  25. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/LICENSE-MIT +0 -0
  26. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/README.md +0 -0
  27. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/SECURITY.md +0 -0
  28. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/edge-cases/opus48_multiline.py +0 -0
  29. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/edge-cases/valid_alt_patterns.py +0 -0
  30. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-modern/pyproject.toml +0 -0
  31. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-modern/src/app.py +0 -0
  32. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-modern/src/config.py +0 -0
  33. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-modern/src/crawler.py +0 -0
  34. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-modern/src/models.py +0 -0
  35. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-modern/src/scanner.py +0 -0
  36. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-modern/src/utils.py +0 -0
  37. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-outdated/pyproject.toml +0 -0
  38. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-outdated/setup.py +0 -0
  39. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-outdated/src/app.py +0 -0
  40. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-outdated/src/config.py +0 -0
  41. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-outdated/src/crawler.py +0 -0
  42. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-outdated/src/models.py +0 -0
  43. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-outdated/src/scanner.py +0 -0
  44. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-a-outdated/src/utils.py +0 -0
  45. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-b-modern/myapp/models.py +0 -0
  46. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-b-modern/myapp/views.py +0 -0
  47. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-b-outdated/myapp/models.py +0 -0
  48. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-b-outdated/myapp/views.py +0 -0
  49. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-c-modern/tests/test_calculator.py +0 -0
  50. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/fixtures/variant-c-outdated/tests/test_calculator.py +0 -0
  51. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/mcp-config.json +0 -0
  52. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompt-v2.txt +0 -0
  53. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompt-v3-mcp.txt +0 -0
  54. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompt-v3.txt +0 -0
  55. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompt-v4-a.txt +0 -0
  56. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompt-v4-b.txt +0 -0
  57. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompt-v4-c.txt +0 -0
  58. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompt.txt +0 -0
  59. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompts/v5-a-detailed.txt +0 -0
  60. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompts/v5-a-normal.txt +0 -0
  61. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompts/v5-a-terse.txt +0 -0
  62. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompts/v5-b-detailed.txt +0 -0
  63. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompts/v5-b-normal.txt +0 -0
  64. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompts/v5-b-terse.txt +0 -0
  65. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompts/v5-c-detailed.txt +0 -0
  66. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompts/v5-c-normal.txt +0 -0
  67. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/prompts/v5-c-terse.txt +0 -0
  68. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/run-mcp.sh +0 -0
  69. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/run-v4.sh +0 -0
  70. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/run-v5.sh +0 -0
  71. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/run.sh +0 -0
  72. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/score-v2.sh +0 -0
  73. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/score-v3.sh +0 -0
  74. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/score-v4.sh +0 -0
  75. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/score.sh +0 -0
  76. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/bench/test-scorer.sh +0 -0
  77. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/docs/benchmark-evaluation.md +0 -0
  78. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/docs/benchmark-procedure.md +0 -0
  79. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/docs/benchmark-v5.md +0 -0
  80. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/docs/design.md +0 -0
  81. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/SKILL.md +0 -0
  82. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
  83. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
  84. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
  85. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -0
  86. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/data-structures/dict-merge-operator.md +0 -0
  87. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/data-structures/match-case-patterns.md +0 -0
  88. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/django/django-async-views.md +0 -0
  89. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/django/django-check-constraints.md +0 -0
  90. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/django/django-json-field.md +0 -0
  91. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
  92. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +0 -0
  93. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +0 -0
  94. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/httpx/httpx-async-client-reuse.md +0 -0
  95. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
  96. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
  97. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-model-api.md +0 -0
  98. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
  99. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
  100. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/pytest/pytest-parametrize.md +0 -0
  101. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/pytest/pytest-raises-match.md +0 -0
  102. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/pytest/pytest-tmp-path.md +0 -0
  103. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-2-style.md +0 -0
  104. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-async-session.md +0 -0
  105. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-mapped-column.md +0 -0
  106. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
  107. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/stdlib/pathlib-over-os-path.md +0 -0
  108. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
  109. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/stdlib/template-strings.md +0 -0
  110. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
  111. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
  112. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/toolchain/pyproject-toml-over-setup.md +0 -0
  113. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
  114. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
  115. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
  116. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/typing/deferred-annotations.md +0 -0
  117. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
  118. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
  119. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
  120. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
  121. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
  122. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
  123. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/__main__.py +0 -0
  124. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/compat.py +0 -0
  125. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/guide_index.py +0 -0
  126. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/retrieve.py +0 -0
  127. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/setup_cmd.py +0 -0
  128. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/src/modern_python_guidance/uninstall_cmd.py +0 -0
  129. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/tests/test_frontmatter.py +0 -0
  130. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/tests/test_scorer_v5.py +0 -0
  131. {modern_python_guidance-0.3.3 → modern_python_guidance-0.3.5}/tests/test_uninstall.py +0 -0
@@ -30,8 +30,11 @@ jobs:
30
30
  - name: Install dependencies
31
31
  run: uv pip install --system -e ".[dev]"
32
32
 
33
- - name: Run tests
34
- run: pytest --tb=short -q
33
+ - name: Check formatting
34
+ run: ruff format --check src/ tests/
35
35
 
36
36
  - name: Run linter
37
37
  run: ruff check src/ tests/
38
+
39
+ - name: Run tests
40
+ run: pytest --tb=short -q --cov --cov-report=term-missing
@@ -2,7 +2,27 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## [0.3.3] — 2026-05-30
5
+ ## [0.3.5] — 2026-05-30
6
+
7
+ ### Added
8
+
9
+ - CI format gate: `ruff format --check src/ tests/` runs before linter, catching formatting regressions at PR time (closes #19)
10
+ - Coverage reporting: `pytest-cov` with branch coverage and `fail_under = 59%` ratchet threshold (closes #15)
11
+ - Guide structure validation: 248 parametrized tests validating all 41 guides — frontmatter fields, section order, code fences, H1 title, no duplicate IDs (closes #16)
12
+ - CONTRIBUTING.md: documented CI checks, format fix command, and guide count update step
13
+
14
+ ### Changed
15
+
16
+ - Auto-formatted 12 existing source/test files with `ruff format` (whitespace only, no logic changes)
17
+ - CI step order: checkout → setup → install → **format check** → linter → tests (with `--cov`)
18
+
19
+ ## [0.3.4] — 2026-05-30
20
+
21
+ ### Fixed
22
+
23
+ - v0.3.3 shipped with `__version__ = "0.3.2"` in `__init__.py` (pyproject.toml was correct). This release fixes the version string
24
+
25
+ ## [0.3.3] — 2026-05-30 (yanked — `__version__` mismatch)
6
26
 
7
27
  ### Added
8
28
 
@@ -37,13 +37,25 @@ See [docs/design.md](docs/design.md) for the full design document.
37
37
  | `frequency` | string | `high` (LLMs do this often), `medium`, `low` |
38
38
 
39
39
  3. Write BAD/GOOD/Why/Version Notes sections
40
- 4. Run `pytest` to verify the guide parses correctly
40
+ 4. Update `EXPECTED_GUIDE_COUNT` in `tests/test_guide_structure.py`
41
+ 5. Run `pytest` — the guide structure tests validate frontmatter, section order, and code fences automatically
41
42
 
42
43
  ## Running tests
43
44
 
44
45
  ```bash
45
46
  uv venv && source .venv/bin/activate
46
47
  uv pip install -e ".[dev]"
47
- pytest
48
- ruff check src/ tests/
48
+ pytest # 510+ tests including guide structure validation
49
+ ruff check src/ tests/ # lint
50
+ ruff format --check src/ tests/ # format check (CI enforced)
49
51
  ```
52
+
53
+ To auto-fix formatting: `ruff format src/ tests/`
54
+
55
+ ## CI checks
56
+
57
+ All PRs run these checks on Python 3.11, 3.12, and 3.13:
58
+
59
+ 1. `ruff format --check` — formatting
60
+ 2. `ruff check` — linting
61
+ 3. `pytest --cov` — tests with branch coverage (`fail_under = 59%`)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modern-python-guidance
3
- Version: 0.3.3
3
+ Version: 0.3.5
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
@@ -25,6 +25,7 @@ Classifier: Typing :: Typed
25
25
  Requires-Python: >=3.11
26
26
  Requires-Dist: packaging>=23.0
27
27
  Provides-Extra: dev
28
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
28
29
  Requires-Dist: pytest>=7.0; extra == 'dev'
29
30
  Requires-Dist: ruff>=0.4.0; extra == 'dev'
30
31
  Description-Content-Type: text/markdown
@@ -1476,14 +1476,17 @@ def main() -> None:
1476
1476
  "c": "C (pytest)",
1477
1477
  }
1478
1478
  v_label = variant_labels.get(args.variant, args.variant)
1479
- print(f"=== V5 Scoring: Variant {v_label}, Run {args.run_id} ===")
1480
- print()
1479
+
1480
+ if args.output_format == "human":
1481
+ print(f"=== V5 Scoring: Variant {v_label}, Run {args.run_id} ===")
1482
+ print()
1481
1483
 
1482
1484
  output = {}
1483
1485
  for session_name in ("control", "treatment"):
1484
1486
  session_dir = results_dir / session_name
1485
1487
  if not session_dir.is_dir():
1486
- print(f" [{session_name}] Directory not found, skipping.")
1488
+ if args.output_format == "human":
1489
+ print(f" [{session_name}] Directory not found, skipping.")
1487
1490
  continue
1488
1491
  data = score_session(session_dir, args.variant)
1489
1492
  output[session_name] = data
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "modern-python-guidance"
7
- version = "0.3.3"
7
+ version = "0.3.5"
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"
@@ -34,6 +34,7 @@ dependencies = [
34
34
  [project.optional-dependencies]
35
35
  dev = [
36
36
  "pytest>=7.0",
37
+ "pytest-cov>=4.0",
37
38
  "ruff>=0.4.0",
38
39
  ]
39
40
 
@@ -66,3 +67,17 @@ select = ["E", "F", "W", "I", "UP", "FURB", "B", "SIM", "RUF"]
66
67
 
67
68
  [tool.pytest.ini_options]
68
69
  testpaths = ["tests"]
70
+
71
+ [tool.coverage.run]
72
+ source = ["modern_python_guidance"]
73
+ branch = true
74
+
75
+ [tool.coverage.report]
76
+ show_missing = true
77
+ skip_empty = true
78
+ fail_under = 59
79
+ exclude_lines = [
80
+ "pragma: no cover",
81
+ "if __name__ == .__main__.",
82
+ "if TYPE_CHECKING:",
83
+ ]
@@ -1,3 +1,3 @@
1
1
  """Modern Python Guidance — version-aware BAD/GOOD pattern guides for AI coding agents."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.5"
@@ -36,7 +36,9 @@ def main(argv: list[str] | None = None) -> None:
36
36
  p_search.add_argument("--category", help="Filter by category")
37
37
  p_search.add_argument("--limit", type=int, default=10, help="Max results (default: 10)")
38
38
  p_search.add_argument(
39
- "--format", choices=["json", "human"], default=None,
39
+ "--format",
40
+ choices=["json", "human"],
41
+ default=None,
40
42
  help="Output format (default: json when piped, human when TTY)",
41
43
  )
42
44
 
@@ -45,7 +47,9 @@ def main(argv: list[str] | None = None) -> None:
45
47
  p_retrieve.add_argument("ids", help="Comma-separated guide IDs")
46
48
  p_retrieve.add_argument("--python-version", help="Target Python version")
47
49
  p_retrieve.add_argument(
48
- "--format", choices=["json", "human"], default=None,
50
+ "--format",
51
+ choices=["json", "human"],
52
+ default=None,
49
53
  help="Output format (default: json when piped, human when TTY)",
50
54
  )
51
55
 
@@ -54,7 +58,9 @@ def main(argv: list[str] | None = None) -> None:
54
58
  p_list.add_argument("--category", help="Filter by category")
55
59
  p_list.add_argument("--python-version", help="Filter by Python version")
56
60
  p_list.add_argument(
57
- "--format", choices=["json", "human"], default=None,
61
+ "--format",
62
+ choices=["json", "human"],
63
+ default=None,
58
64
  help="Output format (default: json when piped, human when TTY)",
59
65
  )
60
66
 
@@ -67,27 +73,35 @@ def main(argv: list[str] | None = None) -> None:
67
73
 
68
74
  # setup
69
75
  p_setup = subparsers.add_parser(
70
- "setup", help="Register MCP server and link Agent Skills",
76
+ "setup",
77
+ help="Register MCP server and link Agent Skills",
71
78
  )
72
79
  p_setup.add_argument("--mcp-only", action="store_true", help="MCP registration only")
73
80
  p_setup.add_argument("--skills-only", action="store_true", help="Skills symlink only")
74
81
  p_setup.add_argument(
75
- "--scope", choices=["user", "local"], default="user",
82
+ "--scope",
83
+ choices=["user", "local"],
84
+ default="user",
76
85
  help="MCP scope (default: user)",
77
86
  )
78
87
  p_setup.add_argument(
79
- "--project-dir", type=Path, help="Project directory for Skills symlink",
88
+ "--project-dir",
89
+ type=Path,
90
+ help="Project directory for Skills symlink",
80
91
  )
81
92
  p_setup.add_argument("--dry-run", action="store_true", help="Show what would be done")
82
93
 
83
94
  # uninstall
84
95
  p_uninstall = subparsers.add_parser(
85
- "uninstall", help="Reverse 'setup': deregister MCP server and unlink Agent Skills",
96
+ "uninstall",
97
+ help="Reverse 'setup': deregister MCP server and unlink Agent Skills",
86
98
  )
87
99
  p_uninstall.add_argument("--mcp-only", action="store_true", help="MCP deregistration only")
88
100
  p_uninstall.add_argument("--skills-only", action="store_true", help="Skills unlink only")
89
101
  p_uninstall.add_argument(
90
- "--project-dir", type=Path, help="Project directory for Skills symlink",
102
+ "--project-dir",
103
+ type=Path,
104
+ help="Project directory for Skills symlink",
91
105
  )
92
106
  p_uninstall.add_argument("--dry-run", action="store_true", help="Show what would be done")
93
107
 
@@ -75,9 +75,7 @@ def _parse_raw(lines: list[str]) -> dict[str, Any]:
75
75
  if current_key is None:
76
76
  raise FrontmatterError("list item without preceding key", line=i)
77
77
  if not isinstance(data[current_key], list):
78
- raise FrontmatterError(
79
- f"list item for non-list key '{current_key}'", line=i
80
- )
78
+ raise FrontmatterError(f"list item for non-list key '{current_key}'", line=i)
81
79
  data[current_key].append(_parse_scalar(list_match.group(1).strip()))
82
80
  continue
83
81
 
@@ -340,11 +340,14 @@ def _handle_request(msg: dict) -> dict | None:
340
340
  return None
341
341
 
342
342
  if method == "initialize":
343
- result = _result_response(req_id, {
344
- "protocolVersion": PROTOCOL_VERSION,
345
- "capabilities": {"tools": {}},
346
- "serverInfo": {"name": "modern-python-guidance", "version": __version__},
347
- })
343
+ result = _result_response(
344
+ req_id,
345
+ {
346
+ "protocolVersion": PROTOCOL_VERSION,
347
+ "capabilities": {"tools": {}},
348
+ "serverInfo": {"name": "modern-python-guidance", "version": __version__},
349
+ },
350
+ )
348
351
  return None if is_notification else result
349
352
 
350
353
  if method == "tools/list":
@@ -60,19 +60,25 @@ def search(
60
60
 
61
61
  if score > 0:
62
62
  score += FREQ_BOOST.get(meta.frequency, 0.0)
63
- results.append(SearchResult(
64
- guide_id=guide_id,
65
- score=score,
66
- meta=meta,
67
- token_estimate=token_estimate(guide.body),
68
- snippet=guide.snippet,
69
- ))
63
+ results.append(
64
+ SearchResult(
65
+ guide_id=guide_id,
66
+ score=score,
67
+ meta=meta,
68
+ token_estimate=token_estimate(guide.body),
69
+ snippet=guide.snippet,
70
+ )
71
+ )
70
72
 
71
73
  results.sort(key=lambda r: (-r.score, r.guide_id))
72
74
 
73
75
  if not results:
74
76
  return _fuzzy_fallback(
75
- index, query, python_version=python_version, category=category, limit=limit,
77
+ index,
78
+ query,
79
+ python_version=python_version,
80
+ category=category,
81
+ limit=limit,
76
82
  )
77
83
 
78
84
  return results[:limit]
@@ -139,14 +145,16 @@ def _fuzzy_fallback(
139
145
  continue
140
146
  seen.add(guide_id)
141
147
  guide = candidates[guide_id]
142
- results.append(SearchResult(
143
- guide_id=guide_id,
144
- score=round(ratio, 3),
145
- meta=guide.meta,
146
- token_estimate=token_estimate(guide.body),
147
- fuzzy=True,
148
- snippet=guide.snippet,
149
- ))
148
+ results.append(
149
+ SearchResult(
150
+ guide_id=guide_id,
151
+ score=round(ratio, 3),
152
+ meta=guide.meta,
153
+ token_estimate=token_estimate(guide.body),
154
+ fuzzy=True,
155
+ snippet=guide.snippet,
156
+ )
157
+ )
150
158
 
151
159
  results.sort(key=lambda r: (-r.score, r.guide_id))
152
- return results[:min(limit, FUZZY_MAX)]
160
+ return results[: min(limit, FUZZY_MAX)]
@@ -21,9 +21,7 @@ log = logging.getLogger(__name__)
21
21
 
22
22
  DEFAULT_VERSION = "3.11"
23
23
 
24
- _KNOWN_MINORS = [
25
- Version(f"3.{minor}") for minor in range(7, 20)
26
- ]
24
+ _KNOWN_MINORS = [Version(f"3.{minor}") for minor in range(7, 20)]
27
25
 
28
26
  _POETRY_CARET_RE = re.compile(r"\^(\d+\.\d+)")
29
27
 
@@ -68,9 +66,7 @@ def _from_pyproject(path: Path) -> str | None:
68
66
  if requires_python:
69
67
  return _min_version_from_specifier(requires_python)
70
68
 
71
- poetry_python = (
72
- data.get("tool", {}).get("poetry", {}).get("dependencies", {}).get("python")
73
- )
69
+ poetry_python = data.get("tool", {}).get("poetry", {}).get("dependencies", {}).get("python")
74
70
  if poetry_python:
75
71
  m = _POETRY_CARET_RE.search(str(poetry_python))
76
72
  if m:
@@ -55,8 +55,17 @@ class TestSearch:
55
55
  assert r.returncode == 0
56
56
  data = json.loads(r.stdout)
57
57
  expected_keys = {
58
- "id", "title", "category", "layer", "tags", "python",
59
- "frequency", "score", "token_estimate", "fuzzy", "snippet",
58
+ "id",
59
+ "title",
60
+ "category",
61
+ "layer",
62
+ "tags",
63
+ "python",
64
+ "frequency",
65
+ "score",
66
+ "token_estimate",
67
+ "fuzzy",
68
+ "snippet",
60
69
  }
61
70
  assert set(data[0].keys()) == expected_keys
62
71
  assert isinstance(data[0]["tags"], list)
@@ -89,14 +98,27 @@ class TestRetrieve:
89
98
  r = run_cli("retrieve", "use-builtin-generics", "--format", "json")
90
99
  data = json.loads(r.stdout)
91
100
  expected_keys = {
92
- "id", "title", "category", "layer", "python",
93
- "frequency", "version_match", "content", "token_estimate", "source",
101
+ "id",
102
+ "title",
103
+ "category",
104
+ "layer",
105
+ "python",
106
+ "frequency",
107
+ "version_match",
108
+ "content",
109
+ "token_estimate",
110
+ "source",
94
111
  }
95
112
  assert set(data[0].keys()) == expected_keys
96
113
 
97
114
  def test_retrieve_version_match_flag(self):
98
115
  r = run_cli(
99
- "retrieve", "taskgroup-over-gather", "--python-version", "3.9", "--format", "json",
116
+ "retrieve",
117
+ "taskgroup-over-gather",
118
+ "--python-version",
119
+ "3.9",
120
+ "--format",
121
+ "json",
100
122
  )
101
123
  data = json.loads(r.stdout)
102
124
  assert data[0]["version_match"] is False
@@ -0,0 +1,129 @@
1
+ """Structural validation for all guide files.
2
+
3
+ Ensures every guide in skills/modern-python-guidance/guides/ conforms to:
4
+ - Valid frontmatter (parsed by parse_frontmatter)
5
+ - id matches filename, category matches parent directory
6
+ - Exactly 5 ## sections in order: BAD, GOOD, Why, <any>, References
7
+ - Code fences in BAD and GOOD sections
8
+ - Body starts with H1 title
9
+ - No duplicate IDs across all guides
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ from modern_python_guidance.frontmatter import parse_frontmatter
20
+ from modern_python_guidance.guide_index import _find_guides_dir
21
+
22
+ GUIDES_DIR = _find_guides_dir()
23
+ EXPECTED_GUIDE_COUNT = 41
24
+
25
+ REQUIRED_HEADING_ORDER = {
26
+ 0: "BAD",
27
+ 1: "GOOD",
28
+ 2: "Why",
29
+ 4: "References",
30
+ }
31
+
32
+
33
+ def _collect_guides() -> list[Path]:
34
+ return sorted(GUIDES_DIR.rglob("*.md"))
35
+
36
+
37
+ def _headings_outside_fences(body: str) -> list[str]:
38
+ # Handles both ``` and ~~~ fences; guide_index._code_lines only handles ```
39
+ headings = []
40
+ in_fence = False
41
+ for line in body.splitlines():
42
+ stripped = line.strip()
43
+ if re.match(r"^(`{3,}|~{3,})", stripped):
44
+ in_fence = not in_fence
45
+ continue
46
+ if not in_fence and line.startswith("## "):
47
+ headings.append(line[3:].strip())
48
+ return headings
49
+
50
+
51
+ def _section_text(body: str, heading: str) -> str:
52
+ # Not fence-aware: splits on \n## which could appear inside code fences.
53
+ # Current guides have no ## at line start inside fences, so this is safe for now.
54
+ parts = body.split(f"## {heading}\n", 1)
55
+ if len(parts) < 2:
56
+ return ""
57
+ section = parts[1].split("\n## ", 1)[0]
58
+ return section
59
+
60
+
61
+ _GUIDE_FILES = _collect_guides()
62
+ _GUIDE_IDS = [f"{f.parent.name}/{f.stem}" for f in _GUIDE_FILES]
63
+
64
+
65
+ @pytest.fixture(params=_GUIDE_FILES, ids=_GUIDE_IDS)
66
+ def guide_file(request: pytest.FixtureRequest) -> Path:
67
+ return request.param
68
+
69
+
70
+ class TestGuideStructure:
71
+ def test_parses_without_error(self, guide_file: Path):
72
+ text = guide_file.read_text(encoding="utf-8")
73
+ parse_frontmatter(text)
74
+
75
+ def test_id_matches_filename(self, guide_file: Path):
76
+ text = guide_file.read_text(encoding="utf-8")
77
+ meta, _ = parse_frontmatter(text)
78
+ assert meta.id == guide_file.stem, (
79
+ f"frontmatter id '{meta.id}' != filename '{guide_file.stem}'"
80
+ )
81
+
82
+ def test_category_matches_dirname(self, guide_file: Path):
83
+ text = guide_file.read_text(encoding="utf-8")
84
+ meta, _ = parse_frontmatter(text)
85
+ assert meta.category == guide_file.parent.name, (
86
+ f"frontmatter category '{meta.category}' != dir '{guide_file.parent.name}'"
87
+ )
88
+
89
+ def test_section_headings(self, guide_file: Path):
90
+ text = guide_file.read_text(encoding="utf-8")
91
+ _, body = parse_frontmatter(text)
92
+ headings = _headings_outside_fences(body)
93
+
94
+ assert len(headings) == 5, f"expected 5 ## headings, got {len(headings)}: {headings}"
95
+ for idx, expected in REQUIRED_HEADING_ORDER.items():
96
+ assert headings[idx] == expected, (
97
+ f"heading[{idx}] expected '{expected}', got '{headings[idx]}'"
98
+ )
99
+
100
+ def test_bad_good_have_code_fences(self, guide_file: Path):
101
+ text = guide_file.read_text(encoding="utf-8")
102
+ _, body = parse_frontmatter(text)
103
+ for section_name in ("BAD", "GOOD"):
104
+ section = _section_text(body, section_name)
105
+ assert re.search(r"^(`{3,}|~{3,})", section, re.MULTILINE), (
106
+ f"## {section_name} section has no code fence"
107
+ )
108
+
109
+ def test_body_starts_with_h1(self, guide_file: Path):
110
+ text = guide_file.read_text(encoding="utf-8")
111
+ _, body = parse_frontmatter(text)
112
+ assert body.startswith("# "), "body does not start with H1 heading"
113
+
114
+
115
+ class TestGuideInventory:
116
+ def test_no_duplicate_ids(self):
117
+ seen: dict[str, Path] = {}
118
+ for guide_file in _GUIDE_FILES:
119
+ text = guide_file.read_text(encoding="utf-8")
120
+ meta, _ = parse_frontmatter(text)
121
+ assert meta.id not in seen, (
122
+ f"duplicate id '{meta.id}': {seen[meta.id]} and {guide_file}"
123
+ )
124
+ seen[meta.id] = guide_file
125
+
126
+ def test_guide_count(self):
127
+ assert len(_GUIDE_FILES) == EXPECTED_GUIDE_COUNT, (
128
+ f"expected {EXPECTED_GUIDE_COUNT} guides, found {len(_GUIDE_FILES)}"
129
+ )