Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,18 @@ forbidden_modules =
pycodestyle
mccabe

# Optional MCP dependency:
mcp

ignore_imports =
# These modules must import from flake8 to provide required API:
wemake_python_styleguide.checker -> flake8
wemake_python_styleguide.formatter -> flake8
wemake_python_styleguide.options.config -> flake8
# We disallow direct imports of our dependencies from anywhere, except:
wemake_python_styleguide.formatter -> pygments
# MCP server uses the `mcp` SDK:
wemake_python_styleguide.mcp.server -> mcp


[importlinter:contract:subapi-restrictions]
Expand Down
883 changes: 873 additions & 10 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ flake8 = "^7.3"
attrs = "*"
pygments = "^2.19"

mcp = { version = "^1.0", optional = true }

[tool.poetry.group.dev.dependencies]
pytest = "^9.0"
pytest-cov = "^7.0"
Expand Down Expand Up @@ -299,5 +301,9 @@ extend-exclude = [
]


[tool.poetry.extras]
mcp = ["mcp"]

[tool.poetry.scripts]
wps = "wemake_python_styleguide.cli.cli_app:main"
wps-mcp = "wemake_python_styleguide.mcp.server:main"
Empty file added tests/test_mcp/__init__.py
Empty file.
145 changes: 145 additions & 0 deletions tests/test_mcp/test_flake8_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Tests for the flake8 runner used by the MCP server."""

from wemake_python_styleguide.mcp.flake8_runner import (
_get_explanation, # noqa: PLC2701
_parse_violations, # noqa: PLC2701
lint_file,
run_flake8,
)


class TestParseViolationsBasic:
"""Test the output parser basic functionality."""

def test_empty_output(self):
"""No output means no violations."""
assert _parse_violations('', []) == []

def test_single_violation_code(self):
"""Parse a single violation line code."""
raw = 'WPS100|||1|||0|||Found wrong module name'
source_lines = ['# bad module']
parsed = _parse_violations(raw, source_lines)

assert len(parsed) == 1
first = parsed[0]
assert first['code'] == 'WPS100'
assert first['message'] == 'Found wrong module name'

def test_single_violation_location(self):
"""Parse a single violation line location."""
raw = 'WPS100|||1|||0|||Found wrong module name'
source_lines = ['# bad module']
first = _parse_violations(raw, source_lines)[0]

assert first['line'] == 1
assert first['column'] == 0
assert first['source_line'] == '# bad module'

def test_multiple_violations(self):
"""Parse multiple violation lines."""
raw = (
'WPS100|||1|||0|||First violation\n'
'WPS200|||2|||4|||Second violation'
)
source_lines = ['line one', ' line two']
parsed = _parse_violations(raw, source_lines)
assert len(parsed) == 2

def test_malformed_line_skipped(self):
"""Malformed lines are silently skipped."""
raw = 'this is not a valid line'
assert _parse_violations(raw, []) == []

def test_out_of_range_line_number(self):
"""Line numbers beyond source produce empty source_line."""
raw = 'WPS100|||999|||0|||some error'
first = _parse_violations(raw, ['only one line'])[0]
assert not first['source_line']


class TestParseViolationsEnrichment:
"""Test the output parser WPS enrichment."""

def test_wps_violation_has_link(self):
"""WPS violations include a documentation link."""
raw = 'WPS100|||1|||0|||msg'
first = _parse_violations(raw, ['x'])[0]
assert 'link' in first
assert 'WPS100' in first['link']

def test_non_wps_violation_has_no_explanation(self):
"""Non-WPS codes should not include explanation or link."""
raw = 'E501|||1|||80|||line too long'
first = _parse_violations(raw, ['x' * 100])[0]
assert 'explanation' not in first
assert 'link' not in first


class TestGetExplanation:
"""Test looking up violation docstrings."""

def test_known_violation(self):
"""Known WPS codes return a non-empty explanation."""
explanation = _get_explanation('WPS100')
assert explanation is not None
assert len(explanation) > 0

def test_unknown_violation(self):
"""Unknown codes return None."""
assert _get_explanation('WPS99999') is None

def test_invalid_code_format(self):
"""Non-numeric codes return None."""
assert _get_explanation('NOT_A_CODE') is None


class TestRunFlake8:
"""Integration tests for the flake8 runner."""

def test_clean_code(self):
"""Clean code produces zero violations."""
output = run_flake8('coordinate = 1\n')
assert output['total_violations'] == 0
assert output['violations'] == []

def test_code_with_violations_count(self):
"""Code that triggers WPS violations returns them."""
source = 'print("hello")\n'
output = run_flake8(source)
assert output['total_violations'] > 0

def test_code_with_violations_fields(self):
"""Each violation has the required fields."""
source = 'print("hello")\n'
violation = run_flake8(source)['violations'][0]
assert 'code' in violation
assert 'message' in violation
assert 'line' in violation
assert 'column' in violation
assert 'source_line' in violation

def test_custom_filename(self):
"""Custom filename is accepted without error."""
output = run_flake8('x = 1\n', filename='my_module.py')
assert isinstance(output['violations'], list)

def test_result_structure(self):
"""Result dict always has required keys."""
output = run_flake8('coordinate = 1\n')
assert 'violations' in output
assert 'total_violations' in output


class TestLintFile:
"""Integration tests for lint_file."""

def test_lint_file(self, tmp_path):
"""Lint a file on disk."""
test_file = tmp_path / 'test_module.py'
test_file.write_text('coordinate = 1\n')
output = lint_file(str(test_file))
assert 'file' in output
assert output['file'] == str(test_file)
assert 'violations' in output
assert 'total_violations' in output
112 changes: 112 additions & 0 deletions tests/test_mcp/test_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Tests for the MCP server tool definitions."""

