Chapter 10: Plugin Development and Extension
Haiyue
31min
Chapter 10: Plugin Development and Extension
Learning Objectives
- Master the basic principles of Pylint plugin development
- Learn to create custom checkers and rules
- Understand Pylint’s AST analysis mechanism
- Master plugin testing, packaging, and distribution
Knowledge Points
Pylint Plugin Architecture
🔄 正在渲染 Mermaid 图表...
Checker Types
| Checker Type | Interface | Purpose | Example |
|---|---|---|---|
| AST Checker | IAstroidChecker | Analyzes Abstract Syntax Tree | Checks function complexity |
| Raw Checker | IRawChecker | Analyzes raw code text | Checks code formatting |
| Token Checker | ITokenChecker | Analyzes lexical tokens | Checks identifier naming |
Example Code
Basic Plugin Structure
# pylint_custom_plugin.py
"""
Custom Pylint Plugin Example
This plugin demonstrates how to create custom checkers.
"""
from typing import TYPE_CHECKING, Optional
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.interfaces import IAstroidChecker
if TYPE_CHECKING:
from pylint.lint import PyLinter
class CustomChecker(BaseChecker):
"""Custom checker base class"""
# Checker interface
__implements__ = IAstroidChecker
# Plugin name
name = 'custom'
# Priority (lower number means higher priority)
priority = -1
# Message definitions
msgs = {
'C9001': (
'Function name should be more descriptive than "%s"',
'non-descriptive-function-name',
'Function names should be descriptive and meaningful.'
),
'W9001': (
'Function "%s" has too many print statements (%d)',
'too-many-prints',
'Functions should not have excessive print statements.'
),
'E9001': (
'Dangerous eval() usage detected',
'dangerous-eval-usage',
'Using eval() can be dangerous and should be avoided.'
),
}
# Configuration options
options = (
('max-print-statements', {
'default': 3,
'type': 'int',
'help': 'Maximum allowed print statements in a function'
}),
('min-function-name-length', {
'default': 3,
'type': 'int',
'help': 'Minimum length for function names'
}),
('descriptive-words', {
'default': ['get', 'set', 'create', 'update', 'delete', 'process'],
'type': 'csv',
'help': 'List of words considered descriptive for function names'
}),
)
def visit_functiondef(self, node: nodes.FunctionDef) -> None:
"""Check function definition"""
self._check_function_name(node)
self._check_print_statements(node)
def visit_call(self, node: nodes.Call) -> None:
"""Check function calls"""
self._check_eval_usage(node)
def _check_function_name(self, node: nodes.FunctionDef) -> None:
"""Check if function name is descriptive enough"""
func_name = node.name
# Skip special methods
if func_name.startswith('__') and func_name.endswith('__'):
return
# Check length
min_length = self.config.min_function_name_length
if len(func_name) < min_length:
self.add_message(
'non-descriptive-function-name',
node=node,
args=(func_name,)
)
return
# Check for descriptive words
descriptive_words = self.config.descriptive_words
func_lower = func_name.lower()
has_descriptive_word = any(
word in func_lower for word in descriptive_words
)
# If function name is too short and does not contain descriptive words
if len(func_name) <= 5 and not has_descriptive_word:
self.add_message(
'non-descriptive-function-name',
node=node,
args=(func_name,)
)
def _check_print_statements(self, node: nodes.FunctionDef) -> None:
"""Check the number of print statements in a function"""
print_count = 0
for child in node.nodes_of_class(nodes.Call):
if (
isinstance(child.func, nodes.Name) and
child.func.name == 'print'
):
print_count += 1
max_prints = self.config.max_print_statements
if print_count > max_prints:
self.add_message(
'too-many-prints',
node=node,
args=(node.name, print_count)
)
def _check_eval_usage(self, node: nodes.Call) -> None:
"""Check for dangerous eval() usage"""
if (
isinstance(node.func, nodes.Name) and
node.func.name == 'eval'
):
self.add_message(
'dangerous-eval-usage',
node=node
)
def register(linter: 'PyLinter') -> None:
"""Register plugin to Pylint"""
linter.register_checker(CustomChecker(linter))
Advanced Checker Example
# advanced_checker.py
"""
Advanced Custom Checker Example
Demonstrates more complex code analysis capabilities.
"""
import re
from typing import Set, Dict, List
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.interfaces import IAstroidChecker
class SecurityChecker(BaseChecker):
"""Security-related checker"""
__implements__ = IAstroidChecker
name = 'security'
priority = -1
msgs = {
'S9001': (
'Hardcoded password detected: "%s"',
'hardcoded-password',
'Passwords should not be hardcoded in source code.'
),
'S9002': (
'SQL injection risk in query: "%s"',
'sql-injection-risk',
'String formatting in SQL queries can lead to injection attacks.'
),
'S9003': (
'Insecure random usage detected',
'insecure-random',
'Use secrets module for cryptographic purposes.'
),
'S9004': (
'Shell injection risk in subprocess call',
'shell-injection-risk',
'Using shell=True with user input can be dangerous.'
),
}
options = (
('password-patterns', {
'default': ['password', 'passwd', 'pwd', 'secret', 'token'],
'type': 'csv',
'help': 'Patterns that might indicate hardcoded passwords'
}),
)
def __init__(self, linter):
super().__init__(linter)
self.hardcoded_strings: Set[str] = set()
self.sql_patterns = [
re.compile(r'SELECT\s+.*\s+FROM', re.IGNORECASE),
re.compile(r'INSERT\s+INTO', re.IGNORECASE),
re.compile(r'UPDATE\s+.*\s+SET', re.IGNORECASE),
re.compile(r'DELETE\s+FROM', re.IGNORECASE),
]
def visit_assign(self, node: nodes.Assign) -> None:
"""Check assignment statements"""
self._check_hardcoded_passwords(node)
def visit_call(self, node: nodes.Call) -> None:
"""Check function calls"""
self._check_sql_injection(node)
self._check_insecure_random(node)
self._check_shell_injection(node)
def _check_hardcoded_passwords(self, node: nodes.Assign) -> None:
"""Check for hardcoded passwords"""
for target in node.targets:
if isinstance(target, nodes.AssignName):
var_name = target.name.lower()
password_patterns = self.config.password_patterns
if any(pattern in var_name for pattern in password_patterns):
if isinstance(node.value, nodes.Const):
value = str(node.value.value)
if len(value) > 3: # Ignore values that are too short
self.add_message(
'hardcoded-password',
node=node,
args=(value[:20] + '...' if len(value) > 20 else value,)
)
def _check_sql_injection(self, node: nodes.Call) -> None:
"""Check for SQL injection risk"""
# Check common database execution methods
dangerous_methods = ['execute', 'executemany', 'query']
if (
isinstance(node.func, nodes.Attribute) and
node.func.attrname in dangerous_methods
):
for arg in node.args:
if isinstance(arg, nodes.BinOp):
# Check string concatenation
if self._contains_sql_keywords(arg):
self.add_message(
'sql-injection-risk',
node=node,
args=(arg.as_string()[:50] + '...',)
)
elif isinstance(arg, nodes.Call):
# Check string formatting
if (
isinstance(arg.func, nodes.Attribute) and
arg.func.attrname == 'format'
):
if self._contains_sql_keywords(arg.func.expr):
self.add_message(
'sql-injection-risk',
node=node,
args=(arg.as_string()[:50] + '...',)
)
def _contains_sql_keywords(self, node) -> bool:
"""Check if node contains SQL keywords"""
if isinstance(node, nodes.Const) and isinstance(node.value, str):
return any(pattern.search(node.value) for pattern in self.sql_patterns)
return False
def _check_insecure_random(self, node: nodes.Call) -> None:
"""Check for insecure random number usage"""
if isinstance(node.func, nodes.Attribute):
if (
node.func.attrname in ['random', 'randint', 'choice'] and
isinstance(node.func.expr, nodes.Name) and
node.func.expr.name == 'random'
):
# Check if used in a security-related context
if self._is_security_context(node):
self.add_message('insecure-random', node=node)
def _is_security_context(self, node) -> bool:
"""Determine if in a security-related context"""
# Simple heuristic check
parent = node.parent
while parent:
if isinstance(parent, nodes.FunctionDef):
func_name = parent.name.lower()
security_keywords = [
'password', 'token', 'secret', 'key', 'auth',
'session', 'csrf', 'nonce', 'salt'
]
if any(keyword in func_name for keyword in security_keywords):
return True
parent = parent.parent
return False
def _check_shell_injection(self, node: nodes.Call) -> None:
"""Check for shell injection risk"""
if (
isinstance(node.func, nodes.Attribute) and
node.func.attrname in ['run', 'call', 'check_output']
):
# Check if shell=True is used
for keyword in node.keywords:
if (
keyword.arg == 'shell' and
isinstance(keyword.value, nodes.Const) and
keyword.value.value is True
):
self.add_message('shell-injection-risk', node=node)
class PerformanceChecker(BaseChecker):
"""Performance-related checker"""
__implements__ = IAstroidChecker
name = 'performance'
priority = -1
msgs = {
'P9001': (
'Inefficient string concatenation in loop',
'inefficient-string-concat',
'Use list.join() instead of += for string concatenation in loops.'
),
'P9002': (
'List comprehension can be used instead of loop',
'loop-to-comprehension',
'List comprehensions are generally more efficient than equivalent loops.'
),
'P9003': (
'Consider using enumerate() instead of manual indexing',
'manual-indexing',
'enumerate() is more pythonic and often more efficient.'
),
}
def visit_for(self, node: nodes.For) -> None:
"""Check for loops"""
self._check_string_concatenation(node)
self._check_list_creation(node)
self._check_manual_indexing(node)
def _check_string_concatenation(self, node: nodes.For) -> None:
"""Check string concatenation in loops"""
for child in node.body:
if isinstance(child, nodes.AugAssign):
if child.op == '+=' and self._is_string_type(child.target):
self.add_message('inefficient-string-concat', node=child)
def _check_list_creation(self, node: nodes.For) -> None:
"""Check loops that can be replaced by list comprehensions"""
if (
len(node.body) == 1 and
isinstance(node.body[0], nodes.Expr) and
isinstance(node.body[0].value, nodes.Call)
):
call = node.body[0].value
if (
isinstance(call.func, nodes.Attribute) and
call.func.attrname == 'append'
):
self.add_message('loop-to-comprehension', node=node)
def _check_manual_indexing(self, node: nodes.For) -> None:
"""Check for manual indexing"""
if isinstance(node.iter, nodes.Call):
if (
isinstance(node.iter.func, nodes.Name) and
node.iter.func.name == 'range'
):
# Check if index is used to access sequence in loop body
for child in node.nodes_of_class(nodes.Subscript):
if (
isinstance(child.slice, nodes.Name) and
child.slice.name == node.target.name
):
self.add_message('manual-indexing', node=node)
break
def _is_string_type(self, node) -> bool:
"""Determine if the node is a string type"""
# Simplified type inference
if isinstance(node, nodes.Name):
return True # Requires more complex type inference logic
return False
def register(linter):
"""Register all checkers"""
linter.register_checker(SecurityChecker(linter))
linter.register_checker(PerformanceChecker(linter))
Raw Text Checker
# raw_checker.py
"""
Raw Text Checker Example
Analyzes the raw text of the source code instead of the AST.
"""
import re
from typing import List, Tuple
from pylint.checkers import BaseRawFileChecker
from pylint.interfaces import IRawChecker
class CodeStyleRawChecker(BaseRawFileChecker):
"""Code style raw checker"""
__implements__ = IRawChecker
name = 'code-style-raw'
priority = -1
msgs = {
'R9001': (
'Line too long (%d/%d characters)',
'line-too-long-custom',
'Lines should not exceed the specified length limit.'
),
'R9002': (
'Trailing whitespace detected',
'trailing-whitespace-custom',
'Lines should not have trailing whitespace.'
),
'R9003': (
'TODO comment found: "%s"',
'todo-comment',
'TODO comments should be tracked and resolved.'
),
'R9004': (
'Inconsistent indentation detected',
'inconsistent-indentation',
'Use consistent indentation (spaces or tabs, not mixed).'
),
'R9005': (
'Missing blank line after class/function definition',
'missing-blank-line',
'Add blank lines for better readability.'
),
}
options = (
('max-line-length-custom', {
'default': 88,
'type': 'int',
'help': 'Maximum allowed line length'
}),
('track-todos', {
'default': True,
'type': 'yn',
'help': 'Whether to report TODO comments'
}),
)
def process_module(self, node):
"""Process the raw text of the module"""
with open(node.file, 'r', encoding='utf-8') as f:
lines = f.readlines()
self._check_line_length(lines)
self._check_trailing_whitespace(lines)
self._check_todo_comments(lines)
self._check_indentation(lines)
self._check_blank_lines(lines)
def _check_line_length(self, lines: List[str]) -> None:
"""Check line length"""
max_length = self.config.max_line_length_custom
for line_num, line in enumerate(lines, 1):
# Remove newline characters
line_content = line.rstrip('\n\r')
if len(line_content) > max_length:
self.add_message(
'line-too-long-custom',
line=line_num,
args=(len(line_content), max_length)
)
def _check_trailing_whitespace(self, lines: List[str]) -> None:
"""Check for trailing whitespace"""
for line_num, line in enumerate(lines, 1):
# Check for trailing whitespace (excluding newline characters)
line_content = line.rstrip('\n\r')
if line_content != line_content.rstrip():
self.add_message('trailing-whitespace-custom', line=line_num)
def _check_todo_comments(self, lines: List[str]) -> None:
"""Check for TODO comments"""
if not self.config.track_todos:
return
todo_pattern = re.compile(r'#.*?TODO:?\s*(.+)', re.IGNORECASE)
for line_num, line in enumerate(lines, 1):
match = todo_pattern.search(line)
if match:
todo_text = match.group(1).strip()
self.add_message(
'todo-comment',
line=line_num,
args=(todo_text[:50] + '...' if len(todo_text) > 50 else todo_text,)
)
def _check_indentation(self, lines: List[str]) -> None:
"""Check for indentation consistency"""
has_spaces = False
has_tabs = False
for line_num, line in enumerate(lines, 1):
if line.strip(): # Skip empty lines
# Check leading whitespace
leading_whitespace = line[:len(line) - len(line.lstrip())]
if ' ' in leading_whitespace:
has_spaces = True
if '\t' in leading_whitespace:
has_tabs = True
# If both spaces and tabs are present
if has_spaces and has_tabs:
self.add_message('inconsistent-indentation', line=line_num)
return
def _check_blank_lines(self, lines: List[str]) -> None:
"""Check blank line convention"""
for line_num, line in enumerate(lines):
if line_num == 0:
continue
current_line = line.strip()
previous_line = lines[line_num - 1].strip()
# Check for blank line after class or function definition
if (
self._is_class_or_function_def(previous_line) and
current_line and
not self._is_docstring_start(current_line)
):
# Check if the next line is empty (if it exists)
if (
line_num + 1 < len(lines) and
lines[line_num + 1].strip()
):
self.add_message('missing-blank-line', line=line_num)
def _is_class_or_function_def(self, line: str) -> bool:
"""Check if it's a class or function definition"""
return (
line.startswith('def ')
or line.startswith('class ')
or line.startswith('async def ')
)
def _is_docstring_start(self, line: str) -> bool:
"""Check if it's the start of a docstring"""
return line.startswith('"""') or line.startswith("'''")
def register(linter):
"""Register checkers"""
linter.register_checker(CodeStyleRawChecker(linter))
Plugin Testing Framework
# test_custom_plugin.py
"""
Testing framework for custom plugins
"""
import tempfile
import textwrap
from pathlib import Path
from pylint.lint import PyLinter
from pylint.reporters.text import TextReporter
from pylint.testutils import CheckerTestCase, MessageTest
import io
class TestCustomChecker(CheckerTestCase):
"""Custom checker test class"""
CHECKER_CLASS = None # Subclass needs to set
def test_function_name_too_short(self):
"""Test for too short function name check"""
code = textwrap.dedent("""
def a():
pass
def b(x, y):
return x + y
"""
)
with self.assertAddsMessages(
MessageTest(
msg_id='non-descriptive-function-name',
line=1
),
MessageTest(
msg_id='non-descriptive-function-name',
line=4
)
):
self.checker.check_code(code)
def test_too_many_prints(self):
"""Test for too many print statements check"""
code = textwrap.dedent("""
def debug_function():
print("Debug 1")
print("Debug 2")
print("Debug 3")
print("Debug 4") # Exceeds default limit
"""
)
with self.assertAddsMessages(
MessageTest(
msg_id='too-many-prints',
line=1
)
):
self.checker.check_code(code)
def test_eval_usage(self):
"""Test for eval usage check"""
code = textwrap.dedent("""
def dangerous_function():
user_input = "1 + 1"
result = eval(user_input)
return result
"""
)
with self.assertAddsMessages(
MessageTest(
msg_id='dangerous-eval-usage',
line=3
)
):
self.checker.check_code(code)
class PluginTester:
"""Plugin testing tool"""
def __init__(self, plugin_module):
self.plugin_module = plugin_module
self.linter = PyLinter()
self.plugin_module.register(self.linter)
def test_code(self, code: str, expected_messages: List[str] = None) -> Dict:
"""Test code and return results"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write(code)
f.flush()
# Set reporter
output = io.StringIO()
reporter = TextReporter(output)
self.linter.set_reporter(reporter)
# Run check
self.linter.check([f.name])
# Get results
messages = output.getvalue()
score = self.linter.stats['global_note']
# Clean up temporary file
Path(f.name).unlink()
return {
'messages': messages,
'score': score,
'message_count': len(self.linter.stats['by_msg'])
}
def run_test_suite(self) -> None:
"""Run test suite"""
test_cases = [
{
'name': 'Short function name',
'code': 'def a(): pass',
'expected_msg_ids': ['non-descriptive-function-name']
},
{
'name': 'Many prints',
'code': '''
def test():
print(1)
print(2)
print(3)
print(4)
''',
'expected_msg_ids': ['too-many-prints']
},
{
'name': 'Eval usage',
'code': 'result = eval("1+1")',
'expected_msg_ids': ['dangerous-eval-usage']
}
]
for test_case in test_cases:
print(f"Running test: {test_case['name']}")
result = self.test_code(test_case['code'])
# Simple validation
for expected_id in test_case['expected_msg_ids']:
if expected_id in result['messages']:
print(f" ✅ Found expected message: {expected_id}")
else:
print(f" ❌ Missing expected message: {expected_id}")
print(f" Score: {result['score']:.2f}")
print()
# Usage example
def test_plugin():
"""Main function to test plugin"""
import pylint_custom_plugin
tester = PluginTester(pylint_custom_plugin)
tester.run_test_suite()
if __name__ == "__main__":
test_plugin()
Plugin Packaging and Distribution
# setup.py
"""
Plugin packaging configuration
"""
from setuptools import setup, find_packages
setup(
name='pylint-custom-plugin',
version='1.0.0',
description='Custom Pylint plugin with additional checks',
long_description=open('README.md').read(),
long_description_content_type='text/markdown',
author='Your Name',
author_email='your.email@example.com',
url='https://github.com/yourusername/pylint-custom-plugin',
packages=find_packages(),
python_requires='>=3.8',
install_requires=[
'pylint>=2.15.0',
'astroid>=2.12.0',
],
extras_require={
'dev': [
'pytest>=7.0.0',
'pytest-cov>=4.0.0',
'black>=22.0.0',
'isort>=5.10.0',
]
},
entry_points={
'pylint.plugins': [
'custom = pylint_custom_plugin',
'security = advanced_checker:SecurityChecker',
'performance = advanced_checker:PerformanceChecker',
'raw-style = raw_checker',
]
},
classifiers=[
'Development Status :: 4 - Beta',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Programming Language :: Python :: 3',
'Programming Language :: 3.8',
'Programming Language :: 3.9',
'Programming Language :: 3.10',
'Programming Language :: 3.11',
'Topic :: Software Development :: Quality Assurance',
],
keywords='pylint plugin code quality analysis',
)
# pyproject.toml
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pylint-custom-plugin"
version = "1.0.0"
description = "Custom Pylint plugin with additional checks"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [
{name = "Your Name", email = "your.email@example.com"}
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Topic :: Software Development :: Quality Assurance",
]
dependencies = [
"pylint>=2.15.0",
"astroid>=2.12.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"black>=22.0.0",
"isort>=5.10.0",
]
[project.urls]
Homepage = "https://github.com/yourusername/pylint-custom-plugin"
Repository = "https://github.com/yourusername/pylint-custom-plugin"
Issues = "https://github.com/yourusername/pylint-custom-plugin/issues"
[project.entry-points."pylint.plugins"]
custom = "pylint_custom_plugin"
security = "advanced_checker:SecurityChecker"
performance = "advanced_checker:PerformanceChecker"
raw-style = "raw_checker"
[tool.black]
line-length = 88
target-version = ['py38']
[tool.isort]
profile = "black"
line_length = 88
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
Plugin Usage Configuration
# .pylintrc - Configuration for using custom plugins
[MASTER]
# Load plugins
load-plugins = pylint_custom_plugin,
advanced_checker,
raw_checker
[MESSAGES CONTROL]
# Enable custom messages
enable = non-descriptive-function-name,
too-many-prints,
dangerous-eval-usage,
hardcoded-password,
sql-injection-risk,
insecure-random,
shell-injection-risk,
inefficient-string-concat,
loop-to-comprehension,
manual-indexing,
line-too-long-custom,
trailing-whitespace-custom,
todo-comment,
inconsistent-indentation,
missing-blank-line
[CUSTOM]
# Custom plugin configuration
max-print-statements = 2
min-function-name-length = 4
descriptive-words = get,set,create,update,delete,process,handle,manage
[SECURITY]
# Security check configuration
password-patterns = password,passwd,pwd,secret,token,key
[CODE-STYLE-RAW]
# Raw text check configuration
max-line-length-custom = 100
track-todos = yes
Best Practices for Plugin Development
- Clear Message Definitions: Provide clear, useful error messages and suggested fixes
- Configuration Options: Provide configurable options for checkers
- Performance Considerations: Avoid expensive operations within checkers
- Test Coverage: Write comprehensive tests for all checking rules
- Thorough Documentation: Provide detailed documentation for plugin usage
Caveats
- AST Understanding: Requires in-depth understanding of the Astroid AST structure
- Version Compatibility: Ensure plugin compatibility with different Pylint versions
- Error Handling: Properly handle exceptions to prevent plugin crashes
- Memory Management: Pay attention to avoiding memory leaks, especially when dealing with large codebases
By developing custom Pylint plugins, you can extend the scope of code quality checks to meet specific project or team needs, providing more precise code analysis capabilities.