modern-python-guidance 0.4.2__tar.gz → 0.4.4__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.
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/.github/workflows/ci.yml +1 -1
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/CHANGELOG.md +20 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/PKG-INFO +4 -3
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/README.md +3 -2
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/docs/design.md +3 -4
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/pyproject.toml +1 -1
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/__init__.py +1 -1
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/check.py +22 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/compat.py +2 -2
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/frontmatter.py +9 -1
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/mcp_server.py +24 -1
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/version_detect.py +50 -15
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_check.py +56 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_compat.py +12 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_frontmatter.py +43 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_guide_structure.py +9 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_mcp_server.py +31 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_mcp_unit.py +112 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_version_detect.py +86 -6
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/.github/workflows/check-python-release.yml +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/.github/workflows/publish.yml +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/.gitignore +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/CONTRIBUTING.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/LICENSE +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/LICENSE-MIT +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/SECURITY.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/edge-cases/opus48_multiline.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/edge-cases/valid_alt_patterns.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/pyproject.toml +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/app.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/config.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/crawler.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/models.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/scanner.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-modern/src/utils.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/pyproject.toml +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/setup.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/app.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/config.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/crawler.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/models.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/scanner.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-a-outdated/src/utils.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-b-modern/myapp/models.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-b-modern/myapp/views.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-b-outdated/myapp/models.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-b-outdated/myapp/views.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-c-modern/tests/test_calculator.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/fixtures/variant-c-outdated/tests/test_calculator.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/mcp-config.json +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v2.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v3-mcp.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v3.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v4-a.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v4-b.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt-v4-c.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompt.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-a-detailed.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-a-normal.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-a-terse.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-b-detailed.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-b-normal.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-b-terse.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-c-detailed.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-c-normal.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/prompts/v5-c-terse.txt +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/run-mcp.sh +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/run-v4.sh +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/run-v5.sh +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/run.sh +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/score-v2.sh +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/score-v3.sh +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/score-v4.sh +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/score.sh +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/score_v5.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/bench/test-scorer.sh +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/docs/benchmark-evaluation.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/docs/benchmark-procedure.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/docs/benchmark-v5.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/rules/modern-python.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/SKILL.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/async/async-timeout-context.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/async/exception-groups.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/async/taskgroup-over-gather.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/data-structures/dataclass-modern.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/data-structures/dict-merge-operator.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/data-structures/match-case-patterns.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/django/django-async-views.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/django/django-check-constraints.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/django/django-json-field.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/fastapi/fastapi-annotated-depends.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/fastapi/fastapi-lifespan.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/fastapi/fastapi-typed-state.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/httpx/httpx-async-client-reuse.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/httpx/httpx-streaming.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-config.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-model-api.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-serialization.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pydantic/pydantic-v2-validators.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pytest/pytest-parametrize.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pytest/pytest-raises-match.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/pytest/pytest-tmp-path.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-2-style.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-async-session.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/sqlalchemy/sqlalchemy-mapped-column.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/stdlib/datetime-utc.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/stdlib/pathlib-over-os-path.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/stdlib/removeprefix-removesuffix.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/stdlib/template-strings.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/stdlib/tomllib-builtin.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/toolchain/no-pickle.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/toolchain/pyproject-toml-over-setup.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/toolchain/ruff-over-flake8.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/toolchain/safe-subprocess.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/toolchain/uv-over-pip.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/deferred-annotations.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/override-decorator.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/paramspec-decorators.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/type-parameter-syntax.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/typeis-vs-typeguard.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/union-syntax.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/skills/modern-python-guidance/guides/typing/use-builtin-generics.md +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/__main__.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/cli.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/guide_index.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/retrieve.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/search.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/setup_cmd.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/uninstall_cmd.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_cli_integration.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_cli_unit.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_guide_index.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_retrieve.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_scorer_v5.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_search.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_setup.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_skill_sync.py +0 -0
- {modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/tests/test_uninstall.py +0 -0
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.4.4] — 2026-06-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Poetry constraint parsing: `detect_version()` now extracts the minimum Python version from `[tool.poetry.dependencies].python` instead of only logging a warning. Supported forms: caret (`^3.10`), tilde (`~3.11`), PEP 440 (`>=3.10,<3.14`), and dict-form (`{version = "^3.10"}`). Union operators (`||`) and unsupported formats warn and fall through to `.python-version` / default. (closes #95)
|
|
10
|
+
- Python 3.14 added to CI test matrix as a regular (non-allowed-failure) entry. Python 3.14 has been GA since 2025-10-07; pyproject.toml classifiers already declared support. (closes #94)
|
|
11
|
+
- 12 new tests (999 total).
|
|
12
|
+
|
|
13
|
+
## [0.4.3] — 2026-06-04
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- MCP server crash on malformed `params` and `arguments`: non-dict values now return JSON-RPC -32602 instead of `TypeError`. `serve()` catch-all returns -32603 on unexpected errors. Notification messages (no `id`) are silently dropped per JSON-RPC 2.0 spec. (closes #91)
|
|
18
|
+
- `mpg check` false positives in multi-line docstrings: `check_file()` now uses `tokenize` to identify multi-line string token ranges and skips those lines. Single-line strings on code lines are still scanned. Tokenize failure (syntax errors, indentation errors) falls back to scanning all lines. (closes #92)
|
|
19
|
+
- Invalid PEP 440 specifiers in guide `python:` field silently treated as all-version compatible: `_build_meta()` now validates with `SpecifierSet` at parse time, raising `FrontmatterError`. Runtime `version_compatible()` narrows except from `(InvalidSpecifier, Exception)` to `(InvalidSpecifier, InvalidVersion)`. (closes #93)
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- 53 new tests (987 total).
|
|
24
|
+
|
|
5
25
|
## [0.4.2] — 2026-06-04
|
|
6
26
|
|
|
7
27
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: modern-python-guidance
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
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
|
|
@@ -166,8 +166,9 @@ Guides specify their minimum Python version. The CLI auto-detects your project's
|
|
|
166
166
|
|
|
167
167
|
1. `--python-version` flag
|
|
168
168
|
2. `pyproject.toml` `requires-python`
|
|
169
|
-
3.
|
|
170
|
-
4.
|
|
169
|
+
3. `pyproject.toml` Poetry `python` constraint (`^3.10`, `~3.11`, `>=3.10,<3.14`)
|
|
170
|
+
4. `.python-version` file
|
|
171
|
+
5. Default: 3.11
|
|
171
172
|
|
|
172
173
|
```bash
|
|
173
174
|
# Only shows guides compatible with Python 3.9
|
|
@@ -134,8 +134,9 @@ Guides specify their minimum Python version. The CLI auto-detects your project's
|
|
|
134
134
|
|
|
135
135
|
1. `--python-version` flag
|
|
136
136
|
2. `pyproject.toml` `requires-python`
|
|
137
|
-
3.
|
|
138
|
-
4.
|
|
137
|
+
3. `pyproject.toml` Poetry `python` constraint (`^3.10`, `~3.11`, `>=3.10,<3.14`)
|
|
138
|
+
4. `.python-version` file
|
|
139
|
+
5. Default: 3.11
|
|
139
140
|
|
|
140
141
|
```bash
|
|
141
142
|
# Only shows guides compatible with Python 3.9
|
|
@@ -150,10 +150,9 @@ Fuzzy results are marked with `fuzzy: true` in the output.
|
|
|
150
150
|
|
|
151
151
|
1. `--python-version` CLI flag (explicit override)
|
|
152
152
|
2. `pyproject.toml` `[project].requires-python` (PEP 621)
|
|
153
|
-
3.
|
|
154
|
-
4.
|
|
155
|
-
|
|
156
|
-
Poetry's caret syntax (`^3.11`) is detected but not parsed — the tool logs a warning and suggests using `--python-version` or adding `[project].requires-python`.
|
|
153
|
+
3. `pyproject.toml` `[tool.poetry.dependencies].python` — caret (`^3.10`), tilde (`~3.11`), and PEP 440 (`>=3.10,<3.14`) constraints are parsed to extract the minimum version. Dict-form (`{version = "^3.10"}`) is also supported. Union operators (`||`) are not supported and fall through with a warning.
|
|
154
|
+
4. `.python-version` file (pyenv/asdf convention)
|
|
155
|
+
5. Default: `3.11`
|
|
157
156
|
|
|
158
157
|
## Output format
|
|
159
158
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "modern-python-guidance"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.4"
|
|
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"
|
{modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/check.py
RENAMED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import io
|
|
5
6
|
import re
|
|
7
|
+
import tokenize as _tokenize
|
|
6
8
|
from dataclasses import dataclass
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
|
|
@@ -33,6 +35,23 @@ class CheckMatch:
|
|
|
33
35
|
snippet: str
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
def _string_lines(text: str) -> frozenset[int]:
|
|
39
|
+
"""Line numbers belonging to multi-line STRING tokens (docstrings etc.)."""
|
|
40
|
+
skip: set[int] = set()
|
|
41
|
+
try:
|
|
42
|
+
tokens = _tokenize.generate_tokens(io.StringIO(text).readline)
|
|
43
|
+
string_types = {_tokenize.STRING}
|
|
44
|
+
fstring_mid = getattr(_tokenize, "FSTRING_MIDDLE", None)
|
|
45
|
+
if fstring_mid is not None:
|
|
46
|
+
string_types.add(fstring_mid)
|
|
47
|
+
for tok in tokens:
|
|
48
|
+
if tok.type in string_types and tok.end[0] > tok.start[0]:
|
|
49
|
+
skip.update(range(tok.start[0], tok.end[0] + 1))
|
|
50
|
+
except (_tokenize.TokenError, SyntaxError):
|
|
51
|
+
return frozenset()
|
|
52
|
+
return frozenset(skip)
|
|
53
|
+
|
|
54
|
+
|
|
36
55
|
def check_file(
|
|
37
56
|
path: Path,
|
|
38
57
|
index: GuideIndex,
|
|
@@ -48,8 +67,11 @@ def check_file(
|
|
|
48
67
|
if not patterns:
|
|
49
68
|
return []
|
|
50
69
|
|
|
70
|
+
skip = _string_lines(text)
|
|
51
71
|
matches: list[CheckMatch] = []
|
|
52
72
|
for lineno, line in enumerate(text.splitlines(), 1):
|
|
73
|
+
if lineno in skip:
|
|
74
|
+
continue
|
|
53
75
|
stripped = line.strip()
|
|
54
76
|
if not stripped or stripped.startswith("#"):
|
|
55
77
|
continue
|
{modern_python_guidance-0.4.2 → modern_python_guidance-0.4.4}/src/modern_python_guidance/compat.py
RENAMED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import re
|
|
6
6
|
|
|
7
7
|
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
8
|
-
from packaging.version import Version
|
|
8
|
+
from packaging.version import InvalidVersion, Version
|
|
9
9
|
|
|
10
10
|
VERSION_RE = re.compile(r"^\d+\.\d+$")
|
|
11
11
|
|
|
@@ -14,7 +14,7 @@ def version_compatible(guide_python: str, target: str) -> bool:
|
|
|
14
14
|
try:
|
|
15
15
|
spec = SpecifierSet(guide_python)
|
|
16
16
|
return Version(f"{target}.0") in spec
|
|
17
|
-
except (InvalidSpecifier,
|
|
17
|
+
except (InvalidSpecifier, InvalidVersion):
|
|
18
18
|
return True
|
|
19
19
|
|
|
20
20
|
|
|
@@ -13,6 +13,8 @@ import re
|
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
17
|
+
|
|
16
18
|
_KEY_RE = re.compile(r"^([a-z][a-z0-9_-]*)\s*:\s*(.*)")
|
|
17
19
|
_LIST_ITEM_RE = re.compile(r"^ - (.+)")
|
|
18
20
|
_FENCE = "---"
|
|
@@ -123,6 +125,12 @@ def _build_meta(raw: dict[str, Any]) -> GuideMeta:
|
|
|
123
125
|
if isinstance(raw[str_field], list):
|
|
124
126
|
raise FrontmatterError(f"'{str_field}' must be a scalar value, not a list")
|
|
125
127
|
|
|
128
|
+
python_value = str(raw["python"])
|
|
129
|
+
try:
|
|
130
|
+
SpecifierSet(python_value)
|
|
131
|
+
except InvalidSpecifier as e:
|
|
132
|
+
raise FrontmatterError(f"invalid python specifier '{python_value}': {e}") from e
|
|
133
|
+
|
|
126
134
|
freq = raw["frequency"]
|
|
127
135
|
if freq not in VALID_FREQUENCIES:
|
|
128
136
|
raise FrontmatterError(f"invalid frequency '{freq}', must be one of {VALID_FREQUENCIES}")
|
|
@@ -171,7 +179,7 @@ def _build_meta(raw: dict[str, Any]) -> GuideMeta:
|
|
|
171
179
|
category=str(raw["category"]),
|
|
172
180
|
layer=layer,
|
|
173
181
|
tags=[str(t) for t in tags],
|
|
174
|
-
python=
|
|
182
|
+
python=python_value,
|
|
175
183
|
frequency=freq,
|
|
176
184
|
aliases=[str(a) for a in aliases_raw],
|
|
177
185
|
pep=pep,
|
|
@@ -347,6 +347,15 @@ def _handle_request(msg: dict) -> dict | None:
|
|
|
347
347
|
params = msg.get("params", {})
|
|
348
348
|
is_notification = "id" not in msg
|
|
349
349
|
|
|
350
|
+
if not isinstance(params, dict):
|
|
351
|
+
if not is_notification:
|
|
352
|
+
return _error_response(
|
|
353
|
+
req_id,
|
|
354
|
+
-32602,
|
|
355
|
+
f"Invalid params: expected object, got {type(params).__name__}",
|
|
356
|
+
)
|
|
357
|
+
return None
|
|
358
|
+
|
|
350
359
|
if method == "notifications/initialized":
|
|
351
360
|
return None
|
|
352
361
|
|
|
@@ -368,6 +377,13 @@ def _handle_request(msg: dict) -> dict | None:
|
|
|
368
377
|
if method == "tools/call":
|
|
369
378
|
tool_name = params.get("name", "")
|
|
370
379
|
arguments = params.get("arguments", {})
|
|
380
|
+
if not isinstance(arguments, dict):
|
|
381
|
+
result = _error_response(
|
|
382
|
+
req_id,
|
|
383
|
+
-32602,
|
|
384
|
+
f"Invalid arguments: expected object, got {type(arguments).__name__}",
|
|
385
|
+
)
|
|
386
|
+
return None if is_notification else result
|
|
371
387
|
tool_result = _handle_tool_call(tool_name, arguments)
|
|
372
388
|
result = _result_response(req_id, tool_result)
|
|
373
389
|
return None if is_notification else result
|
|
@@ -390,7 +406,14 @@ def serve(*, stdin: object = None, stdout: object = None) -> None:
|
|
|
390
406
|
if msg is None:
|
|
391
407
|
break
|
|
392
408
|
|
|
393
|
-
|
|
409
|
+
try:
|
|
410
|
+
response = _handle_request(msg)
|
|
411
|
+
except Exception:
|
|
412
|
+
log.exception("Unexpected error handling request")
|
|
413
|
+
if isinstance(msg, dict) and "id" in msg:
|
|
414
|
+
response = _error_response(msg.get("id"), -32603, "Internal error")
|
|
415
|
+
else:
|
|
416
|
+
continue
|
|
394
417
|
if response is not None:
|
|
395
418
|
_write_message(response, stdout)
|
|
396
419
|
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
Precedence chain:
|
|
4
4
|
1. CLI --python-version flag (explicit override)
|
|
5
5
|
2. pyproject.toml [project].requires-python (PEP 621)
|
|
6
|
-
3. .python
|
|
7
|
-
4.
|
|
6
|
+
3. pyproject.toml [tool.poetry.dependencies].python (caret/tilde/PEP 440)
|
|
7
|
+
4. .python-version file (pyenv/asdf)
|
|
8
|
+
5. Default: 3.11
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
from __future__ import annotations
|
|
@@ -24,6 +25,7 @@ DEFAULT_VERSION = "3.11"
|
|
|
24
25
|
_KNOWN_MINORS = [Version(f"3.{minor}") for minor in range(7, 20)]
|
|
25
26
|
|
|
26
27
|
_POETRY_CARET_RE = re.compile(r"\^(\d+\.\d+)")
|
|
28
|
+
_POETRY_TILDE_RE = re.compile(r"~(\d+\.\d+)")
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
def detect_version(
|
|
@@ -68,21 +70,54 @@ def _from_pyproject(path: Path) -> str | None:
|
|
|
68
70
|
|
|
69
71
|
poetry_python = data.get("tool", {}).get("poetry", {}).get("dependencies", {}).get("python")
|
|
70
72
|
if poetry_python:
|
|
71
|
-
|
|
72
|
-
if
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
73
|
+
result = _parse_poetry_python(poetry_python)
|
|
74
|
+
if result is not None:
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_poetry_python(value: str | dict) -> str | None:
|
|
81
|
+
if isinstance(value, dict):
|
|
82
|
+
value = value.get("version")
|
|
83
|
+
if not value:
|
|
84
|
+
log.warning("Poetry python constraint has no 'version' key")
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
if not isinstance(value, str):
|
|
88
|
+
log.warning("Poetry python constraint has unexpected type %s", type(value).__name__)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
poetry_str = value
|
|
92
|
+
|
|
93
|
+
if "||" in poetry_str:
|
|
94
|
+
log.warning(
|
|
95
|
+
"Poetry union constraint '%s' is not supported. "
|
|
96
|
+
"Use --python-version or add [project].requires-python.",
|
|
97
|
+
poetry_str,
|
|
98
|
+
)
|
|
84
99
|
return None
|
|
85
100
|
|
|
101
|
+
m = _POETRY_CARET_RE.search(poetry_str)
|
|
102
|
+
if m:
|
|
103
|
+
log.info("Parsed Poetry caret constraint '%s' → %s", poetry_str, m.group(1))
|
|
104
|
+
return m.group(1)
|
|
105
|
+
|
|
106
|
+
m = _POETRY_TILDE_RE.search(poetry_str)
|
|
107
|
+
if m:
|
|
108
|
+
log.info("Parsed Poetry tilde constraint '%s' → %s", poetry_str, m.group(1))
|
|
109
|
+
return m.group(1)
|
|
110
|
+
|
|
111
|
+
result = _min_version_from_specifier(poetry_str)
|
|
112
|
+
if result is not None:
|
|
113
|
+
log.info("Parsed Poetry PEP 440 constraint '%s' → %s", poetry_str, result)
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
log.warning(
|
|
117
|
+
"Poetry python constraint '%s' detected but not supported. "
|
|
118
|
+
"Use --python-version or add [project].requires-python.",
|
|
119
|
+
poetry_str,
|
|
120
|
+
)
|
|
86
121
|
return None
|
|
87
122
|
|
|
88
123
|
|
|
@@ -276,6 +276,62 @@ class TestEdgeCases:
|
|
|
276
276
|
assert len(patterns) == 0
|
|
277
277
|
|
|
278
278
|
|
|
279
|
+
class TestStringLineFiltering:
|
|
280
|
+
def test_docstring_not_matched(self, tmp_path: Path, index: GuideIndex):
|
|
281
|
+
p = tmp_path / "docstring.py"
|
|
282
|
+
p.write_text(
|
|
283
|
+
"def example():\n"
|
|
284
|
+
' """Use datetime.utcnow() for timestamps.\n'
|
|
285
|
+
"\n"
|
|
286
|
+
" Also from typing import List is common.\n"
|
|
287
|
+
' """\n'
|
|
288
|
+
" return 1\n",
|
|
289
|
+
encoding="utf-8",
|
|
290
|
+
)
|
|
291
|
+
matches = check_file(p, index)
|
|
292
|
+
assert matches == []
|
|
293
|
+
|
|
294
|
+
def test_inline_string_on_code_line_still_matched(self, tmp_path: Path, index: GuideIndex):
|
|
295
|
+
p = tmp_path / "inline.py"
|
|
296
|
+
p.write_text(
|
|
297
|
+
'from typing import List\nx = "some string"\n',
|
|
298
|
+
encoding="utf-8",
|
|
299
|
+
)
|
|
300
|
+
matches = check_file(p, index)
|
|
301
|
+
ids = {m.guide_id for m in matches}
|
|
302
|
+
assert "use-builtin-generics" in ids
|
|
303
|
+
|
|
304
|
+
def test_tokenize_failure_falls_back(self, tmp_path: Path, index: GuideIndex):
|
|
305
|
+
p = tmp_path / "broken.py"
|
|
306
|
+
p.write_text(
|
|
307
|
+
'from typing import List\nx = """unterminated\n',
|
|
308
|
+
encoding="utf-8",
|
|
309
|
+
)
|
|
310
|
+
matches = check_file(p, index)
|
|
311
|
+
ids = {m.guide_id for m in matches}
|
|
312
|
+
assert "use-builtin-generics" in ids
|
|
313
|
+
|
|
314
|
+
def test_indentation_error_falls_back(self, tmp_path: Path, index: GuideIndex):
|
|
315
|
+
p = tmp_path / "indent.py"
|
|
316
|
+
p.write_text(
|
|
317
|
+
"from typing import List\nif True:\n x = 1\n y = 2\n",
|
|
318
|
+
encoding="utf-8",
|
|
319
|
+
)
|
|
320
|
+
matches = check_file(p, index)
|
|
321
|
+
ids = {m.guide_id for m in matches}
|
|
322
|
+
assert "use-builtin-generics" in ids
|
|
323
|
+
|
|
324
|
+
def test_single_line_string_not_skipped(self, tmp_path: Path, index: GuideIndex):
|
|
325
|
+
p = tmp_path / "singleline.py"
|
|
326
|
+
p.write_text(
|
|
327
|
+
"from typing import List # 'example'\n",
|
|
328
|
+
encoding="utf-8",
|
|
329
|
+
)
|
|
330
|
+
matches = check_file(p, index)
|
|
331
|
+
ids = {m.guide_id for m in matches}
|
|
332
|
+
assert "use-builtin-generics" in ids
|
|
333
|
+
|
|
334
|
+
|
|
279
335
|
class TestFreqRank:
|
|
280
336
|
def test_all_frequencies_covered(self):
|
|
281
337
|
assert "high" in FREQ_RANK
|
|
@@ -33,6 +33,18 @@ class TestVersionCompatible:
|
|
|
33
33
|
def test_patch_level_target(self):
|
|
34
34
|
assert version_compatible(">=3.9", "3.11.1") is True
|
|
35
35
|
|
|
36
|
+
def test_unexpected_error_propagates(self, monkeypatch):
|
|
37
|
+
def _boom(self, *a, **kw):
|
|
38
|
+
raise RuntimeError("unexpected")
|
|
39
|
+
|
|
40
|
+
fake = type("Boom", (), {"__init__": _boom, "__contains__": _boom})
|
|
41
|
+
monkeypatch.setattr(
|
|
42
|
+
"modern_python_guidance.compat.SpecifierSet",
|
|
43
|
+
fake,
|
|
44
|
+
)
|
|
45
|
+
with pytest.raises(RuntimeError, match="unexpected"):
|
|
46
|
+
version_compatible(">=3.9", "3.12")
|
|
47
|
+
|
|
36
48
|
|
|
37
49
|
class TestTokenEstimate:
|
|
38
50
|
def test_empty_string(self):
|
|
@@ -346,6 +346,49 @@ Body.
|
|
|
346
346
|
parse_frontmatter(text)
|
|
347
347
|
|
|
348
348
|
|
|
349
|
+
def test_invalid_python_specifier_rejected():
|
|
350
|
+
text = """\
|
|
351
|
+
---
|
|
352
|
+
id: bad-spec
|
|
353
|
+
title: Bad Specifier
|
|
354
|
+
category: typing
|
|
355
|
+
layer: 1
|
|
356
|
+
tags:
|
|
357
|
+
- test
|
|
358
|
+
python: "not-a-valid-specifier"
|
|
359
|
+
frequency: high
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
Body.
|
|
363
|
+
"""
|
|
364
|
+
with pytest.raises(FrontmatterError, match="invalid python specifier"):
|
|
365
|
+
parse_frontmatter(text)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@pytest.mark.parametrize(
|
|
369
|
+
"specifier",
|
|
370
|
+
[">=3.9", ">=3.11", ">=3.9,<3.13", ">=3.11,<3.14", ""],
|
|
371
|
+
ids=["ge39", "ge311", "range39_13", "range311_14", "empty"],
|
|
372
|
+
)
|
|
373
|
+
def test_valid_python_specifiers_accepted(specifier: str):
|
|
374
|
+
text = f"""\
|
|
375
|
+
---
|
|
376
|
+
id: valid-spec
|
|
377
|
+
title: Valid Specifier
|
|
378
|
+
category: typing
|
|
379
|
+
layer: 1
|
|
380
|
+
tags:
|
|
381
|
+
- test
|
|
382
|
+
python: "{specifier}"
|
|
383
|
+
frequency: high
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
Body.
|
|
387
|
+
"""
|
|
388
|
+
meta, _ = parse_frontmatter(text)
|
|
389
|
+
assert meta.python == specifier
|
|
390
|
+
|
|
391
|
+
|
|
349
392
|
def test_detect_patterns_non_list_rejected():
|
|
350
393
|
text = """\
|
|
351
394
|
---
|
|
@@ -210,6 +210,15 @@ class TestDetectPatterns:
|
|
|
210
210
|
)
|
|
211
211
|
|
|
212
212
|
|
|
213
|
+
class TestPythonSpecifiers:
|
|
214
|
+
def test_all_guides_have_valid_specifiers(self, guide_file: Path):
|
|
215
|
+
from packaging.specifiers import SpecifierSet
|
|
216
|
+
|
|
217
|
+
text = guide_file.read_text(encoding="utf-8")
|
|
218
|
+
meta, _ = parse_frontmatter(text)
|
|
219
|
+
SpecifierSet(meta.python)
|
|
220
|
+
|
|
221
|
+
|
|
213
222
|
class TestGuideInventory:
|
|
214
223
|
def test_no_duplicate_ids(self):
|
|
215
224
|
seen: dict[str, Path] = {}
|
|
@@ -386,6 +386,37 @@ class TestProtocol:
|
|
|
386
386
|
assert result["isError"] is True
|
|
387
387
|
|
|
388
388
|
|
|
389
|
+
class TestMalformedParams:
|
|
390
|
+
def test_non_dict_params_returns_error_and_server_continues(self):
|
|
391
|
+
responses = _run_mcp(
|
|
392
|
+
{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": "bad"},
|
|
393
|
+
{"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}},
|
|
394
|
+
)
|
|
395
|
+
assert len(responses) == 2
|
|
396
|
+
assert responses[0]["error"]["code"] == -32602
|
|
397
|
+
assert responses[0]["id"] == 1
|
|
398
|
+
assert "expected object" in responses[0]["error"]["message"]
|
|
399
|
+
assert "protocolVersion" in responses[1]["result"]
|
|
400
|
+
|
|
401
|
+
def test_non_dict_arguments_returns_error_and_server_continues(self):
|
|
402
|
+
responses = _run_mcp(
|
|
403
|
+
*_init_handshake(),
|
|
404
|
+
{
|
|
405
|
+
"jsonrpc": "2.0",
|
|
406
|
+
"id": 1,
|
|
407
|
+
"method": "tools/call",
|
|
408
|
+
"params": {"name": "search_guides", "arguments": "not-a-dict"},
|
|
409
|
+
},
|
|
410
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
|
411
|
+
)
|
|
412
|
+
error = responses[1].get("error")
|
|
413
|
+
assert error is not None
|
|
414
|
+
assert error["code"] == -32602
|
|
415
|
+
assert "expected object" in error["message"]
|
|
416
|
+
assert responses[2]["id"] == 2
|
|
417
|
+
assert "tools" in responses[2]["result"]
|
|
418
|
+
|
|
419
|
+
|
|
389
420
|
class TestStdoutPollution:
|
|
390
421
|
def test_no_non_jsonrpc_output(self):
|
|
391
422
|
stdin_data = _build_session(
|
|
@@ -362,6 +362,49 @@ class TestHandleRequest:
|
|
|
362
362
|
assert resp["error"]["code"] == -32600
|
|
363
363
|
assert "expected JSON object" in resp["error"]["message"]
|
|
364
364
|
|
|
365
|
+
@pytest.mark.parametrize(
|
|
366
|
+
"params",
|
|
367
|
+
[None, "hello", "", [1, 2], 42, 0, 3.14, True, False],
|
|
368
|
+
ids=["none", "string", "empty-string", "list", "int", "zero", "float", "true", "false"],
|
|
369
|
+
)
|
|
370
|
+
def test_non_dict_params_returns_invalid_params(self, params):
|
|
371
|
+
msg = {"jsonrpc": "2.0", "id": 10, "method": "initialize", "params": params}
|
|
372
|
+
resp = mcp._handle_request(msg)
|
|
373
|
+
assert resp["jsonrpc"] == "2.0"
|
|
374
|
+
assert resp["id"] == 10
|
|
375
|
+
assert resp["error"]["code"] == -32602
|
|
376
|
+
assert "expected object" in resp["error"]["message"]
|
|
377
|
+
|
|
378
|
+
def test_non_dict_params_notification_returns_none(self):
|
|
379
|
+
msg = {"jsonrpc": "2.0", "method": "initialize", "params": "bad"}
|
|
380
|
+
assert mcp._handle_request(msg) is None
|
|
381
|
+
|
|
382
|
+
def test_non_dict_arguments_returns_invalid_params(self):
|
|
383
|
+
msg = {
|
|
384
|
+
"jsonrpc": "2.0",
|
|
385
|
+
"id": 11,
|
|
386
|
+
"method": "tools/call",
|
|
387
|
+
"params": {"name": "search_guides", "arguments": "bad"},
|
|
388
|
+
}
|
|
389
|
+
resp = mcp._handle_request(msg)
|
|
390
|
+
assert resp["id"] == 11
|
|
391
|
+
assert resp["error"]["code"] == -32602
|
|
392
|
+
assert "expected object" in resp["error"]["message"]
|
|
393
|
+
|
|
394
|
+
def test_non_dict_arguments_notification_returns_none(self):
|
|
395
|
+
msg = {
|
|
396
|
+
"jsonrpc": "2.0",
|
|
397
|
+
"method": "tools/call",
|
|
398
|
+
"params": {"name": "search_guides", "arguments": [1, 2]},
|
|
399
|
+
}
|
|
400
|
+
assert mcp._handle_request(msg) is None
|
|
401
|
+
|
|
402
|
+
def test_unknown_method_with_bad_params_returns_invalid_params(self):
|
|
403
|
+
"""Centralized params check runs before method dispatch."""
|
|
404
|
+
msg = {"jsonrpc": "2.0", "id": 12, "method": "nonexistent", "params": 42}
|
|
405
|
+
resp = mcp._handle_request(msg)
|
|
406
|
+
assert resp["error"]["code"] == -32602
|
|
407
|
+
|
|
365
408
|
|
|
366
409
|
# --- serve ---
|
|
367
410
|
|
|
@@ -421,3 +464,72 @@ class TestServe:
|
|
|
421
464
|
assert responses[0]["id"] is None
|
|
422
465
|
assert responses[1]["id"] == 1
|
|
423
466
|
assert "protocolVersion" in responses[1]["result"]
|
|
467
|
+
|
|
468
|
+
def test_non_dict_params_recovery(self):
|
|
469
|
+
lines = (
|
|
470
|
+
json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": "bad"})
|
|
471
|
+
+ "\n"
|
|
472
|
+
+ json.dumps({"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}})
|
|
473
|
+
+ "\n"
|
|
474
|
+
)
|
|
475
|
+
sin = io.StringIO(lines)
|
|
476
|
+
sout = io.StringIO()
|
|
477
|
+
mcp.serve(stdin=sin, stdout=sout)
|
|
478
|
+
responses = [json.loads(line) for line in sout.getvalue().strip().split("\n")]
|
|
479
|
+
assert len(responses) == 2
|
|
480
|
+
assert responses[0]["error"]["code"] == -32602
|
|
481
|
+
assert responses[0]["id"] == 1
|
|
482
|
+
assert responses[1]["id"] == 2
|
|
483
|
+
assert "protocolVersion" in responses[1]["result"]
|
|
484
|
+
|
|
485
|
+
def test_catch_all_returns_internal_error(self, monkeypatch):
|
|
486
|
+
def boom(msg):
|
|
487
|
+
raise RuntimeError("unexpected")
|
|
488
|
+
|
|
489
|
+
monkeypatch.setattr(mcp, "_handle_request", boom)
|
|
490
|
+
req = {"jsonrpc": "2.0", "id": 99, "method": "initialize", "params": {}}
|
|
491
|
+
sin = io.StringIO(json.dumps(req) + "\n")
|
|
492
|
+
sout = io.StringIO()
|
|
493
|
+
mcp.serve(stdin=sin, stdout=sout)
|
|
494
|
+
resp = json.loads(sout.getvalue().strip())
|
|
495
|
+
assert resp["id"] == 99
|
|
496
|
+
assert resp["error"]["code"] == -32603
|
|
497
|
+
assert resp["error"]["message"] == "Internal error"
|
|
498
|
+
|
|
499
|
+
def test_catch_all_notification_no_response(self, monkeypatch):
|
|
500
|
+
def boom(msg):
|
|
501
|
+
raise RuntimeError("unexpected")
|
|
502
|
+
|
|
503
|
+
monkeypatch.setattr(mcp, "_handle_request", boom)
|
|
504
|
+
req = {"jsonrpc": "2.0", "method": "initialize", "params": {}}
|
|
505
|
+
sin = io.StringIO(json.dumps(req) + "\n")
|
|
506
|
+
sout = io.StringIO()
|
|
507
|
+
mcp.serve(stdin=sin, stdout=sout)
|
|
508
|
+
assert sout.getvalue() == ""
|
|
509
|
+
|
|
510
|
+
def test_catch_all_then_recovery(self, monkeypatch):
|
|
511
|
+
call_count = 0
|
|
512
|
+
|
|
513
|
+
original = mcp._handle_request
|
|
514
|
+
|
|
515
|
+
def boom_once(msg):
|
|
516
|
+
nonlocal call_count
|
|
517
|
+
call_count += 1
|
|
518
|
+
if call_count == 1:
|
|
519
|
+
raise RuntimeError("first call fails")
|
|
520
|
+
return original(msg)
|
|
521
|
+
|
|
522
|
+
monkeypatch.setattr(mcp, "_handle_request", boom_once)
|
|
523
|
+
lines = (
|
|
524
|
+
json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
|
|
525
|
+
+ "\n"
|
|
526
|
+
+ json.dumps({"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}})
|
|
527
|
+
+ "\n"
|
|
528
|
+
)
|
|
529
|
+
sin = io.StringIO(lines)
|
|
530
|
+
sout = io.StringIO()
|
|
531
|
+
mcp.serve(stdin=sin, stdout=sout)
|
|
532
|
+
responses = [json.loads(line) for line in sout.getvalue().strip().split("\n")]
|
|
533
|
+
assert len(responses) == 2
|
|
534
|
+
assert responses[0]["error"]["code"] == -32603
|
|
535
|
+
assert "protocolVersion" in responses[1]["result"]
|