modern-python-guidance 0.2.1__tar.gz → 0.2.3__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 (110) hide show
  1. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/CHANGELOG.md +23 -0
  2. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/PKG-INFO +2 -2
  3. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/README.md +1 -1
  4. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/pyproject.toml +1 -1
  5. modern_python_guidance-0.2.3/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +85 -0
  6. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +1 -1
  7. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +5 -0
  8. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/src/modern_python_guidance/__init__.py +1 -1
  9. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/src/modern_python_guidance/cli.py +4 -0
  10. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/src/modern_python_guidance/guide_index.py +39 -0
  11. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/src/modern_python_guidance/mcp_server.py +4 -0
  12. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/src/modern_python_guidance/search.py +3 -0
  13. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/tests/test_cli_integration.py +12 -0
  14. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/tests/test_mcp_server.py +20 -0
  15. modern_python_guidance-0.2.3/tests/test_search.py +223 -0
  16. modern_python_guidance-0.2.1/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -73
  17. modern_python_guidance-0.2.1/tests/test_search.py +0 -89
  18. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/.github/workflows/ci.yml +0 -0
  19. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/.github/workflows/publish.yml +0 -0
  20. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/.gitignore +0 -0
  21. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/CONTRIBUTING.md +0 -0
  22. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/LICENSE +0 -0
  23. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/LICENSE-MIT +0 -0
  24. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/SECURITY.md +0 -0
  25. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-modern/pyproject.toml +0 -0
  26. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-modern/src/app.py +0 -0
  27. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-modern/src/config.py +0 -0
  28. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-modern/src/crawler.py +0 -0
  29. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-modern/src/models.py +0 -0
  30. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-modern/src/scanner.py +0 -0
  31. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-modern/src/utils.py +0 -0
  32. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-outdated/pyproject.toml +0 -0
  33. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-outdated/setup.py +0 -0
  34. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-outdated/src/app.py +0 -0
  35. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-outdated/src/config.py +0 -0
  36. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-outdated/src/crawler.py +0 -0
  37. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-outdated/src/models.py +0 -0
  38. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-outdated/src/scanner.py +0 -0
  39. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-a-outdated/src/utils.py +0 -0
  40. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-b-modern/myapp/models.py +0 -0
  41. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-b-modern/myapp/views.py +0 -0
  42. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-b-outdated/myapp/models.py +0 -0
  43. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-b-outdated/myapp/views.py +0 -0
  44. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-c-modern/tests/test_calculator.py +0 -0
  45. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/fixtures/variant-c-outdated/tests/test_calculator.py +0 -0
  46. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/mcp-config.json +0 -0
  47. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/prompt-v2.txt +0 -0
  48. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/prompt-v3-mcp.txt +0 -0
  49. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/prompt-v3.txt +0 -0
  50. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/prompt-v4-a.txt +0 -0
  51. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/prompt-v4-b.txt +0 -0
  52. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/prompt-v4-c.txt +0 -0
  53. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/prompt.txt +0 -0
  54. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/run-mcp.sh +0 -0
  55. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/run-v4.sh +0 -0
  56. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/run.sh +0 -0
  57. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/score-v2.sh +0 -0
  58. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/score-v3.sh +0 -0
  59. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/score-v4.sh +0 -0
  60. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/score.sh +0 -0
  61. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/bench/test-scorer.sh +0 -0
  62. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/docs/benchmark-evaluation.md +0 -0
  63. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/docs/benchmark-procedure.md +0 -0
  64. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/docs/design.md +0 -0
  65. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/SKILL.md +0 -0
  66. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
  67. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
  68. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
  69. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/data-structures/dict-merge-operator.md +0 -0
  70. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/data-structures/match-case-patterns.md +0 -0
  71. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/django/django-async-views.md +0 -0
  72. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/django/django-check-constraints.md +0 -0
  73. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/django/django-json-field.md +0 -0
  74. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
  75. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/httpx/httpx-async-client-reuse.md +0 -0
  76. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
  77. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
  78. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-model-api.md +0 -0
  79. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
  80. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
  81. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/pytest/pytest-parametrize.md +0 -0
  82. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/pytest/pytest-raises-match.md +0 -0
  83. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/pytest/pytest-tmp-path.md +0 -0
  84. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-2-style.md +0 -0
  85. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-async-session.md +0 -0
  86. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-mapped-column.md +0 -0
  87. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
  88. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/stdlib/pathlib-over-os-path.md +0 -0
  89. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
  90. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
  91. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
  92. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/toolchain/pyproject-toml-over-setup.md +0 -0
  93. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
  94. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
  95. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
  96. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
  97. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
  98. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
  99. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
  100. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
  101. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
  102. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/src/modern_python_guidance/__main__.py +0 -0
  103. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/src/modern_python_guidance/compat.py +0 -0
  104. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/src/modern_python_guidance/frontmatter.py +0 -0
  105. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/src/modern_python_guidance/retrieve.py +0 -0
  106. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/src/modern_python_guidance/version_detect.py +0 -0
  107. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/tests/test_frontmatter.py +0 -0
  108. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/tests/test_retrieve.py +0 -0
  109. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/tests/test_skill_sync.py +0 -0
  110. {modern_python_guidance-0.2.1 → modern_python_guidance-0.2.3}/tests/test_version_detect.py +0 -0
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.3] — 2026-05-28
6
+
7
+ ### Fixed
8
+
9
+ - `fastapi-typed-state` guide: added missing Version Notes section (closes #13)
10
+ - `fastapi-typed-state` and `fastapi-lifespan` guides: corrected minimum version from FastAPI >= 0.93.0 to >= 0.94.0 (lifespan state dict requires Starlette >= 0.26.0, which FastAPI 0.93.0 excludes)
11
+
12
+ ## [0.2.2] — 2026-05-28
13
+
14
+ ### Changed
15
+
16
+ - Search response (MCP + CLI) now includes `tags`, `python`, `frequency`, and `snippet` fields for richer agent decision-making without requiring a follow-up retrieve call
17
+ - `dataclass-modern` guide rewritten: BAD/GOOD examples now center on immutable value objects (`frozen=True, slots=True, kw_only=True`), with decision criteria for when to use each flag; frequency upgraded to `high`
18
+ - README benchmark highlight now specifies "via Agent Skills" to accurately reflect the delivery method used in the A/B evaluation
19
+
20
+ ### Added
21
+
22
+ - Snippet extraction: every guide produces a one-liner BAD → GOOD transformation preview (e.g. `@dataclass → @dataclass(frozen=True, slots=True, kw_only=True)`)
23
+ - 6 new tests: snippet non-empty invariant, exact fixture assertions, MCP/CLI enriched key validation
24
+
5
25
  ## [0.2.1] — 2026-05-27
6
26
 
7
27
  ### Changed
@@ -68,6 +88,9 @@ Initial release.
68
88
  - Strict YAML-subset frontmatter parser (no PyYAML dependency)
69
89
  - GitHub Actions CI (pytest + ruff on Python 3.11, 3.12, 3.13)
70
90
 
91
+ [0.2.3]: https://github.com/yottayoshida/modern-python-guidance/releases/tag/v0.2.3
92
+ [0.2.2]: https://github.com/yottayoshida/modern-python-guidance/releases/tag/v0.2.2
93
+ [0.2.1]: https://github.com/yottayoshida/modern-python-guidance/releases/tag/v0.2.1
71
94
  [0.2.0]: https://github.com/yottayoshida/modern-python-guidance/releases/tag/v0.2.0
72
95
  [0.1.2]: https://github.com/yottayoshida/modern-python-guidance/releases/tag/v0.1.2
73
96
  [0.1.1]: https://github.com/yottayoshida/modern-python-guidance/releases/tag/v0.1.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modern-python-guidance
3
- Version: 0.2.1
3
+ Version: 0.2.3
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
@@ -40,7 +40,7 @@ Stop your AI from writing `typing.List`, `@validator`, and `setup.py`. 39 versio
40
40
 
41
41
  ## Highlights
42
42
 
43
- - **Measurable impact**: +14.7pp overall improvement in A/B benchmark with 38 scored items ([details](docs/benchmark-evaluation.md)). Largest variant (FastAPI, 32 items): Control 60.4% → Treatment 82.3%
43
+ - **Measurable impact**: +14.7pp overall improvement in A/B benchmark via Agent Skills (38 scored items, [details](docs/benchmark-evaluation.md)). Largest variant (FastAPI, 32 items): Control 60.4% → Treatment 82.3%
44
44
  - **39 guides** across stdlib, Pydantic, FastAPI, Django, SQLAlchemy, pytest, and toolchain
45
45
  - **Version-aware**: auto-detects your project's Python version and filters guides accordingly
46
46
  - **3 delivery methods**: MCP server, CLI, Agent Skills plugin
@@ -9,7 +9,7 @@ Stop your AI from writing `typing.List`, `@validator`, and `setup.py`. 39 versio
9
9
 
10
10
  ## Highlights
11
11
 
12
- - **Measurable impact**: +14.7pp overall improvement in A/B benchmark with 38 scored items ([details](docs/benchmark-evaluation.md)). Largest variant (FastAPI, 32 items): Control 60.4% → Treatment 82.3%
12
+ - **Measurable impact**: +14.7pp overall improvement in A/B benchmark via Agent Skills (38 scored items, [details](docs/benchmark-evaluation.md)). Largest variant (FastAPI, 32 items): Control 60.4% → Treatment 82.3%
13
13
  - **39 guides** across stdlib, Pydantic, FastAPI, Django, SQLAlchemy, pytest, and toolchain
14
14
  - **Version-aware**: auto-detects your project's Python version and filters guides accordingly
15
15
  - **3 delivery methods**: MCP server, CLI, Agent Skills plugin
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "modern-python-guidance"
7
- version = "0.2.1"
7
+ version = "0.2.3"
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"
@@ -0,0 +1,85 @@
1
+ ---
2
+ id: dataclass-modern
3
+ title: Use Modern Dataclass Features (frozen, slots, kw_only)
4
+ category: data-structures
5
+ layer: 1
6
+ tags:
7
+ - dataclass
8
+ - slots
9
+ - kw_only
10
+ - frozen
11
+ - immutable
12
+ aliases:
13
+ - dataclass
14
+ - dataclasses
15
+ python: ">=3.10"
16
+ frequency: high
17
+ ---
18
+
19
+ # Use Modern Dataclass Features
20
+
21
+ Since Python 3.10, dataclasses support `frozen=True`, `slots=True`, and `kw_only=True` for immutable value objects with better performance.
22
+
23
+ ## BAD
24
+
25
+ ```python
26
+ from dataclasses import dataclass
27
+
28
+ @dataclass
29
+ class AppConfig:
30
+ db_host: str
31
+ db_port: int
32
+ debug: bool = False
33
+
34
+ config = AppConfig("localhost", 5432)
35
+ config.db_host = "evil.example.com" # mutable — accidental or malicious mutation
36
+ config.typo_field = True # silently creates new attribute
37
+ ```
38
+
39
+ ## GOOD
40
+
41
+ ```python
42
+ from dataclasses import dataclass
43
+
44
+ @dataclass(frozen=True, slots=True, kw_only=True)
45
+ class AppConfig:
46
+ db_host: str
47
+ db_port: int
48
+ debug: bool = False
49
+
50
+ config = AppConfig(db_host="localhost", db_port=5432)
51
+ config.db_host = "evil" # FrozenInstanceError
52
+ config.typo_field = True # AttributeError
53
+ ```
54
+
55
+ ## Why
56
+
57
+ ### When to use each flag
58
+
59
+ | Flag | Use when | Effect |
60
+ |------|----------|--------|
61
+ | `frozen=True` | Value objects, configs, DTOs, dict keys | Immutable + hashable |
62
+ | `slots=True` | Always (unless you need `__dict__`) | 20-35% less memory, faster access, blocks typo attrs |
63
+ | `kw_only=True` | 3+ fields, or fields of same type | Forces named args, prevents ordering bugs |
64
+
65
+ ### When NOT to use
66
+
67
+ - **`frozen`**: Skip when you need mutable builder pattern or in-place updates in tight loops
68
+ - **`slots`**: Skip when you need `__dict__` introspection, multiple inheritance with conflicting slots, or dynamic attribute assignment
69
+ - **`kw_only`**: Skip for 1-2 field classes where positional is unambiguous (e.g., `Point(x, y)`)
70
+
71
+ ### Decision checklist
72
+
73
+ 1. Is this a value object, config, or DTO? → Add `frozen=True`
74
+ 2. Do you need `__dict__` or multiple inheritance? → If no, add `slots=True`
75
+ 3. Are there 3+ fields or fields of the same type? → Add `kw_only=True`
76
+
77
+ ## Version Notes
78
+
79
+ - 3.10+: `slots=True`, `kw_only=True`
80
+ - 3.10+: Per-field `kw_only` via `field(kw_only=True)`
81
+ - 3.7-3.9: Basic `@dataclass` and `frozen=True` only (no slots/kw_only)
82
+
83
+ ## References
84
+
85
+ - [dataclasses documentation](https://docs.python.org/3/library/dataclasses.html)
@@ -68,7 +68,7 @@ async def root(request: Request):
68
68
 
69
69
  ## Version Notes
70
70
 
71
- - Works on Python 3.9+ with FastAPI >= 0.93.0
71
+ - Works on Python 3.9+ with FastAPI >= 0.94.0 (lifespan state dict requires Starlette >= 0.26.0)
72
72
  - `AsyncIterator` moved from `typing` to `collections.abc` in 3.9
73
73
 
74
74
  ## References
@@ -70,6 +70,11 @@ async def root(request: Request):
70
70
  - `dataclass` or `TypedDict` documents the expected shape
71
71
  - Resource cleanup is guaranteed by the context manager
72
72
 
73
+ ## Version Notes
74
+
75
+ - Lifespan state dict requires FastAPI >= 0.94.0 (Starlette >= 0.26.0)
76
+ - `@dataclass(slots=True)` requires Python 3.10+; use plain `@dataclass` on 3.9
77
+
73
78
  ## References
74
79
 
75
80
  - [FastAPI Lifespan State](https://fastapi.tiangolo.com/advanced/events/#lifespan-state)
@@ -1,3 +1,3 @@
1
1
  """Modern Python Guidance — version-aware BAD/GOOD pattern guides for AI coding agents."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.3"
@@ -122,9 +122,13 @@ def _cmd_search(args: argparse.Namespace) -> None:
122
122
  "title": r.meta.title,
123
123
  "category": r.meta.category,
124
124
  "layer": r.meta.layer,
125
+ "tags": r.meta.tags,
126
+ "python": r.meta.python,
127
+ "frequency": r.meta.frequency,
125
128
  "score": r.score,
126
129
  "token_estimate": r.token_estimate,
127
130
  "fuzzy": r.fuzzy,
131
+ "snippet": r.snippet,
128
132
  }
129
133
  for r in results
130
134
  ]
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import importlib.resources
6
6
  import logging
7
7
  from dataclasses import dataclass, field
8
+ from itertools import zip_longest
8
9
  from pathlib import Path
9
10
 
10
11
  from modern_python_guidance.frontmatter import FrontmatterError, GuideMeta, parse_frontmatter
@@ -17,6 +18,7 @@ class Guide:
17
18
  meta: GuideMeta
18
19
  body: str
19
20
  source_path: str
21
+ snippet: str = ""
20
22
 
21
23
 
22
24
  @dataclass
@@ -68,6 +70,7 @@ def build_index(guides_dir: Path | None = None) -> GuideIndex:
68
70
  meta=meta,
69
71
  body=body,
70
72
  source_path=str(md_file),
73
+ snippet=_extract_snippet(body),
71
74
  )
72
75
  except FrontmatterError as e:
73
76
  log.warning("Skipping %s: %s", md_file, e)
@@ -78,6 +81,42 @@ def build_index(guides_dir: Path | None = None) -> GuideIndex:
78
81
  return index
79
82
 
80
83
 
84
+ def _extract_snippet(body: str) -> str:
85
+ """Extract a BAD → GOOD one-liner from guide body.
86
+
87
+ Finds the first pair of lines from BAD and GOOD code blocks that differ,
88
+ which best conveys the transformation the guide teaches.
89
+ """
90
+ bad_lines = _code_lines(body, "## BAD")
91
+ good_lines = _code_lines(body, "## GOOD")
92
+ if not bad_lines or not good_lines:
93
+ return ""
94
+ for b, g in zip_longest(bad_lines, good_lines, fillvalue=""):
95
+ if b != g:
96
+ return f"{b} → {g}" if b and g else (b or g)
97
+ return f"{bad_lines[0]} → {good_lines[0]}"
98
+
99
+
100
+ def _code_lines(body: str, heading: str) -> list[str]:
101
+ """Return all non-empty code lines from the first fence under a heading."""
102
+ parts = body.split(heading + "\n")
103
+ if len(parts) < 2:
104
+ return []
105
+ section = parts[1].split("\n## ")[0]
106
+ in_fence = False
107
+ lines: list[str] = []
108
+ for line in section.splitlines():
109
+ stripped = line.strip()
110
+ if stripped.startswith("```"):
111
+ if not in_fence:
112
+ in_fence = True
113
+ continue
114
+ break
115
+ if in_fence and stripped:
116
+ lines.append(stripped)
117
+ return lines
118
+
119
+
81
120
  def _find_guides_dir() -> Path:
82
121
  try:
83
122
  skills_pkg = importlib.resources.files("modern_python_guidance") / "skills"
@@ -252,9 +252,13 @@ def _tool_search(arguments: dict) -> dict:
252
252
  "title": r.meta.title,
253
253
  "category": r.meta.category,
254
254
  "layer": r.meta.layer,
255
+ "tags": r.meta.tags,
256
+ "python": r.meta.python,
257
+ "frequency": r.meta.frequency,
255
258
  "score": r.score,
256
259
  "token_estimate": r.token_estimate,
257
260
  "fuzzy": r.fuzzy,
261
+ "snippet": r.snippet,
258
262
  }
259
263
  for r in results
260
264
  ]
@@ -28,6 +28,7 @@ class SearchResult:
28
28
  meta: GuideMeta
29
29
  token_estimate: int
30
30
  fuzzy: bool = False
31
+ snippet: str = ""
31
32
 
32
33
 
33
34
  def search(
@@ -64,6 +65,7 @@ def search(
64
65
  score=score,
65
66
  meta=meta,
66
67
  token_estimate=token_estimate(guide.body),
68
+ snippet=guide.snippet,
67
69
  ))
68
70
 
69
71
  results.sort(key=lambda r: (-r.score, r.guide_id))
@@ -143,6 +145,7 @@ def _fuzzy_fallback(
143
145
  meta=guide.meta,
144
146
  token_estimate=token_estimate(guide.body),
145
147
  fuzzy=True,
148
+ snippet=guide.snippet,
146
149
  ))
147
150
 
148
151
  results.sort(key=lambda r: (-r.score, r.guide_id))
@@ -50,6 +50,18 @@ class TestSearch:
50
50
  data = json.loads(r.stdout)
51
51
  assert all(d["category"] == "fastapi" for d in data)
52
52
 
53
+ def test_search_enriched_keys(self):
54
+ r = run_cli("search", "pydantic validator", "--format", "json")
55
+ assert r.returncode == 0
56
+ data = json.loads(r.stdout)
57
+ expected_keys = {
58
+ "id", "title", "category", "layer", "tags", "python",
59
+ "frequency", "score", "token_estimate", "fuzzy", "snippet",
60
+ }
61
+ assert set(data[0].keys()) == expected_keys
62
+ assert isinstance(data[0]["tags"], list)
63
+ assert "→" in data[0]["snippet"]
64
+
53
65
 
54
66
  class TestRetrieve:
55
67
  def test_retrieve_single(self):
@@ -99,6 +99,26 @@ class TestSearchGuides:
99
99
  assert isinstance(data, list)
100
100
  assert len(data) >= 1
101
101
 
102
+ def test_search_enriched_keys(self):
103
+ responses = _run_mcp(
104
+ *_init_handshake(),
105
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
106
+ "name": "search_guides",
107
+ "arguments": {"query": "pydantic validator"},
108
+ }},
109
+ )
110
+ data = json.loads(responses[1]["result"]["content"][0]["text"])
111
+ expected_keys = {
112
+ "id", "title", "category", "layer", "tags", "python",
113
+ "frequency", "score", "token_estimate", "fuzzy", "snippet",
114
+ }
115
+ assert set(data[0].keys()) == expected_keys
116
+ assert isinstance(data[0]["tags"], list)
117
+ assert isinstance(data[0]["python"], str)
118
+ assert isinstance(data[0]["frequency"], str)
119
+ assert isinstance(data[0]["snippet"], str)
120
+ assert "→" in data[0]["snippet"]
121
+
102
122
  def test_search_empty_query(self):
103
123
  responses = _run_mcp(
104
124
  *_init_handshake(),
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from modern_python_guidance.guide_index import _extract_snippet, build_index
8
+ from modern_python_guidance.search import search
9
+
10
+ GUIDES_DIR = Path(__file__).parent.parent / "skills" / "modern-python-guidance" / "guides"
11
+
12
+
13
+ @pytest.fixture
14
+ def index():
15
+ return build_index(GUIDES_DIR)
16
+
17
+
18
+ class TestBasicSearch:
19
+ def test_search_by_tag(self, index):
20
+ results = search(index, "typing")
21
+ assert len(results) >= 1
22
+ assert results[0].guide_id == "use-builtin-generics"
23
+
24
+ def test_search_by_alias(self, index):
25
+ results = search(index, "typing.List")
26
+ assert len(results) >= 1
27
+ assert results[0].guide_id == "use-builtin-generics"
28
+
29
+ def test_search_by_title_word(self, index):
30
+ results = search(index, "lifespan")
31
+ assert len(results) >= 1
32
+ assert results[0].guide_id == "fastapi-lifespan"
33
+
34
+ def test_search_by_category(self, index):
35
+ results = search(index, "asyncio", category="async")
36
+ assert all(r.meta.category == "async" for r in results)
37
+
38
+ def test_search_returns_token_estimate(self, index):
39
+ results = search(index, "typing")
40
+ assert all(r.token_estimate > 0 for r in results)
41
+
42
+
43
+ class TestVersionFilter:
44
+ def test_version_excludes_incompatible(self, index):
45
+ results = search(index, "asyncio taskgroup", python_version="3.9")
46
+ ids = [r.guide_id for r in results]
47
+ assert "taskgroup-over-gather" not in ids
48
+
49
+ def test_version_includes_compatible(self, index):
50
+ results = search(index, "asyncio taskgroup", python_version="3.11")
51
+ ids = [r.guide_id for r in results]
52
+ assert "taskgroup-over-gather" in ids
53
+
54
+
55
+ class TestFuzzyFallback:
56
+ def test_fuzzy_on_no_match(self, index):
57
+ results = search(index, "genercs")
58
+ assert len(results) > 0
59
+ assert results[0].fuzzy is True
60
+
61
+ def test_truly_irrelevant_query(self, index):
62
+ results = search(index, "javascript react angular")
63
+ assert all(r.fuzzy for r in results) or len(results) == 0
64
+
65
+
66
+ class TestDeterminism:
67
+ def test_same_score_sorted_by_id(self, index):
68
+ results = search(index, "pydantic")
69
+ assert len(results) >= 2
70
+ assert not any(r.fuzzy for r in results)
71
+ same_score_groups: dict[float, list[str]] = {}
72
+ for r in results:
73
+ same_score_groups.setdefault(r.score, []).append(r.guide_id)
74
+ for group in same_score_groups.values():
75
+ assert group == sorted(group)
76
+
77
+
78
+ class TestSnippet:
79
+ def test_all_guides_have_snippet(self, index):
80
+ results = search(index, "python", limit=50)
81
+ for r in results:
82
+ guide = index.get(r.guide_id)
83
+ assert guide is not None
84
+ assert guide.snippet, f"{r.guide_id} has empty snippet"
85
+
86
+ def test_all_guides_non_empty_snippet(self):
87
+ idx = build_index(GUIDES_DIR)
88
+ for guide_id, guide in idx.guides.items():
89
+ assert guide.snippet, f"{guide_id} has empty snippet"
90
+
91
+ def test_snippet_in_search_result(self, index):
92
+ results = search(index, "pydantic validator")
93
+ assert len(results) >= 1
94
+ r = results[0]
95
+ assert r.snippet
96
+ assert "→" in r.snippet
97
+
98
+ def test_snippet_exact_fixtures(self, index):
99
+ fixtures = {
100
+ "pydantic-v2-validators": (
101
+ "from pydantic import BaseModel, validator, root_validator"
102
+ " → "
103
+ "from pydantic import BaseModel, field_validator, model_validator"
104
+ ),
105
+ "dataclass-modern": "@dataclass → @dataclass(frozen=True, slots=True, kw_only=True)",
106
+ "use-builtin-generics": (
107
+ "from typing import Dict, List, Optional, Set, Tuple"
108
+ " → "
109
+ "def process(items: list[str]) -> dict[str, int]:"
110
+ ),
111
+ "taskgroup-over-gather": (
112
+ "results = await asyncio.gather("
113
+ " → "
114
+ "async with asyncio.TaskGroup() as tg:"
115
+ ),
116
+ }
117
+ for guide_id, expected in fixtures.items():
118
+ guide = index.get(guide_id)
119
+ assert guide is not None, f"{guide_id} not found"
120
+ assert guide.snippet == expected, (
121
+ f"{guide_id}: expected {expected!r}, got {guide.snippet!r}"
122
+ )
123
+
124
+
125
+ class TestSnippetExtraction:
126
+ def test_unequal_bad_longer(self):
127
+ body = (
128
+ "## BAD\n```python\nline_a\nline_b\nline_c\n```\n"
129
+ "## GOOD\n```python\nline_x\n```\n"
130
+ )
131
+ snippet = _extract_snippet(body)
132
+ assert snippet == "line_a → line_x"
133
+
134
+ def test_unequal_good_longer(self):
135
+ body = (
136
+ "## BAD\n```python\nline_a\n```\n"
137
+ "## GOOD\n```python\nline_x\nline_y\nline_z\n```\n"
138
+ )
139
+ snippet = _extract_snippet(body)
140
+ assert snippet == "line_a → line_x"
141
+
142
+ def test_late_differing_line(self):
143
+ body = (
144
+ "## BAD\n```python\nimport foo\nresult = old_call()\n```\n"
145
+ "## GOOD\n```python\nimport foo\nresult = new_call()\n```\n"
146
+ )
147
+ snippet = _extract_snippet(body)
148
+ assert snippet == "result = old_call() → result = new_call()"
149
+
150
+ def test_diff_beyond_eight_lines(self):
151
+ shared = "\n".join(f"line_{i}" for i in range(9))
152
+ body = (
153
+ f"## BAD\n```python\n{shared}\nold_call()\n```\n"
154
+ f"## GOOD\n```python\n{shared}\nnew_call()\n```\n"
155
+ )
156
+ snippet = _extract_snippet(body)
157
+ assert snippet == "old_call() → new_call()"
158
+
159
+ def test_trailing_only_in_bad(self):
160
+ body = (
161
+ "## BAD\n```python\nshared\nextra_bad\n```\n"
162
+ "## GOOD\n```python\nshared\n```\n"
163
+ )
164
+ snippet = _extract_snippet(body)
165
+ assert snippet == "extra_bad"
166
+
167
+ def test_trailing_only_in_good(self):
168
+ body = (
169
+ "## BAD\n```python\nshared\n```\n"
170
+ "## GOOD\n```python\nshared\nextra_good\n```\n"
171
+ )
172
+ snippet = _extract_snippet(body)
173
+ assert snippet == "extra_good"
174
+
175
+ def test_all_lines_identical(self):
176
+ body = (
177
+ "## BAD\n```python\nsame\n```\n"
178
+ "## GOOD\n```python\nsame\n```\n"
179
+ )
180
+ snippet = _extract_snippet(body)
181
+ assert snippet == "same → same"
182
+
183
+ def test_heading_boundary_not_prefix_match(self):
184
+ body = (
185
+ "## BADLY_NAMED\n```python\nwrong\n```\n"
186
+ "## BAD\n```python\ncorrect_bad\n```\n"
187
+ "## GOOD\n```python\ncorrect_good\n```\n"
188
+ )
189
+ snippet = _extract_snippet(body)
190
+ assert snippet == "correct_bad → correct_good"
191
+
192
+ def test_first_fence_only(self):
193
+ body = (
194
+ "## BAD\n```python\nfirst_bad\n```\n"
195
+ "```python\nsecond_bad\n```\n"
196
+ "## GOOD\n```python\nfirst_good\n```\n"
197
+ )
198
+ snippet = _extract_snippet(body)
199
+ assert snippet == "first_bad → first_good"
200
+
201
+ def test_no_bad_section(self):
202
+ body = "## GOOD\n```python\ncode\n```\n"
203
+ snippet = _extract_snippet(body)
204
+ assert snippet == ""
205
+
206
+ def test_no_good_section(self):
207
+ body = "## BAD\n```python\ncode\n```\n"
208
+ snippet = _extract_snippet(body)
209
+ assert snippet == ""
210
+
211
+
212
+ class TestEdgeCases:
213
+ def test_empty_query(self, index):
214
+ results = search(index, "")
215
+ assert results == []
216
+
217
+ def test_long_query_truncated(self, index):
218
+ results = search(index, "x " * 1000)
219
+ assert isinstance(results, list)
220
+
221
+ def test_limit(self, index):
222
+ results = search(index, "python", limit=2)
223
+ assert len(results) <= 2
@@ -1,73 +0,0 @@
1
- ---
2
- id: dataclass-modern
3
- title: Use Modern Dataclass Features (slots, kw_only)
4
- category: data-structures
5
- layer: 1
6
- tags:
7
- - dataclass
8
- - slots
9
- - kw_only
10
- aliases:
11
- - dataclass
12
- - dataclasses
13
- python: ">=3.10"
14
- frequency: medium
15
- ---
16
-
17
- # Use Modern Dataclass Features
18
-
19
- Since Python 3.10, dataclasses support `slots=True` and `kw_only=True` for better performance and safer APIs.
20
-
21
- ## BAD
22
-
23
- ```python
24
- from dataclasses import dataclass
25
-
26
- @dataclass
27
- class Point:
28
- x: float
29
- y: float
30
- z: float = 0.0
31
-
32
- # No slots: allows typos on attributes
33
- p = Point(1.0, 2.0)
34
- p.w = 3.0 # silently creates new attribute (typo for 'z')
35
-
36
- # Positional args: easy to mix up x and y
37
- p = Point(2.0, 1.0) # is this (x=2, y=1) or (x=1, y=2)?
38
- ```
39
-
40
- ## GOOD
41
-
42
- ```python
43
- from dataclasses import dataclass
44
-
45
- @dataclass(slots=True, kw_only=True)
46
- class Point:
47
- x: float
48
- y: float
49
- z: float = 0.0
50
-
51
- p = Point(x=1.0, y=2.0)
52
- p.w = 3.0 # AttributeError: 'Point' has no attribute 'w'
53
-
54
- # kw_only forces explicit names — no positional confusion
55
- p = Point(x=2.0, y=1.0) # intent is clear
56
- ```
57
-
58
- ## Why
59
-
60
- - `slots=True`: 20-35% less memory, faster attribute access, prevents typo attributes
61
- - `kw_only=True`: forces named arguments, eliminates positional ordering bugs
62
- - `frozen=True` + `slots=True`: fast immutable value objects
63
- - Combine for production data classes: `@dataclass(slots=True, frozen=True, kw_only=True)`
64
-
65
- ## Version Notes
66
-
67
- - 3.10+: `slots=True`, `kw_only=True`
68
- - 3.10+: Per-field `kw_only` via `field(kw_only=True)`
69
- - 3.7-3.9: Basic `@dataclass` without slots/kw_only
70
-
71
- ## References
72
-
73
- - [dataclasses documentation](https://docs.python.org/3/library/dataclasses.html)
@@ -1,89 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
-
5
- import pytest
6
-
7
- from modern_python_guidance.guide_index import build_index
8
- from modern_python_guidance.search import search
9
-
10
- GUIDES_DIR = Path(__file__).parent.parent / "skills" / "modern-python-guidance" / "guides"
11
-
12
-
13
- @pytest.fixture
14
- def index():
15
- return build_index(GUIDES_DIR)
16
-
17
-
18
- class TestBasicSearch:
19
- def test_search_by_tag(self, index):
20
- results = search(index, "typing")
21
- assert len(results) >= 1
22
- assert results[0].guide_id == "use-builtin-generics"
23
-
24
- def test_search_by_alias(self, index):
25
- results = search(index, "typing.List")
26
- assert len(results) >= 1
27
- assert results[0].guide_id == "use-builtin-generics"
28
-
29
- def test_search_by_title_word(self, index):
30
- results = search(index, "lifespan")
31
- assert len(results) >= 1
32
- assert results[0].guide_id == "fastapi-lifespan"
33
-
34
- def test_search_by_category(self, index):
35
- results = search(index, "asyncio", category="async")
36
- assert all(r.meta.category == "async" for r in results)
37
-
38
- def test_search_returns_token_estimate(self, index):
39
- results = search(index, "typing")
40
- assert all(r.token_estimate > 0 for r in results)
41
-
42
-
43
- class TestVersionFilter:
44
- def test_version_excludes_incompatible(self, index):
45
- results = search(index, "asyncio taskgroup", python_version="3.9")
46
- ids = [r.guide_id for r in results]
47
- assert "taskgroup-over-gather" not in ids
48
-
49
- def test_version_includes_compatible(self, index):
50
- results = search(index, "asyncio taskgroup", python_version="3.11")
51
- ids = [r.guide_id for r in results]
52
- assert "taskgroup-over-gather" in ids
53
-
54
-
55
- class TestFuzzyFallback:
56
- def test_fuzzy_on_no_match(self, index):
57
- results = search(index, "genercs")
58
- assert len(results) > 0
59
- assert results[0].fuzzy is True
60
-
61
- def test_truly_irrelevant_query(self, index):
62
- results = search(index, "javascript react angular")
63
- assert all(r.fuzzy for r in results) or len(results) == 0
64
-
65
-
66
- class TestDeterminism:
67
- def test_same_score_sorted_by_id(self, index):
68
- results = search(index, "pydantic")
69
- assert len(results) >= 2
70
- assert not any(r.fuzzy for r in results)
71
- same_score_groups: dict[float, list[str]] = {}
72
- for r in results:
73
- same_score_groups.setdefault(r.score, []).append(r.guide_id)
74
- for group in same_score_groups.values():
75
- assert group == sorted(group)
76
-
77
-
78
- class TestEdgeCases:
79
- def test_empty_query(self, index):
80
- results = search(index, "")
81
- assert results == []
82
-
83
- def test_long_query_truncated(self, index):
84
- results = search(index, "x " * 1000)
85
- assert isinstance(results, list)
86
-
87
- def test_limit(self, index):
88
- results = search(index, "python", limit=2)
89
- assert len(results) <= 2