modern-python-guidance 0.3.6__tar.gz → 0.3.7__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 (134) hide show
  1. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/CHANGELOG.md +15 -0
  2. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/CONTRIBUTING.md +1 -1
  3. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/PKG-INFO +1 -1
  4. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/pyproject.toml +2 -2
  5. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/__init__.py +1 -1
  6. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/mcp_server.py +7 -6
  7. modern_python_guidance-0.3.7/tests/test_cli_unit.py +223 -0
  8. modern_python_guidance-0.3.7/tests/test_mcp_unit.py +379 -0
  9. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/.github/workflows/check-python-release.yml +0 -0
  10. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/.github/workflows/ci.yml +0 -0
  11. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/.github/workflows/publish.yml +0 -0
  12. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/.gitignore +0 -0
  13. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/LICENSE +0 -0
  14. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/LICENSE-MIT +0 -0
  15. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/README.md +0 -0
  16. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/SECURITY.md +0 -0
  17. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/edge-cases/opus48_multiline.py +0 -0
  18. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/edge-cases/valid_alt_patterns.py +0 -0
  19. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-modern/pyproject.toml +0 -0
  20. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-modern/src/app.py +0 -0
  21. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-modern/src/config.py +0 -0
  22. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-modern/src/crawler.py +0 -0
  23. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-modern/src/models.py +0 -0
  24. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-modern/src/scanner.py +0 -0
  25. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-modern/src/utils.py +0 -0
  26. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-outdated/pyproject.toml +0 -0
  27. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-outdated/setup.py +0 -0
  28. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-outdated/src/app.py +0 -0
  29. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-outdated/src/config.py +0 -0
  30. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-outdated/src/crawler.py +0 -0
  31. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-outdated/src/models.py +0 -0
  32. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-outdated/src/scanner.py +0 -0
  33. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-a-outdated/src/utils.py +0 -0
  34. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-b-modern/myapp/models.py +0 -0
  35. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-b-modern/myapp/views.py +0 -0
  36. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-b-outdated/myapp/models.py +0 -0
  37. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-b-outdated/myapp/views.py +0 -0
  38. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-c-modern/tests/test_calculator.py +0 -0
  39. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/fixtures/variant-c-outdated/tests/test_calculator.py +0 -0
  40. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/mcp-config.json +0 -0
  41. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompt-v2.txt +0 -0
  42. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompt-v3-mcp.txt +0 -0
  43. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompt-v3.txt +0 -0
  44. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompt-v4-a.txt +0 -0
  45. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompt-v4-b.txt +0 -0
  46. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompt-v4-c.txt +0 -0
  47. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompt.txt +0 -0
  48. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompts/v5-a-detailed.txt +0 -0
  49. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompts/v5-a-normal.txt +0 -0
  50. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompts/v5-a-terse.txt +0 -0
  51. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompts/v5-b-detailed.txt +0 -0
  52. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompts/v5-b-normal.txt +0 -0
  53. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompts/v5-b-terse.txt +0 -0
  54. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompts/v5-c-detailed.txt +0 -0
  55. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompts/v5-c-normal.txt +0 -0
  56. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/prompts/v5-c-terse.txt +0 -0
  57. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/run-mcp.sh +0 -0
  58. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/run-v4.sh +0 -0
  59. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/run-v5.sh +0 -0
  60. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/run.sh +0 -0
  61. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/score-v2.sh +0 -0
  62. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/score-v3.sh +0 -0
  63. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/score-v4.sh +0 -0
  64. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/score.sh +0 -0
  65. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/score_v5.py +0 -0
  66. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/bench/test-scorer.sh +0 -0
  67. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/docs/benchmark-evaluation.md +0 -0
  68. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/docs/benchmark-procedure.md +0 -0
  69. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/docs/benchmark-v5.md +0 -0
  70. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/docs/design.md +0 -0
  71. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/rules/modern-python.md +0 -0
  72. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/SKILL.md +0 -0
  73. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
  74. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
  75. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
  76. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -0
  77. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/data-structures/dict-merge-operator.md +0 -0
  78. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/data-structures/match-case-patterns.md +0 -0
  79. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/django/django-async-views.md +0 -0
  80. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/django/django-check-constraints.md +0 -0
  81. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/django/django-json-field.md +0 -0
  82. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
  83. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +0 -0
  84. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +0 -0
  85. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/httpx/httpx-async-client-reuse.md +0 -0
  86. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
  87. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
  88. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-model-api.md +0 -0
  89. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
  90. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
  91. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/pytest/pytest-parametrize.md +0 -0
  92. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/pytest/pytest-raises-match.md +0 -0
  93. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/pytest/pytest-tmp-path.md +0 -0
  94. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-2-style.md +0 -0
  95. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-async-session.md +0 -0
  96. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-mapped-column.md +0 -0
  97. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
  98. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/stdlib/pathlib-over-os-path.md +0 -0
  99. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
  100. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/stdlib/template-strings.md +0 -0
  101. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
  102. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
  103. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/toolchain/pyproject-toml-over-setup.md +0 -0
  104. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
  105. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
  106. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
  107. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/typing/deferred-annotations.md +0 -0
  108. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
  109. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
  110. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
  111. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
  112. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
  113. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
  114. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/__main__.py +0 -0
  115. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/cli.py +0 -0
  116. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/compat.py +0 -0
  117. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/frontmatter.py +0 -0
  118. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/guide_index.py +0 -0
  119. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/retrieve.py +0 -0
  120. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/search.py +0 -0
  121. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/setup_cmd.py +0 -0
  122. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/uninstall_cmd.py +0 -0
  123. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/src/modern_python_guidance/version_detect.py +0 -0
  124. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/tests/test_cli_integration.py +0 -0
  125. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/tests/test_frontmatter.py +0 -0
  126. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/tests/test_guide_structure.py +0 -0
  127. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/tests/test_mcp_server.py +0 -0
  128. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/tests/test_retrieve.py +0 -0
  129. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/tests/test_scorer_v5.py +0 -0
  130. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/tests/test_search.py +0 -0
  131. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/tests/test_setup.py +0 -0
  132. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/tests/test_skill_sync.py +0 -0
  133. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/tests/test_uninstall.py +0 -0
  134. {modern_python_guidance-0.3.6 → modern_python_guidance-0.3.7}/tests/test_version_detect.py +0 -0
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.7] — 2026-06-02
6
+
7
+ ### Fixed
8
+
9
+ - `_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.
10
+
11
+ ### Added
12
+
13
+ - 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.
14
+
15
+ ### Changed
16
+
17
+ - Coverage `fail_under` ratcheted from 59% to 92% (actual: 92.48%)
18
+ - CONTRIBUTING.md coverage gate updated to match
19
+
5
20
  ## [0.3.6] — 2026-05-31
6
21
 
7
22
  ### 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.7
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.7"
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.7"
@@ -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:
@@ -0,0 +1,223 @@
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(self, capsys):
119
+ with pytest.raises(SystemExit, match="1"):
120
+ main(argv=["retrieve", "nonexistent-guide-id", "--format", "json"])
121
+
122
+ def test_no_match_human_exits_1(self, capsys):
123
+ with pytest.raises(SystemExit, match="1"):
124
+ main(argv=["retrieve", "nonexistent-guide-id", "--format", "human"])
125
+ assert "No guides found" in capsys.readouterr().out
126
+
127
+ def test_version_match_yes(self, capsys):
128
+ main(
129
+ argv=[
130
+ "retrieve",
131
+ "use-builtin-generics",
132
+ "--python-version",
133
+ "3.12",
134
+ "--format",
135
+ "human",
136
+ ]
137
+ )
138
+ out = capsys.readouterr().out
139
+ assert "YES" in out
140
+
141
+
142
+ class TestCmdList:
143
+ def test_json_output(self, capsys):
144
+ main(argv=["list", "--format", "json"])
145
+ data = json.loads(capsys.readouterr().out)
146
+ assert isinstance(data, list)
147
+ assert len(data) >= 10
148
+
149
+ def test_human_output(self, capsys):
150
+ main(argv=["list", "--format", "human"])
151
+ out = capsys.readouterr().out
152
+ assert "layer" in out
153
+
154
+ def test_category_filter(self, capsys):
155
+ main(argv=["list", "--category", "stdlib", "--format", "json"])
156
+ data = json.loads(capsys.readouterr().out)
157
+ assert data
158
+ for item in data:
159
+ assert item["category"] == "stdlib"
160
+
161
+ def test_version_filter(self, capsys):
162
+ main(argv=["list", "--python-version", "3.11", "--format", "json"])
163
+ data = json.loads(capsys.readouterr().out)
164
+ assert isinstance(data, list)
165
+ assert len(data) >= 1
166
+
167
+ def test_empty_exits_1(self, capsys):
168
+ with pytest.raises(SystemExit, match="1"):
169
+ main(argv=["list", "--category", "nonexistent_cat", "--format", "json"])
170
+
171
+ def test_category_grouping_human(self, capsys):
172
+ main(argv=["list", "--format", "human"])
173
+ out = capsys.readouterr().out
174
+ assert "[" in out and "]" in out
175
+
176
+
177
+ class TestCmdDetectVersion:
178
+ def test_basic_output(self, capsys):
179
+ main(argv=["detect-version"])
180
+ out = capsys.readouterr().out.strip()
181
+ assert out # should return some version string
182
+
183
+
184
+ class TestCmdSetupUninstall:
185
+ def test_setup_dispatch(self):
186
+ with patch("modern_python_guidance.setup_cmd.run_setup", return_value=0) as mock:
187
+ with pytest.raises(SystemExit, match="0"):
188
+ main(argv=["setup", "--dry-run"])
189
+ mock.assert_called_once()
190
+ call_kwargs = mock.call_args
191
+ assert call_kwargs[1]["dry_run"] is True
192
+
193
+ def test_uninstall_dispatch(self):
194
+ with patch("modern_python_guidance.uninstall_cmd.run_uninstall", return_value=0) as mock:
195
+ with pytest.raises(SystemExit, match="0"):
196
+ main(argv=["uninstall", "--dry-run"])
197
+ mock.assert_called_once()
198
+ call_kwargs = mock.call_args
199
+ assert call_kwargs[1]["dry_run"] is True
200
+
201
+ def test_setup_nonzero_exit(self):
202
+ with (
203
+ patch("modern_python_guidance.setup_cmd.run_setup", return_value=1),
204
+ pytest.raises(SystemExit, match="1"),
205
+ ):
206
+ main(argv=["setup", "--dry-run"])
207
+
208
+ def test_uninstall_nonzero_exit(self):
209
+ with (
210
+ patch("modern_python_guidance.uninstall_cmd.run_uninstall", return_value=1),
211
+ pytest.raises(SystemExit, match="1"),
212
+ ):
213
+ main(argv=["uninstall", "--dry-run"])
214
+
215
+
216
+ # --- helpers ---
217
+
218
+
219
+ def _make_ns(**kwargs):
220
+ """Build a minimal argparse.Namespace for _resolve_format tests."""
221
+ import argparse
222
+
223
+ return argparse.Namespace(**kwargs)
@@ -0,0 +1,379 @@
1
+ """In-process unit tests for mcp_server.py — direct function calls for coverage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ import modern_python_guidance.mcp_server as mcp
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def _reset_index():
16
+ """Reset the global singleton between tests."""
17
+ mcp._index = None
18
+ yield
19
+ mcp._index = None
20
+
21
+
22
+ # --- Framing ---
23
+
24
+
25
+ class TestFraming:
26
+ def test_read_message_eof(self):
27
+ stream = io.StringIO("")
28
+ assert mcp._read_message(stream) is None
29
+
30
+ def test_read_message_valid_json(self):
31
+ stream = io.StringIO('{"key": "value"}\n')
32
+ result = mcp._read_message(stream)
33
+ assert result == {"key": "value"}
34
+
35
+ def test_read_message_skips_blank_lines(self):
36
+ stream = io.StringIO('\n\n\n{"key": "value"}\n')
37
+ result = mcp._read_message(stream)
38
+ assert result == {"key": "value"}
39
+
40
+ def test_read_message_many_blank_lines_no_recursion(self):
41
+ lines = "\n" * 2000 + '{"ok": true}\n'
42
+ stream = io.StringIO(lines)
43
+ result = mcp._read_message(stream)
44
+ assert result == {"ok": True}
45
+
46
+ def test_read_message_blank_then_eof(self):
47
+ stream = io.StringIO("\n\n\n")
48
+ assert mcp._read_message(stream) is None
49
+
50
+ def test_read_message_invalid_json_raises_skip(self):
51
+ stream = io.StringIO("not-json\n")
52
+ with pytest.raises(mcp._Skip, match="invalid JSON"):
53
+ mcp._read_message(stream)
54
+
55
+ def test_write_message(self):
56
+ out = io.StringIO()
57
+ mcp._write_message({"result": 42}, out)
58
+ written = out.getvalue()
59
+ assert json.loads(written.strip()) == {"result": 42}
60
+ assert written.endswith("\n")
61
+
62
+
63
+ # --- Response builders ---
64
+
65
+
66
+ class TestResponseBuilders:
67
+ def test_error_response(self):
68
+ resp = mcp._error_response(1, -32600, "bad request")
69
+ assert resp["jsonrpc"] == "2.0"
70
+ assert resp["id"] == 1
71
+ assert resp["error"]["code"] == -32600
72
+ assert resp["error"]["message"] == "bad request"
73
+
74
+ def test_result_response(self):
75
+ resp = mcp._result_response(2, {"data": "ok"})
76
+ assert resp["jsonrpc"] == "2.0"
77
+ assert resp["id"] == 2
78
+ assert resp["result"] == {"data": "ok"}
79
+
80
+ def test_tool_result_success(self):
81
+ r = mcp._tool_result("hello")
82
+ assert r["content"][0]["text"] == "hello"
83
+ assert "isError" not in r
84
+
85
+ def test_tool_result_error(self):
86
+ r = mcp._tool_result("fail", is_error=True)
87
+ assert r["content"][0]["text"] == "fail"
88
+ assert r["isError"] is True
89
+
90
+
91
+ # --- _confine_path ---
92
+
93
+
94
+ class TestConfinePath:
95
+ def test_none_returns_cwd(self, tmp_path, monkeypatch):
96
+ monkeypatch.chdir(tmp_path)
97
+ result = mcp._confine_path(None)
98
+ assert result == tmp_path
99
+
100
+ def test_valid_subdir(self, tmp_path, monkeypatch):
101
+ sub = tmp_path / "sub"
102
+ sub.mkdir()
103
+ monkeypatch.chdir(tmp_path)
104
+ result = mcp._confine_path("sub")
105
+ assert isinstance(result, Path)
106
+ assert result == sub
107
+
108
+ def test_nonexistent_dir(self, tmp_path, monkeypatch):
109
+ monkeypatch.chdir(tmp_path)
110
+ result = mcp._confine_path("does-not-exist")
111
+ assert isinstance(result, str)
112
+ assert "not found" in result
113
+
114
+ def test_absolute_path_rejected(self, tmp_path, monkeypatch):
115
+ monkeypatch.chdir(tmp_path)
116
+ result = mcp._confine_path("/etc")
117
+ assert isinstance(result, str)
118
+ assert "relative" in result
119
+
120
+ def test_traversal_rejected(self, tmp_path, monkeypatch):
121
+ monkeypatch.chdir(tmp_path)
122
+ result = mcp._confine_path("../../..")
123
+ assert isinstance(result, str)
124
+
125
+ def test_nested_traversal_rejected(self, tmp_path, monkeypatch):
126
+ sub = tmp_path / "sub"
127
+ sub.mkdir()
128
+ monkeypatch.chdir(tmp_path)
129
+ result = mcp._confine_path("sub/../../..")
130
+ assert isinstance(result, str)
131
+
132
+ def test_symlink_escape_rejected(self, tmp_path, monkeypatch):
133
+ monkeypatch.chdir(tmp_path)
134
+ link = tmp_path / "escape"
135
+ link.symlink_to("/tmp")
136
+ result = mcp._confine_path("escape")
137
+ assert isinstance(result, str)
138
+
139
+ def test_cwd_is_root(self, monkeypatch):
140
+ monkeypatch.chdir("/")
141
+ result = mcp._confine_path(None)
142
+ assert isinstance(result, str)
143
+ assert "root" in result
144
+
145
+
146
+ # --- _validate_python_version ---
147
+
148
+
149
+ class TestValidateVersion:
150
+ def test_none_is_valid(self):
151
+ assert mcp._validate_python_version(None) is None
152
+
153
+ def test_valid_version(self):
154
+ assert mcp._validate_python_version("3.12") is None
155
+
156
+ def test_invalid_version(self):
157
+ err = mcp._validate_python_version("abc")
158
+ assert err is not None
159
+ assert "Invalid" in err
160
+
161
+ def test_three_part_version(self):
162
+ err = mcp._validate_python_version("3.12.1")
163
+ assert err is not None
164
+
165
+
166
+ # --- Tool functions ---
167
+
168
+
169
+ class TestToolFunctions:
170
+ def test_search_empty_query(self):
171
+ r = mcp._tool_search({"query": ""})
172
+ assert r["isError"] is True
173
+
174
+ def test_search_valid(self):
175
+ r = mcp._tool_search({"query": "typing"})
176
+ data = json.loads(r["content"][0]["text"])
177
+ assert isinstance(data, list)
178
+ assert len(data) >= 1
179
+
180
+ def test_search_with_filters(self):
181
+ r = mcp._tool_search(
182
+ {
183
+ "query": "typing",
184
+ "python_version": "3.12",
185
+ "category": "typing",
186
+ "limit": 2,
187
+ }
188
+ )
189
+ data = json.loads(r["content"][0]["text"])
190
+ assert len(data) <= 2
191
+
192
+ def test_search_invalid_version(self):
193
+ r = mcp._tool_search({"query": "typing", "python_version": "bad"})
194
+ assert r["isError"] is True
195
+
196
+ def test_retrieve_empty_ids(self):
197
+ r = mcp._tool_retrieve({"guide_ids": []})
198
+ assert r["isError"] is True
199
+
200
+ def test_retrieve_valid(self):
201
+ r = mcp._tool_retrieve({"guide_ids": ["use-builtin-generics"]})
202
+ data = json.loads(r["content"][0]["text"])
203
+ assert isinstance(data, list)
204
+
205
+ def test_retrieve_exactly_41_allowed(self):
206
+ ids = [f"fake-{i}" for i in range(41)]
207
+ r = mcp._tool_retrieve({"guide_ids": ids})
208
+ assert r.get("isError") is not True
209
+
210
+ def test_retrieve_42_rejected(self):
211
+ ids = [f"fake-{i}" for i in range(42)]
212
+ r = mcp._tool_retrieve({"guide_ids": ids})
213
+ assert r["isError"] is True
214
+ assert "41" in r["content"][0]["text"]
215
+
216
+ def test_retrieve_invalid_version(self):
217
+ r = mcp._tool_retrieve({"guide_ids": ["use-builtin-generics"], "python_version": "x"})
218
+ assert r["isError"] is True
219
+
220
+ def test_list_all(self):
221
+ r = mcp._tool_list({})
222
+ data = json.loads(r["content"][0]["text"])
223
+ assert isinstance(data, list)
224
+ assert len(data) >= 10
225
+
226
+ def test_list_category_filter(self):
227
+ r = mcp._tool_list({"category": "stdlib"})
228
+ data = json.loads(r["content"][0]["text"])
229
+ assert data
230
+ for item in data:
231
+ assert item["category"] == "stdlib"
232
+
233
+ def test_list_version_filter(self):
234
+ r = mcp._tool_list({"python_version": "3.11"})
235
+ data = json.loads(r["content"][0]["text"])
236
+ assert isinstance(data, list)
237
+ assert data
238
+
239
+ def test_list_invalid_version(self):
240
+ r = mcp._tool_list({"python_version": "nope"})
241
+ assert r["isError"] is True
242
+
243
+ def test_detect_version_valid(self, tmp_path, monkeypatch):
244
+ monkeypatch.chdir(tmp_path)
245
+ (tmp_path / "pyproject.toml").write_text('[project]\nrequires-python = ">=3.12"\n')
246
+ r = mcp._tool_detect_version({"project_dir": None})
247
+ data = json.loads(r["content"][0]["text"])
248
+ assert "python_version" in data
249
+
250
+ def test_detect_version_confined(self, tmp_path, monkeypatch):
251
+ monkeypatch.chdir(tmp_path)
252
+ r = mcp._tool_detect_version({"project_dir": "/etc"})
253
+ assert r["isError"] is True
254
+
255
+
256
+ # --- _handle_tool_call ---
257
+
258
+
259
+ class TestHandleToolCall:
260
+ def test_unknown_tool(self):
261
+ r = mcp._handle_tool_call("nonexistent", {})
262
+ assert r["isError"] is True
263
+ assert "Unknown tool" in r["content"][0]["text"]
264
+
265
+ def test_dispatch_search(self):
266
+ r = mcp._handle_tool_call("search_guides", {"query": "typing"})
267
+ assert "isError" not in r
268
+
269
+ def test_exception_handling(self, monkeypatch):
270
+ def boom(*_a, **_kw):
271
+ raise RuntimeError("kaboom")
272
+
273
+ monkeypatch.setattr(mcp, "_tool_search", boom)
274
+ r = mcp._handle_tool_call("search_guides", {"query": "x"})
275
+ assert r["isError"] is True
276
+ assert "Internal error" in r["content"][0]["text"]
277
+
278
+
279
+ # --- _handle_request ---
280
+
281
+
282
+ class TestHandleRequest:
283
+ def test_initialize(self):
284
+ msg = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
285
+ resp = mcp._handle_request(msg)
286
+ assert resp is not None
287
+ assert resp["id"] == 1
288
+ assert "protocolVersion" in resp["result"]
289
+
290
+ def test_initialize_notification_returns_none(self):
291
+ msg = {"jsonrpc": "2.0", "method": "initialize", "params": {}}
292
+ resp = mcp._handle_request(msg)
293
+ assert resp is None
294
+
295
+ def test_notifications_initialized(self):
296
+ msg = {"jsonrpc": "2.0", "method": "notifications/initialized"}
297
+ assert mcp._handle_request(msg) is None
298
+
299
+ def test_tools_list(self):
300
+ msg = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
301
+ resp = mcp._handle_request(msg)
302
+ assert resp is not None
303
+ assert "tools" in resp["result"]
304
+ assert len(resp["result"]["tools"]) == 4
305
+
306
+ def test_tools_call(self):
307
+ msg = {
308
+ "jsonrpc": "2.0",
309
+ "id": 3,
310
+ "method": "tools/call",
311
+ "params": {"name": "search_guides", "arguments": {"query": "typing"}},
312
+ }
313
+ resp = mcp._handle_request(msg)
314
+ assert resp is not None
315
+ assert resp["id"] == 3
316
+
317
+ def test_unknown_method(self):
318
+ msg = {"jsonrpc": "2.0", "id": 4, "method": "nonexistent"}
319
+ resp = mcp._handle_request(msg)
320
+ assert resp is not None
321
+ assert "error" in resp
322
+ assert resp["error"]["code"] == -32601
323
+
324
+ def test_unknown_notification_ignored(self):
325
+ msg = {"jsonrpc": "2.0", "method": "custom/notification"}
326
+ resp = mcp._handle_request(msg)
327
+ assert resp is None
328
+
329
+ def test_tools_call_notification_mode(self):
330
+ msg = {
331
+ "jsonrpc": "2.0",
332
+ "method": "tools/call",
333
+ "params": {"name": "search_guides", "arguments": {"query": "typing"}},
334
+ }
335
+ resp = mcp._handle_request(msg)
336
+ assert resp is None
337
+
338
+
339
+ # --- serve ---
340
+
341
+
342
+ class TestServe:
343
+ def test_empty_stdin_eof(self):
344
+ sin = io.StringIO("")
345
+ sout = io.StringIO()
346
+ mcp.serve(stdin=sin, stdout=sout)
347
+ assert sout.getvalue() == ""
348
+
349
+ def test_single_request(self):
350
+ req = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
351
+ sin = io.StringIO(json.dumps(req) + "\n")
352
+ sout = io.StringIO()
353
+ mcp.serve(stdin=sin, stdout=sout)
354
+ resp = json.loads(sout.getvalue().strip())
355
+ assert resp["id"] == 1
356
+ assert "protocolVersion" in resp["result"]
357
+
358
+ def test_malformed_json_recovery(self):
359
+ lines = (
360
+ "not-valid-json\n"
361
+ + json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
362
+ + "\n"
363
+ )
364
+ sin = io.StringIO(lines)
365
+ sout = io.StringIO()
366
+ mcp.serve(stdin=sin, stdout=sout)
367
+ resp = json.loads(sout.getvalue().strip())
368
+ assert resp["id"] == 1
369
+
370
+ def test_multiple_requests(self):
371
+ req1 = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
372
+ req2 = json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
373
+ sin = io.StringIO(req1 + "\n" + req2 + "\n")
374
+ sout = io.StringIO()
375
+ mcp.serve(stdin=sin, stdout=sout)
376
+ responses = [json.loads(line) for line in sout.getvalue().strip().split("\n")]
377
+ assert len(responses) == 2
378
+ assert responses[0]["id"] == 1
379
+ assert responses[1]["id"] == 2