modern-python-guidance 0.3.6__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.6 → modern_python_guidance-0.3.8}/CHANGELOG.md +25 -0
  2. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/CONTRIBUTING.md +1 -1
  3. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/PKG-INFO +1 -1
  4. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/pyproject.toml +2 -2
  5. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/__init__.py +1 -1
  6. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/cli.py +26 -10
  7. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/mcp_server.py +18 -7
  8. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/retrieve.py +13 -0
  9. modern_python_guidance-0.3.8/tests/test_cli_unit.py +265 -0
  10. modern_python_guidance-0.3.8/tests/test_compat.py +82 -0
  11. modern_python_guidance-0.3.8/tests/test_guide_index.py +280 -0
  12. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/tests/test_mcp_server.py +3 -1
  13. modern_python_guidance-0.3.8/tests/test_mcp_unit.py +423 -0
  14. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/tests/test_retrieve.py +34 -1
  15. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/.github/workflows/check-python-release.yml +0 -0
  16. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/.github/workflows/ci.yml +0 -0
  17. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/.github/workflows/publish.yml +0 -0
  18. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/.gitignore +0 -0
  19. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/LICENSE +0 -0
  20. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/LICENSE-MIT +0 -0
  21. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/README.md +0 -0
  22. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/SECURITY.md +0 -0
  23. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/edge-cases/opus48_multiline.py +0 -0
  24. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/edge-cases/valid_alt_patterns.py +0 -0
  25. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/pyproject.toml +0 -0
  26. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/app.py +0 -0
  27. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/config.py +0 -0
  28. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/crawler.py +0 -0
  29. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/models.py +0 -0
  30. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/scanner.py +0 -0
  31. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-modern/src/utils.py +0 -0
  32. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/pyproject.toml +0 -0
  33. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/setup.py +0 -0
  34. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/app.py +0 -0
  35. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/config.py +0 -0
  36. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/crawler.py +0 -0
  37. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/models.py +0 -0
  38. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/scanner.py +0 -0
  39. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-a-outdated/src/utils.py +0 -0
  40. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-b-modern/myapp/models.py +0 -0
  41. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-b-modern/myapp/views.py +0 -0
  42. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-b-outdated/myapp/models.py +0 -0
  43. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-b-outdated/myapp/views.py +0 -0
  44. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-c-modern/tests/test_calculator.py +0 -0
  45. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/fixtures/variant-c-outdated/tests/test_calculator.py +0 -0
  46. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/mcp-config.json +0 -0
  47. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompt-v2.txt +0 -0
  48. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompt-v3-mcp.txt +0 -0
  49. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompt-v3.txt +0 -0
  50. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompt-v4-a.txt +0 -0
  51. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompt-v4-b.txt +0 -0
  52. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompt-v4-c.txt +0 -0
  53. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompt.txt +0 -0
  54. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompts/v5-a-detailed.txt +0 -0
  55. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompts/v5-a-normal.txt +0 -0
  56. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompts/v5-a-terse.txt +0 -0
  57. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompts/v5-b-detailed.txt +0 -0
  58. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompts/v5-b-normal.txt +0 -0
  59. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompts/v5-b-terse.txt +0 -0
  60. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompts/v5-c-detailed.txt +0 -0
  61. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompts/v5-c-normal.txt +0 -0
  62. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/prompts/v5-c-terse.txt +0 -0
  63. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/run-mcp.sh +0 -0
  64. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/run-v4.sh +0 -0
  65. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/run-v5.sh +0 -0
  66. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/run.sh +0 -0
  67. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/score-v2.sh +0 -0
  68. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/score-v3.sh +0 -0
  69. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/score-v4.sh +0 -0
  70. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/score.sh +0 -0
  71. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/score_v5.py +0 -0
  72. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/bench/test-scorer.sh +0 -0
  73. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/docs/benchmark-evaluation.md +0 -0
  74. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/docs/benchmark-procedure.md +0 -0
  75. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/docs/benchmark-v5.md +0 -0
  76. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/docs/design.md +0 -0
  77. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/rules/modern-python.md +0 -0
  78. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/SKILL.md +0 -0
  79. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
  80. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
  81. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
  82. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -0
  83. {modern_python_guidance-0.3.6 → 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.6 → 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.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/django/django-async-views.md +0 -0
  86. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/django/django-check-constraints.md +0 -0
  87. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/django/django-json-field.md +0 -0
  88. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
  89. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +0 -0
  90. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +0 -0
  91. {modern_python_guidance-0.3.6 → 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.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
  93. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
  94. {modern_python_guidance-0.3.6 → 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.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
  96. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
  97. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pytest/pytest-parametrize.md +0 -0
  98. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pytest/pytest-raises-match.md +0 -0
  99. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/pytest/pytest-tmp-path.md +0 -0
  100. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-2-style.md +0 -0
  101. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-async-session.md +0 -0
  102. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-mapped-column.md +0 -0
  103. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
  104. {modern_python_guidance-0.3.6 → 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.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
  106. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/stdlib/template-strings.md +0 -0
  107. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
  108. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
  109. {modern_python_guidance-0.3.6 → 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.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
  111. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
  112. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
  113. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/deferred-annotations.md +0 -0
  114. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
  115. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
  116. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
  117. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
  118. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
  119. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
  120. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/__main__.py +0 -0
  121. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/compat.py +0 -0
  122. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/frontmatter.py +0 -0
  123. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/guide_index.py +0 -0
  124. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/search.py +0 -0
  125. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/setup_cmd.py +0 -0
  126. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/uninstall_cmd.py +0 -0
  127. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/src/modern_python_guidance/version_detect.py +0 -0
  128. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/tests/test_cli_integration.py +0 -0
  129. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/tests/test_frontmatter.py +0 -0
  130. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/tests/test_guide_structure.py +0 -0
  131. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/tests/test_scorer_v5.py +0 -0
  132. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/tests/test_search.py +0 -0
  133. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/tests/test_setup.py +0 -0
  134. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/tests/test_skill_sync.py +0 -0
  135. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/tests/test_uninstall.py +0 -0
  136. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.8}/tests/test_version_detect.py +0 -0
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.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
+
15
+ ## [0.3.7] — 2026-06-02
16
+
17
+ ### Fixed
18
+
19
+ - `_read_message` CWE-674 recursion bug: ~1000 consecutive blank lines on MCP stdin would crash the server with `RecursionError`. Replaced recursive call with iterative `while` loop.
20
+
21
+ ### Added
22
+
23
+ - 86 in-process unit tests for `cli.py` (33) and `mcp_server.py` (53), raising per-file coverage from 0% to 96%. Covers CLI dispatch, format auto-detection, search/retrieve/list subcommands, `_confine_path` security (8 patterns including symlink escape and CWD=/ guard), JSON-RPC framing, request handling, and serve loop recovery.
24
+
25
+ ### Changed
26
+
27
+ - Coverage `fail_under` ratcheted from 59% to 92% (actual: 92.48%)
28
+ - CONTRIBUTING.md coverage gate updated to match
29
+
5
30
  ## [0.3.6] — 2026-05-31
6
31
 
7
32
  ### Added
@@ -58,4 +58,4 @@ All PRs run these checks on Python 3.11, 3.12, and 3.13:
58
58
 
59
59
  1. `ruff format --check` — formatting
60
60
  2. `ruff check` — linting
61
- 3. `pytest --cov` — tests with branch coverage (`fail_under = 59%`)
61
+ 3. `pytest --cov` — tests with branch coverage (`fail_under = 92%`)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modern-python-guidance
3
- Version: 0.3.6
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.6"
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"
@@ -76,7 +76,7 @@ branch = true
76
76
  [tool.coverage.report]
77
77
  show_missing = true
78
78
  skip_empty = true
79
- fail_under = 59
79
+ fail_under = 92
80
80
  exclude_lines = [
81
81
  "pragma: no cover",
82
82
  "if __name__ == .__main__.",
@@ -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.6"
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
 
@@ -35,12 +35,13 @@ class _Skip(Exception):
35
35
 
36
36
  def _read_message(stream: object = None) -> dict | None:
37
37
  buf = stream or sys.stdin
38
- line = buf.readline()
39
- if not line:
40
- return None
41
- line = line.strip()
42
- if not line:
43
- return _read_message(stream)
38
+ while True:
39
+ line = buf.readline()
40
+ if not line:
41
+ return None
42
+ line = line.strip()
43
+ if line:
44
+ break
44
45
  try:
45
46
  return json.loads(line)
46
47
  except json.JSONDecodeError as exc:
@@ -279,6 +280,14 @@ def _tool_retrieve(arguments: dict) -> dict:
279
280
 
280
281
  index = _get_index()
281
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
+
282
291
  return _tool_result(json.dumps(results, indent=2, ensure_ascii=False))
283
292
 
284
293
 
@@ -331,6 +340,8 @@ PROTOCOL_VERSION = "2024-11-05"
331
340
 
332
341
 
333
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")
334
345
  method = msg.get("method", "")
335
346
  req_id = msg.get("id")
336
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,
@@ -0,0 +1,265 @@
1
+ """In-process unit tests for cli.py — direct function calls for coverage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ from modern_python_guidance.cli import _resolve_format, main
12
+
13
+
14
+ class TestMainDispatch:
15
+ def test_no_command_exits_2(self, capsys):
16
+ with pytest.raises(SystemExit, match="2"):
17
+ main(argv=[])
18
+
19
+ def test_version_flag(self, capsys):
20
+ from modern_python_guidance import __version__
21
+
22
+ with pytest.raises(SystemExit, match="0"):
23
+ main(argv=["--version"])
24
+ assert __version__ in capsys.readouterr().out
25
+
26
+ def test_invalid_python_version_exits(self, capsys):
27
+ with pytest.raises(SystemExit, match="2"):
28
+ main(argv=["search", "typing", "--python-version", "abc"])
29
+
30
+ def test_broken_pipe_exits_0(self, monkeypatch):
31
+ def raise_broken_pipe(*_a, **_kw):
32
+ raise BrokenPipeError
33
+
34
+ monkeypatch.setattr("modern_python_guidance.cli.do_search", raise_broken_pipe)
35
+ with pytest.raises(SystemExit, match="0"):
36
+ main(argv=["search", "typing"])
37
+
38
+
39
+ class TestResolveFormat:
40
+ def test_explicit_json(self):
41
+ ns = _make_ns(format="json")
42
+ assert _resolve_format(ns) == "json"
43
+
44
+ def test_explicit_human(self):
45
+ ns = _make_ns(format="human")
46
+ assert _resolve_format(ns) == "human"
47
+
48
+ def test_auto_tty(self, monkeypatch):
49
+ monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
50
+ ns = _make_ns(format=None)
51
+ assert _resolve_format(ns) == "human"
52
+
53
+ def test_auto_pipe(self, monkeypatch):
54
+ monkeypatch.setattr(sys.stdout, "isatty", lambda: False)
55
+ ns = _make_ns(format=None)
56
+ assert _resolve_format(ns) == "json"
57
+
58
+
59
+ class TestCmdSearch:
60
+ def test_json_output(self, capsys):
61
+ main(argv=["search", "typing list", "--format", "json"])
62
+ data = json.loads(capsys.readouterr().out)
63
+ assert isinstance(data, list)
64
+ assert len(data) >= 1
65
+
66
+ def test_human_output(self, capsys):
67
+ main(argv=["search", "typing", "--format", "human"])
68
+ out = capsys.readouterr().out
69
+ assert "use-builtin-generics" in out
70
+
71
+ def test_no_match_exits_1(self, capsys):
72
+ with pytest.raises(SystemExit, match="1"):
73
+ main(argv=["search", "qqqxxx999zzz", "--format", "json"])
74
+
75
+ def test_no_match_human_exits_1(self, capsys):
76
+ with pytest.raises(SystemExit, match="1"):
77
+ main(argv=["search", "qqqxxx999zzz", "--format", "human"])
78
+ assert "No guides found" in capsys.readouterr().out
79
+
80
+ def test_fuzzy_marker(self, capsys):
81
+ main(argv=["search", "typng", "--format", "human"])
82
+ out = capsys.readouterr().out
83
+ assert "(fuzzy)" in out or "use-builtin-generics" in out
84
+
85
+ def test_category_filter(self, capsys):
86
+ main(argv=["search", "typing", "--category", "typing", "--format", "json"])
87
+ data = json.loads(capsys.readouterr().out)
88
+ assert data
89
+ for item in data:
90
+ assert item["category"] == "typing"
91
+
92
+ def test_limit(self, capsys):
93
+ main(argv=["search", "python", "--limit", "2", "--format", "json"])
94
+ data = json.loads(capsys.readouterr().out)
95
+ assert len(data) <= 2
96
+
97
+
98
+ class TestCmdRetrieve:
99
+ def test_json_output(self, capsys):
100
+ main(argv=["retrieve", "use-builtin-generics", "--format", "json"])
101
+ data = json.loads(capsys.readouterr().out)
102
+ assert isinstance(data, list)
103
+ assert data[0]["id"] == "use-builtin-generics"
104
+
105
+ def test_human_output(self, capsys):
106
+ main(argv=["retrieve", "use-builtin-generics", "--format", "human"])
107
+ out = capsys.readouterr().out
108
+ assert "use-builtin-generics" in out
109
+ assert "version match:" in out
110
+
111
+ def test_comma_split_ids(self, capsys):
112
+ main(argv=["retrieve", "use-builtin-generics,union-syntax", "--format", "json"])
113
+ data = json.loads(capsys.readouterr().out)
114
+ ids = {r["id"] for r in data}
115
+ assert "use-builtin-generics" in ids
116
+ assert "union-syntax" in ids
117
+
118
+ def test_no_match_exits_1_json_envelope(self, capsys):
119
+ with pytest.raises(SystemExit, match="1"):
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"
125
+
126
+ def test_no_match_human_exits_1(self, capsys):
127
+ with pytest.raises(SystemExit, match="1"):
128
+ main(argv=["retrieve", "nonexistent-guide-id", "--format", "human"])
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
168
+
169
+ def test_version_match_yes(self, capsys):
170
+ main(
171
+ argv=[
172
+ "retrieve",
173
+ "use-builtin-generics",
174
+ "--python-version",
175
+ "3.12",
176
+ "--format",
177
+ "human",
178
+ ]
179
+ )
180
+ out = capsys.readouterr().out
181
+ assert "YES" in out
182
+
183
+
184
+ class TestCmdList:
185
+ def test_json_output(self, capsys):
186
+ main(argv=["list", "--format", "json"])
187
+ data = json.loads(capsys.readouterr().out)
188
+ assert isinstance(data, list)
189
+ assert len(data) >= 10
190
+
191
+ def test_human_output(self, capsys):
192
+ main(argv=["list", "--format", "human"])
193
+ out = capsys.readouterr().out
194
+ assert "layer" in out
195
+
196
+ def test_category_filter(self, capsys):
197
+ main(argv=["list", "--category", "stdlib", "--format", "json"])
198
+ data = json.loads(capsys.readouterr().out)
199
+ assert data
200
+ for item in data:
201
+ assert item["category"] == "stdlib"
202
+
203
+ def test_version_filter(self, capsys):
204
+ main(argv=["list", "--python-version", "3.11", "--format", "json"])
205
+ data = json.loads(capsys.readouterr().out)
206
+ assert isinstance(data, list)
207
+ assert len(data) >= 1
208
+
209
+ def test_empty_exits_1(self, capsys):
210
+ with pytest.raises(SystemExit, match="1"):
211
+ main(argv=["list", "--category", "nonexistent_cat", "--format", "json"])
212
+
213
+ def test_category_grouping_human(self, capsys):
214
+ main(argv=["list", "--format", "human"])
215
+ out = capsys.readouterr().out
216
+ assert "[" in out and "]" in out
217
+
218
+
219
+ class TestCmdDetectVersion:
220
+ def test_basic_output(self, capsys):
221
+ main(argv=["detect-version"])
222
+ out = capsys.readouterr().out.strip()
223
+ assert out # should return some version string
224
+
225
+
226
+ class TestCmdSetupUninstall:
227
+ def test_setup_dispatch(self):
228
+ with patch("modern_python_guidance.setup_cmd.run_setup", return_value=0) as mock:
229
+ with pytest.raises(SystemExit, match="0"):
230
+ main(argv=["setup", "--dry-run"])
231
+ mock.assert_called_once()
232
+ call_kwargs = mock.call_args
233
+ assert call_kwargs[1]["dry_run"] is True
234
+
235
+ def test_uninstall_dispatch(self):
236
+ with patch("modern_python_guidance.uninstall_cmd.run_uninstall", return_value=0) as mock:
237
+ with pytest.raises(SystemExit, match="0"):
238
+ main(argv=["uninstall", "--dry-run"])
239
+ mock.assert_called_once()
240
+ call_kwargs = mock.call_args
241
+ assert call_kwargs[1]["dry_run"] is True
242
+
243
+ def test_setup_nonzero_exit(self):
244
+ with (
245
+ patch("modern_python_guidance.setup_cmd.run_setup", return_value=1),
246
+ pytest.raises(SystemExit, match="1"),
247
+ ):
248
+ main(argv=["setup", "--dry-run"])
249
+
250
+ def test_uninstall_nonzero_exit(self):
251
+ with (
252
+ patch("modern_python_guidance.uninstall_cmd.run_uninstall", return_value=1),
253
+ pytest.raises(SystemExit, match="1"),
254
+ ):
255
+ main(argv=["uninstall", "--dry-run"])
256
+
257
+
258
+ # --- helpers ---
259
+
260
+
261
+ def _make_ns(**kwargs):
262
+ """Build a minimal argparse.Namespace for _resolve_format tests."""
263
+ import argparse
264
+
265
+ return argparse.Namespace(**kwargs)
@@ -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