modern-python-guidance 0.3.7__tar.gz → 0.3.8__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 (136) hide show
  1. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/CHANGELOG.md +10 -0
  2. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/PKG-INFO +1 -1
  3. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/pyproject.toml +1 -1
  4. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/__init__.py +1 -1
  5. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/cli.py +26 -10
  6. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/mcp_server.py +11 -1
  7. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/retrieve.py +13 -0
  8. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_cli_unit.py +44 -2
  9. modern_python_guidance-0.3.8/tests/test_compat.py +82 -0
  10. modern_python_guidance-0.3.8/tests/test_guide_index.py +280 -0
  11. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_mcp_server.py +3 -1
  12. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_mcp_unit.py +45 -1
  13. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_retrieve.py +34 -1
  14. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/.github/workflows/check-python-release.yml +0 -0
  15. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/.github/workflows/ci.yml +0 -0
  16. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/.github/workflows/publish.yml +0 -0
  17. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/.gitignore +0 -0
  18. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/CONTRIBUTING.md +0 -0
  19. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/LICENSE +0 -0
  20. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/LICENSE-MIT +0 -0
  21. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/README.md +0 -0
  22. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/SECURITY.md +0 -0
  23. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/edge-cases/opus48_multiline.py +0 -0
  24. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/edge-cases/valid_alt_patterns.py +0 -0
  25. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/pyproject.toml +0 -0
  26. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/app.py +0 -0
  27. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/config.py +0 -0
  28. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/crawler.py +0 -0
  29. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/models.py +0 -0
  30. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/scanner.py +0 -0
  31. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/utils.py +0 -0
  32. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/pyproject.toml +0 -0
  33. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/setup.py +0 -0
  34. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/app.py +0 -0
  35. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/config.py +0 -0
  36. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/crawler.py +0 -0
  37. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/models.py +0 -0
  38. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/scanner.py +0 -0
  39. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/utils.py +0 -0
  40. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-b-modern/myapp/models.py +0 -0
  41. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-b-modern/myapp/views.py +0 -0
  42. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-b-outdated/myapp/models.py +0 -0
  43. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-b-outdated/myapp/views.py +0 -0
  44. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-c-modern/tests/test_calculator.py +0 -0
  45. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/fixtures/variant-c-outdated/tests/test_calculator.py +0 -0
  46. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/mcp-config.json +0 -0
  47. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompt-v2.txt +0 -0
  48. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompt-v3-mcp.txt +0 -0
  49. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompt-v3.txt +0 -0
  50. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompt-v4-a.txt +0 -0
  51. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompt-v4-b.txt +0 -0
  52. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompt-v4-c.txt +0 -0
  53. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompt.txt +0 -0
  54. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompts/v5-a-detailed.txt +0 -0
  55. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompts/v5-a-normal.txt +0 -0
  56. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompts/v5-a-terse.txt +0 -0
  57. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompts/v5-b-detailed.txt +0 -0
  58. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompts/v5-b-normal.txt +0 -0
  59. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompts/v5-b-terse.txt +0 -0
  60. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompts/v5-c-detailed.txt +0 -0
  61. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompts/v5-c-normal.txt +0 -0
  62. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/prompts/v5-c-terse.txt +0 -0
  63. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/run-mcp.sh +0 -0
  64. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/run-v4.sh +0 -0
  65. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/run-v5.sh +0 -0
  66. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/run.sh +0 -0
  67. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/score-v2.sh +0 -0
  68. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/score-v3.sh +0 -0
  69. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/score-v4.sh +0 -0
  70. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/score.sh +0 -0
  71. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/score_v5.py +0 -0
  72. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/bench/test-scorer.sh +0 -0
  73. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/docs/benchmark-evaluation.md +0 -0
  74. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/docs/benchmark-procedure.md +0 -0
  75. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/docs/benchmark-v5.md +0 -0
  76. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/docs/design.md +0 -0
  77. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/rules/modern-python.md +0 -0
  78. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/SKILL.md +0 -0
  79. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
  80. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
  81. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
  82. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -0
  83. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/data-structures/dict-merge-operator.md +0 -0
  84. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/data-structures/match-case-patterns.md +0 -0
  85. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/django/django-async-views.md +0 -0
  86. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/django/django-check-constraints.md +0 -0
  87. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/django/django-json-field.md +0 -0
  88. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
  89. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +0 -0
  90. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +0 -0
  91. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/httpx/httpx-async-client-reuse.md +0 -0
  92. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
  93. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
  94. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-model-api.md +0 -0
  95. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
  96. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
  97. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pytest/pytest-parametrize.md +0 -0
  98. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pytest/pytest-raises-match.md +0 -0
  99. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pytest/pytest-tmp-path.md +0 -0
  100. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-2-style.md +0 -0
  101. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-async-session.md +0 -0
  102. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-mapped-column.md +0 -0
  103. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
  104. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/stdlib/pathlib-over-os-path.md +0 -0
  105. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
  106. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/stdlib/template-strings.md +0 -0
  107. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
  108. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
  109. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/toolchain/pyproject-toml-over-setup.md +0 -0
  110. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
  111. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
  112. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
  113. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/deferred-annotations.md +0 -0
  114. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
  115. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
  116. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
  117. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
  118. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
  119. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
  120. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/__main__.py +0 -0
  121. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/compat.py +0 -0
  122. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/frontmatter.py +0 -0
  123. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/guide_index.py +0 -0
  124. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/search.py +0 -0
  125. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/setup_cmd.py +0 -0
  126. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/uninstall_cmd.py +0 -0
  127. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/src/modern_python_guidance/version_detect.py +0 -0
  128. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_cli_integration.py +0 -0
  129. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_frontmatter.py +0 -0
  130. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_guide_structure.py +0 -0
  131. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_scorer_v5.py +0 -0
  132. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_search.py +0 -0
  133. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_setup.py +0 -0
  134. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_skill_sync.py +0 -0
  135. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_uninstall.py +0 -0
  136. {modern_python_guidance-0.3.7 → modern_python_guidance-0.3.8}/tests/test_version_detect.py +0 -0
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.8] — 2026-06-02
6
+
7
+ ### Added
8
+
9
+ - Fuzzy suggestions on retrieve miss: when a guide ID is not found, `difflib.get_close_matches` suggests up to 3 similar IDs (cutoff=0.5, case-insensitive). CLI shows "Did you mean:" in human format; JSON format and MCP tool return an envelope `{"results": [...], "not_found": [{"id": ..., "suggestions": [...]}]}`. Bare list preserved on all-found for backward compatibility. Exit code 1 when any ID is not found. (closes #14)
10
+
11
+ ### Fixed
12
+
13
+ - `_handle_request` crash on non-dict JSON input (list, string, number, bool): now returns JSON-RPC -32600 "Invalid Request" error instead of `AttributeError`. Server continues processing subsequent requests. (closes #82)
14
+
5
15
  ## [0.3.7] — 2026-06-02
6
16
 
7
17
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modern-python-guidance
3
- Version: 0.3.7
3
+ Version: 0.3.8
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "modern-python-guidance"
7
- version = "0.3.7"
7
+ version = "0.3.8"
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.3.7"
3
+ __version__ = "0.3.8"
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from modern_python_guidance import __version__
13
13
  from modern_python_guidance.compat import VERSION_RE, version_compatible
14
14
  from modern_python_guidance.guide_index import build_index
15
- from modern_python_guidance.retrieve import retrieve
15
+ from modern_python_guidance.retrieve import retrieve, suggest_ids
16
16
  from modern_python_guidance.search import search as do_search
17
17
  from modern_python_guidance.version_detect import detect_version
18
18
 
@@ -191,26 +191,42 @@ def _cmd_search(args: argparse.Namespace) -> None:
191
191
 
192
192
  def _cmd_retrieve(args: argparse.Namespace) -> None:
193
193
  index = build_index()
194
- guide_ids = [gid.strip() for gid in args.ids.split(",")]
194
+ guide_ids = [gid.strip() for gid in args.ids.split(",") if gid.strip()]
195
+ if not guide_ids:
196
+ print("No guide IDs provided.")
197
+ sys.exit(1)
195
198
  results = retrieve(index, guide_ids, python_version=args.python_version)
196
199
 
197
- fmt = _resolve_format(args)
200
+ found_ids = {r["id"] for r in results}
201
+ missing = [gid for gid in guide_ids if gid not in found_ids]
198
202
 
199
- if not results:
200
- if fmt == "human":
201
- print("No guides found.")
202
- else:
203
- print("[]")
204
- sys.exit(1)
203
+ fmt = _resolve_format(args)
205
204
 
206
205
  if fmt == "json":
207
- print(json.dumps(results, indent=2, ensure_ascii=False))
206
+ if missing:
207
+ not_found = [{"id": gid, "suggestions": suggest_ids(index, gid)} for gid in missing]
208
+ envelope = {"results": results, "not_found": not_found}
209
+ print(json.dumps(envelope, indent=2, ensure_ascii=False))
210
+ else:
211
+ print(json.dumps(results, indent=2, ensure_ascii=False))
208
212
  else:
209
213
  for r in results:
210
214
  match_str = "YES" if r["version_match"] else "NO"
211
215
  print(f"--- {r['id']} (version match: {match_str}) ---")
212
216
  print(r["content"])
213
217
  print()
218
+ for gid in missing:
219
+ suggestions = suggest_ids(index, gid)
220
+ if suggestions:
221
+ print(f"No guide found for '{gid}'. Did you mean:")
222
+ for s in suggestions:
223
+ print(f" {s}")
224
+ else:
225
+ print(f"No guide found for '{gid}'.")
226
+ print("Run 'mpg list' to see available guides.")
227
+
228
+ if missing:
229
+ sys.exit(1)
214
230
 
215
231
 
216
232
  def _cmd_list(args: argparse.Namespace) -> None:
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  from modern_python_guidance import __version__
11
11
  from modern_python_guidance.compat import VERSION_RE
12
12
  from modern_python_guidance.guide_index import GuideIndex, build_index
13
- from modern_python_guidance.retrieve import retrieve
13
+ from modern_python_guidance.retrieve import retrieve, suggest_ids
14
14
  from modern_python_guidance.search import search
15
15
  from modern_python_guidance.version_detect import detect_version
16
16
 
@@ -280,6 +280,14 @@ def _tool_retrieve(arguments: dict) -> dict:
280
280
 
281
281
  index = _get_index()
282
282
  results = retrieve(index, guide_ids, python_version=pv)
283
+
284
+ found_ids = {r["id"] for r in results}
285
+ missing = [gid for gid in guide_ids if gid not in found_ids]
286
+ if missing:
287
+ not_found = [{"id": gid, "suggestions": suggest_ids(index, gid)} for gid in missing]
288
+ envelope = {"results": results, "not_found": not_found}
289
+ return _tool_result(json.dumps(envelope, indent=2, ensure_ascii=False))
290
+
283
291
  return _tool_result(json.dumps(results, indent=2, ensure_ascii=False))
284
292
 
285
293
 
@@ -332,6 +340,8 @@ PROTOCOL_VERSION = "2024-11-05"
332
340
 
333
341
 
334
342
  def _handle_request(msg: dict) -> dict | None:
343
+ if not isinstance(msg, dict):
344
+ return _error_response(None, -32600, "Invalid Request: expected JSON object")
335
345
  method = msg.get("method", "")
336
346
  req_id = msg.get("id")
337
347
  params = msg.get("params", {})
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import difflib
5
6
  import json
6
7
  from typing import Any
7
8
 
@@ -9,6 +10,18 @@ from modern_python_guidance import __version__
9
10
  from modern_python_guidance.compat import token_estimate, version_compatible
10
11
  from modern_python_guidance.guide_index import Guide, GuideIndex
11
12
 
13
+ MAX_ID_LEN = 200
14
+ _SUGGEST_CUTOFF = 0.5
15
+ _SUGGEST_MAX = 3
16
+
17
+
18
+ def suggest_ids(index: GuideIndex, missing_id: str) -> list[str]:
19
+ if not isinstance(missing_id, str):
20
+ return []
21
+ truncated = missing_id[:MAX_ID_LEN].lower()
22
+ all_ids = list(index.guides.keys())
23
+ return difflib.get_close_matches(truncated, all_ids, n=_SUGGEST_MAX, cutoff=_SUGGEST_CUTOFF)
24
+
12
25
 
13
26
  def retrieve(
14
27
  index: GuideIndex,
@@ -115,14 +115,56 @@ class TestCmdRetrieve:
115
115
  assert "use-builtin-generics" in ids
116
116
  assert "union-syntax" in ids
117
117
 
118
- def test_no_match_exits_1(self, capsys):
118
+ def test_no_match_exits_1_json_envelope(self, capsys):
119
119
  with pytest.raises(SystemExit, match="1"):
120
120
  main(argv=["retrieve", "nonexistent-guide-id", "--format", "json"])
121
+ data = json.loads(capsys.readouterr().out)
122
+ assert "not_found" in data
123
+ assert data["results"] == []
124
+ assert data["not_found"][0]["id"] == "nonexistent-guide-id"
121
125
 
122
126
  def test_no_match_human_exits_1(self, capsys):
123
127
  with pytest.raises(SystemExit, match="1"):
124
128
  main(argv=["retrieve", "nonexistent-guide-id", "--format", "human"])
125
- assert "No guides found" in capsys.readouterr().out
129
+ assert "No guide found for" in capsys.readouterr().out
130
+
131
+ def test_suggestion_human(self, capsys):
132
+ with pytest.raises(SystemExit, match="1"):
133
+ main(argv=["retrieve", "builtin-generics", "--format", "human"])
134
+ out = capsys.readouterr().out
135
+ assert "Did you mean" in out
136
+ assert "use-builtin-generics" in out
137
+
138
+ def test_suggestion_json(self, capsys):
139
+ with pytest.raises(SystemExit, match="1"):
140
+ main(argv=["retrieve", "builtin-generics", "--format", "json"])
141
+ data = json.loads(capsys.readouterr().out)
142
+ assert "use-builtin-generics" in data["not_found"][0]["suggestions"]
143
+
144
+ def test_mixed_valid_and_invalid(self, capsys):
145
+ with pytest.raises(SystemExit, match="1"):
146
+ main(argv=["retrieve", "use-builtin-generics,zzz-fake", "--format", "json"])
147
+ data = json.loads(capsys.readouterr().out)
148
+ assert len(data["results"]) == 1
149
+ assert data["results"][0]["id"] == "use-builtin-generics"
150
+ assert data["not_found"][0]["id"] == "zzz-fake"
151
+
152
+ def test_all_found_bare_list(self, capsys):
153
+ main(argv=["retrieve", "use-builtin-generics", "--format", "json"])
154
+ data = json.loads(capsys.readouterr().out)
155
+ assert isinstance(data, list)
156
+ assert data[0]["id"] == "use-builtin-generics"
157
+
158
+ def test_trailing_comma_ignored(self, capsys):
159
+ main(argv=["retrieve", "use-builtin-generics,", "--format", "json"])
160
+ data = json.loads(capsys.readouterr().out)
161
+ assert isinstance(data, list)
162
+ assert data[0]["id"] == "use-builtin-generics"
163
+
164
+ def test_all_commas_exits_1(self, capsys):
165
+ with pytest.raises(SystemExit, match="1"):
166
+ main(argv=["retrieve", ",,,", "--format", "human"])
167
+ assert "No guide IDs provided" in capsys.readouterr().out
126
168
 
127
169
  def test_version_match_yes(self, capsys):
128
170
  main(
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from modern_python_guidance.compat import VERSION_RE, token_estimate, version_compatible
6
+
7
+
8
+ class TestVersionCompatible:
9
+ def test_compatible(self):
10
+ assert version_compatible(">=3.9", "3.12") is True
11
+
12
+ def test_incompatible(self):
13
+ assert version_compatible(">=3.11", "3.9") is False
14
+
15
+ def test_boundary_exact(self):
16
+ assert version_compatible(">=3.11", "3.11") is True
17
+
18
+ def test_compound_specifier_pass(self):
19
+ assert version_compatible(">=3.9,<3.13", "3.12") is True
20
+
21
+ def test_compound_specifier_fail(self):
22
+ assert version_compatible(">=3.9,<3.13", "3.13") is False
23
+
24
+ def test_empty_specifier_matches_all(self):
25
+ assert version_compatible("", "3.12") is True
26
+
27
+ def test_invalid_specifier_fail_open(self):
28
+ assert version_compatible("not-valid", "3.12") is True
29
+
30
+ def test_invalid_target_fail_open(self):
31
+ assert version_compatible(">=3.9", "abc") is True
32
+
33
+ def test_patch_level_target(self):
34
+ assert version_compatible(">=3.9", "3.11.1") is True
35
+
36
+
37
+ class TestTokenEstimate:
38
+ def test_empty_string(self):
39
+ assert token_estimate("") == 0
40
+
41
+ def test_one_char(self):
42
+ assert token_estimate("x") == 0
43
+
44
+ def test_three_chars(self):
45
+ assert token_estimate("xxx") == 0
46
+
47
+ def test_four_chars(self):
48
+ assert token_estimate("abcd") == 1
49
+
50
+ def test_five_chars(self):
51
+ assert token_estimate("xxxxx") == 1
52
+
53
+ def test_hundred_chars(self):
54
+ assert token_estimate("x" * 100) == 25
55
+
56
+
57
+ class TestVersionRe:
58
+ @pytest.mark.parametrize(
59
+ ("value", "expected"),
60
+ [
61
+ ("3.12", True),
62
+ ("3.12.1", False),
63
+ ("abc", False),
64
+ ("", False),
65
+ ("v3.12", False),
66
+ ("3.", False),
67
+ (".11", False),
68
+ ("03.011", True),
69
+ ],
70
+ ids=[
71
+ "major_minor",
72
+ "patch_no_match",
73
+ "alpha_no_match",
74
+ "empty_no_match",
75
+ "v_prefix_no_match",
76
+ "trailing_dot_no_match",
77
+ "leading_dot_no_match",
78
+ "leading_zeros_match",
79
+ ],
80
+ )
81
+ def test_pattern(self, value: str, expected: bool):
82
+ assert (VERSION_RE.match(value) is not None) is expected
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ from modern_python_guidance.frontmatter import GuideMeta
8
+ from modern_python_guidance.guide_index import (
9
+ Guide,
10
+ GuideIndex,
11
+ _code_lines,
12
+ _find_guides_dir,
13
+ build_index,
14
+ )
15
+
16
+
17
+ def _raise_type_error(_pkg):
18
+ raise TypeError("mocked")
19
+
20
+
21
+ def _make_guide_md(
22
+ *,
23
+ guide_id: str = "test-guide",
24
+ title: str = "Test Guide",
25
+ category: str = "testing",
26
+ layer: int = 1,
27
+ python: str = ">=3.9",
28
+ frequency: str = "high",
29
+ bad_code: str = "old_code()",
30
+ good_code: str = "new_code()",
31
+ ) -> str:
32
+ return (
33
+ f"---\n"
34
+ f"id: {guide_id}\n"
35
+ f"title: {title}\n"
36
+ f"category: {category}\n"
37
+ f"layer: {layer}\n"
38
+ f"tags:\n"
39
+ f" - test\n"
40
+ f'python: "{python}"\n'
41
+ f"frequency: {frequency}\n"
42
+ f"---\n"
43
+ f"\n"
44
+ f"## BAD\n"
45
+ f"```python\n"
46
+ f"{bad_code}\n"
47
+ f"```\n"
48
+ f"\n"
49
+ f"## GOOD\n"
50
+ f"```python\n"
51
+ f"{good_code}\n"
52
+ f"```\n"
53
+ )
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # GuideIndex dataclass methods
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ class TestGuideIndex:
62
+ def test_len_empty(self):
63
+ idx = GuideIndex()
64
+ assert len(idx) == 0
65
+
66
+ def test_len_with_entries(self):
67
+ meta = GuideMeta(
68
+ id="a",
69
+ title="A",
70
+ category="c",
71
+ layer=1,
72
+ tags=["t"],
73
+ python=">=3.9",
74
+ frequency="high",
75
+ )
76
+ idx = GuideIndex(guides={"a": Guide(meta=meta, body="", source_path="a.md")})
77
+ assert len(idx) == 1
78
+
79
+ def test_get_existing(self):
80
+ meta = GuideMeta(
81
+ id="a",
82
+ title="A",
83
+ category="c",
84
+ layer=1,
85
+ tags=["t"],
86
+ python=">=3.9",
87
+ frequency="high",
88
+ )
89
+ guide = Guide(meta=meta, body="body", source_path="a.md")
90
+ idx = GuideIndex(guides={"a": guide})
91
+ assert idx.get("a") is guide
92
+
93
+ def test_get_missing(self):
94
+ idx = GuideIndex()
95
+ assert idx.get("nonexistent") is None
96
+
97
+ def test_all_meta(self):
98
+ meta = GuideMeta(
99
+ id="a",
100
+ title="A",
101
+ category="c",
102
+ layer=1,
103
+ tags=["t"],
104
+ python=">=3.9",
105
+ frequency="high",
106
+ )
107
+ idx = GuideIndex(guides={"a": Guide(meta=meta, body="", source_path="a.md")})
108
+ result = idx.all_meta()
109
+ assert len(result) == 1
110
+ assert result[0] is meta
111
+
112
+ def test_categories_sorted_unique(self):
113
+ def _meta(guide_id: str, cat: str) -> GuideMeta:
114
+ return GuideMeta(
115
+ id=guide_id,
116
+ title=guide_id,
117
+ category=cat,
118
+ layer=1,
119
+ tags=["t"],
120
+ python=">=3.9",
121
+ frequency="high",
122
+ )
123
+
124
+ idx = GuideIndex(
125
+ guides={
126
+ "b": Guide(meta=_meta("b", "zeta"), body="", source_path="b.md"),
127
+ "a": Guide(meta=_meta("a", "alpha"), body="", source_path="a.md"),
128
+ "c": Guide(meta=_meta("c", "alpha"), body="", source_path="c.md"),
129
+ }
130
+ )
131
+ assert idx.categories() == ["alpha", "zeta"]
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # build_index
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ class TestBuildIndex:
140
+ def test_happy_path(self, tmp_path: Path):
141
+ (tmp_path / "guide-a.md").write_text(_make_guide_md(guide_id="guide-a"))
142
+ (tmp_path / "guide-b.md").write_text(_make_guide_md(guide_id="guide-b", category="async"))
143
+ idx = build_index(tmp_path)
144
+ assert len(idx) == 2
145
+ assert idx.get("guide-a") is not None
146
+ assert idx.get("guide-b") is not None
147
+
148
+ def test_nonexistent_directory(self, tmp_path: Path, caplog):
149
+ missing = tmp_path / "does-not-exist"
150
+ with caplog.at_level(logging.WARNING):
151
+ idx = build_index(missing)
152
+ assert len(idx) == 0
153
+ assert "not found" in caplog.text
154
+
155
+ def test_empty_directory(self, tmp_path: Path):
156
+ idx = build_index(tmp_path)
157
+ assert len(idx) == 0
158
+
159
+ def test_duplicate_id_first_wins(self, tmp_path: Path, caplog):
160
+ (tmp_path / "aaa.md").write_text(_make_guide_md(guide_id="dup", title="First"))
161
+ (tmp_path / "bbb.md").write_text(_make_guide_md(guide_id="dup", title="Second"))
162
+ (tmp_path / "ccc.md").write_text(_make_guide_md(guide_id="survivor", title="Survivor"))
163
+ with caplog.at_level(logging.WARNING):
164
+ idx = build_index(tmp_path)
165
+ assert len(idx) == 2
166
+ assert idx.get("dup").meta.title == "First"
167
+ assert idx.get("survivor") is not None
168
+ assert "Duplicate" in caplog.text
169
+
170
+ def test_frontmatter_error_skipped(self, tmp_path: Path, caplog):
171
+ (tmp_path / "bad.md").write_text("no frontmatter here")
172
+ (tmp_path / "good.md").write_text(_make_guide_md(guide_id="good"))
173
+ with caplog.at_level(logging.WARNING):
174
+ idx = build_index(tmp_path)
175
+ assert len(idx) == 1
176
+ assert idx.get("good") is not None
177
+ assert "Skipping" in caplog.text
178
+
179
+ def test_generic_exception_skipped(self, tmp_path: Path, caplog):
180
+ (tmp_path / "err.md").write_text("placeholder")
181
+ (tmp_path / "ok.md").write_text(_make_guide_md(guide_id="ok"))
182
+
183
+ original_read_text = Path.read_text
184
+
185
+ def _patched_read_text(self, *args, **kwargs):
186
+ if self.name == "err.md":
187
+ raise RuntimeError("simulated read failure")
188
+ return original_read_text(self, *args, **kwargs)
189
+
190
+ with patch.object(Path, "read_text", _patched_read_text), caplog.at_level(logging.WARNING):
191
+ idx = build_index(tmp_path)
192
+ assert len(idx) == 1
193
+ assert idx.get("ok") is not None
194
+ assert "Unexpected error" in caplog.text
195
+
196
+ def test_id_filename_mismatch(self, tmp_path: Path, caplog):
197
+ (tmp_path / "wrong-name.md").write_text(_make_guide_md(guide_id="real-id"))
198
+ with caplog.at_level(logging.WARNING):
199
+ idx = build_index(tmp_path)
200
+ assert len(idx) == 1
201
+ assert idx.get("real-id") is not None
202
+ assert idx.get("wrong-name") is None
203
+ assert "mismatch" in caplog.text
204
+
205
+ def test_nested_subdirectories(self, tmp_path: Path):
206
+ sub = tmp_path / "subdir"
207
+ sub.mkdir()
208
+ (sub / "nested.md").write_text(_make_guide_md(guide_id="nested"))
209
+ idx = build_index(tmp_path)
210
+ assert len(idx) == 1
211
+ assert idx.get("nested") is not None
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # _code_lines (boundary cases only — _extract_snippet covered by test_search.py)
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ class TestCodeLines:
220
+ def test_basic_extraction(self):
221
+ body = "## BAD\n```python\nold()\nnew()\n```\n"
222
+ assert _code_lines(body, "## BAD") == ["old()", "new()"]
223
+
224
+ def test_tilde_fence_unsupported(self):
225
+ body = "## BAD\n~~~python\nold()\n~~~\n"
226
+ assert _code_lines(body, "## BAD") == []
227
+
228
+ def test_heading_trailing_space_no_match(self):
229
+ body = "## BAD \n```python\nold()\n```\n"
230
+ assert _code_lines(body, "## BAD") == []
231
+
232
+ def test_unclosed_fence_returns_all_lines(self):
233
+ body = "## BAD\n```python\nline1\nline2\n"
234
+ assert _code_lines(body, "## BAD") == ["line1", "line2"]
235
+
236
+ def test_empty_code_block(self):
237
+ body = "## BAD\n```python\n```\n"
238
+ assert _code_lines(body, "## BAD") == []
239
+
240
+ def test_multiple_fences_first_only(self):
241
+ body = "## BAD\n```python\nfirst()\n```\n```python\nsecond()\n```\n"
242
+ assert _code_lines(body, "## BAD") == ["first()"]
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # _find_guides_dir fallbacks
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ class TestFindGuidesDir:
251
+ def test_dev_fallback(self, monkeypatch):
252
+ monkeypatch.setattr(
253
+ "modern_python_guidance.guide_index.importlib.resources.files",
254
+ _raise_type_error,
255
+ )
256
+ import modern_python_guidance.guide_index as gi_module
257
+
258
+ repo_root = Path(__file__).resolve().parent.parent
259
+ monkeypatch.setattr(
260
+ gi_module,
261
+ "__file__",
262
+ str(repo_root / "src" / "modern_python_guidance" / "guide_index.py"),
263
+ )
264
+ result = _find_guides_dir()
265
+ expected = repo_root / "skills" / "modern-python-guidance" / "guides"
266
+ assert result == expected
267
+
268
+ def test_relative_fallback(self, monkeypatch, tmp_path):
269
+ monkeypatch.setattr(
270
+ "modern_python_guidance.guide_index.importlib.resources.files",
271
+ _raise_type_error,
272
+ )
273
+ fake_file = tmp_path / "pkg" / "sub" / "guide_index.py"
274
+ fake_file.parent.mkdir(parents=True)
275
+ fake_file.touch()
276
+ import modern_python_guidance.guide_index as gi_module
277
+
278
+ monkeypatch.setattr(gi_module, "__file__", str(fake_file))
279
+ result = _find_guides_dir()
280
+ assert result == Path("skills") / "modern-python-guidance" / "guides"
@@ -262,7 +262,9 @@ class TestRetrieveGuides:
262
262
  result = responses[1]["result"]
263
263
  assert "isError" not in result
264
264
  data = json.loads(result["content"][0]["text"])
265
- assert data == []
265
+ assert "not_found" in data
266
+ assert data["results"] == []
267
+ assert data["not_found"][0]["id"] == "nonexistent-guide-xyz"
266
268
 
267
269
 
268
270
  class TestListGuides:
@@ -197,10 +197,25 @@ class TestToolFunctions:
197
197
  r = mcp._tool_retrieve({"guide_ids": []})
198
198
  assert r["isError"] is True
199
199
 
200
- def test_retrieve_valid(self):
200
+ def test_retrieve_valid_bare_list(self):
201
201
  r = mcp._tool_retrieve({"guide_ids": ["use-builtin-generics"]})
202
202
  data = json.loads(r["content"][0]["text"])
203
203
  assert isinstance(data, list)
204
+ assert data[0]["id"] == "use-builtin-generics"
205
+
206
+ def test_retrieve_nonexistent_envelope(self):
207
+ r = mcp._tool_retrieve({"guide_ids": ["builtin-generics"]})
208
+ data = json.loads(r["content"][0]["text"])
209
+ assert "not_found" in data
210
+ assert data["not_found"][0]["id"] == "builtin-generics"
211
+ assert "use-builtin-generics" in data["not_found"][0]["suggestions"]
212
+
213
+ def test_retrieve_mixed_envelope(self):
214
+ r = mcp._tool_retrieve({"guide_ids": ["use-builtin-generics", "zzz-fake"]})
215
+ data = json.loads(r["content"][0]["text"])
216
+ assert len(data["results"]) == 1
217
+ assert data["results"][0]["id"] == "use-builtin-generics"
218
+ assert data["not_found"][0]["id"] == "zzz-fake"
204
219
 
205
220
  def test_retrieve_exactly_41_allowed(self):
206
221
  ids = [f"fake-{i}" for i in range(41)]
@@ -335,6 +350,18 @@ class TestHandleRequest:
335
350
  resp = mcp._handle_request(msg)
336
351
  assert resp is None
337
352
 
353
+ @pytest.mark.parametrize(
354
+ "msg",
355
+ [[1, 2, 3], "hello", 42, True],
356
+ ids=["list", "string", "number", "bool"],
357
+ )
358
+ def test_non_dict_returns_invalid_request(self, msg):
359
+ resp = mcp._handle_request(msg)
360
+ assert resp["jsonrpc"] == "2.0"
361
+ assert resp["id"] is None
362
+ assert resp["error"]["code"] == -32600
363
+ assert "expected JSON object" in resp["error"]["message"]
364
+
338
365
 
339
366
  # --- serve ---
340
367
 
@@ -377,3 +404,20 @@ class TestServe:
377
404
  assert len(responses) == 2
378
405
  assert responses[0]["id"] == 1
379
406
  assert responses[1]["id"] == 2
407
+
408
+ def test_non_dict_json_recovery(self):
409
+ lines = (
410
+ json.dumps([1, 2, 3])
411
+ + "\n"
412
+ + json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
413
+ + "\n"
414
+ )
415
+ sin = io.StringIO(lines)
416
+ sout = io.StringIO()
417
+ mcp.serve(stdin=sin, stdout=sout)
418
+ responses = [json.loads(line) for line in sout.getvalue().strip().split("\n")]
419
+ assert len(responses) == 2
420
+ assert responses[0]["error"]["code"] == -32600
421
+ assert responses[0]["id"] is None
422
+ assert responses[1]["id"] == 1
423
+ assert "protocolVersion" in responses[1]["result"]
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  import pytest
7
7
 
8
8
  from modern_python_guidance.guide_index import build_index
9
- from modern_python_guidance.retrieve import retrieve, retrieve_json
9
+ from modern_python_guidance.retrieve import retrieve, retrieve_json, suggest_ids
10
10
 
11
11
  GUIDES_DIR = Path(__file__).parent.parent / "skills" / "modern-python-guidance" / "guides"
12
12
 
@@ -84,3 +84,36 @@ class TestRetrieveJSON:
84
84
  "source",
85
85
  }
86
86
  assert set(parsed[0].keys()) == expected_keys
87
+
88
+
89
+ class TestSuggestIds:
90
+ def test_close_match(self, index):
91
+ suggestions = suggest_ids(index, "builtin-generics")
92
+ assert "use-builtin-generics" in suggestions
93
+
94
+ def test_no_match(self, index):
95
+ suggestions = suggest_ids(index, "zzz-totally-unknown")
96
+ assert suggestions == []
97
+
98
+ def test_max_three(self, index):
99
+ suggestions = suggest_ids(index, "pydantic")
100
+ assert len(suggestions) <= 3
101
+
102
+ def test_case_insensitive(self, index):
103
+ suggestions = suggest_ids(index, "USE-BUILTIN-GENERICS")
104
+ assert "use-builtin-generics" in suggestions
105
+
106
+ def test_long_id_truncated(self, index):
107
+ long_id = "use-builtin-generics" + "-x" * 200
108
+ suggestions = suggest_ids(index, long_id)
109
+ assert isinstance(suggestions, list)
110
+
111
+ def test_non_string_returns_empty(self, index):
112
+ assert suggest_ids(index, 123) == []
113
+
114
+ def test_empty_index(self):
115
+ from modern_python_guidance.guide_index import GuideIndex
116
+
117
+ empty = GuideIndex()
118
+ suggestions = suggest_ids(empty, "anything")
119
+ assert suggestions == []