import json

from wemake_python_styleguide.mcp.server import (
explain_rule,
lint,
lint_file,
mcp_server,
)


class TestLintTool:
"""Test the ``lint`` MCP tool."""

def test_clean_code_returns_no_violations(self):
"""Clean code returns empty violations list."""
output = json.loads(lint('coordinate = 1\n'))
assert output['total_violations'] == 0
assert output['violations'] == []

def test_bad_code_returns_violations(self):
"""Code with issues returns violations in JSON."""
output = json.loads(lint('print("hello")\n'))
assert output['total_violations'] > 0
assert isinstance(output['violations'], list)

def test_violation_has_required_fields(self):
"""Each violation has all required fields."""
output = json.loads(lint('print("hello")\n'))
assert output['total_violations'] > 0
violation = output['violations'][0]
for field in ('code', 'message', 'line', 'column', 'source_line'):
assert field in violation, f'Missing field: {field}'

def test_wps_violation_has_explanation(self):
"""WPS violations include an explanation and link."""
output = json.loads(lint('print("hello")\n'))
wps_violations = [
entry
for entry in output['violations']
if entry['code'].startswith('WPS')
]
assert len(wps_violations) > 0
for violation in wps_violations:
assert 'explanation' in violation
assert 'link' in violation

def test_output_is_valid_json(self):
"""Output is always valid JSON."""
raw = lint('coordinate = 1\n')
parsed = json.loads(raw)
assert isinstance(parsed, dict)

def test_custom_filename(self):
"""Custom filename parameter is accepted."""
output = json.loads(lint('coordinate = 1\n', filename='module.py'))
assert output['total_violations'] == 0


class TestLintFileTool:
"""Test the ``lint_file`` MCP tool."""

def test_lint_file_returns_json(self, tmp_path):
"""lint_file returns valid JSON with file key."""
test_file = tmp_path / 'example.py'
test_file.write_text('coordinate = 1\n')
output = json.loads(lint_file(str(test_file)))
assert 'file' in output
assert output['file'] == str(test_file)
assert 'violations' in output


class TestExplainRuleTool:
"""Test the ``explain_rule`` MCP tool."""

def test_known_rule(self):
"""Known rules return full documentation."""
output = json.loads(explain_rule('WPS100'))
assert output['code'] == 'WPS100'
assert 'name' in output
assert 'section' in output
assert 'explanation' in output
assert 'link' in output

def test_numeric_code(self):
"""Accepts numeric-only codes."""
output = json.loads(explain_rule('100'))
assert output['code'] == 'WPS100'

def test_unknown_rule(self):
"""Unknown rules return an error."""
output = json.loads(explain_rule('WPS99999'))
assert 'error' in output

def test_invalid_code(self):
"""Non-numeric codes return an error."""
output = json.loads(explain_rule('NOT_A_CODE'))
assert 'error' in output

def test_low_number_zero_padded(self):
"""Low-numbered codes are zero-padded to 3 digits."""
output = json.loads(explain_rule('0'))
assert output.get('code', '') == 'WPS000'


class TestMCPServerRegistration:
"""Test the MCP server object has tools registered."""

def test_server_name(self):
"""Server has the correct name."""
assert mcp_server.name == 'wemake-python-styleguide'
15 changes: 15 additions & 0 deletions wemake_python_styleguide/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
MCP (Model Context Protocol) server for ``wemake-python-styleguide``.

Provides tools for LLMs to lint Python source code
and explain WPS violation rules.

Usage::

wps-mcp

Or::

python -m wemake_python_styleguide.mcp

"""
6 changes: 6 additions & 0 deletions wemake_python_styleguide/mcp/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Allow running the MCP server via ``python -m``."""

from wemake_python_styleguide.mcp.server import main

if __name__ == '__main__':
main()
Loading
Loading