0
# Plugin Development Framework
1
2
Framework for creating custom linters and extending pylama functionality. Pylama provides a standardized plugin system that enables integration of any code analysis tool through a consistent interface.
3
4
## Capabilities
5
6
### Modern Linter Base Class
7
8
Base class for creating new-style linter plugins with context-aware checking.
9
10
```python { .api }
11
class LinterV2(Linter):
12
"""
13
Modern linter base class with context-aware checking.
14
15
Attributes:
16
name: Optional[str] - Unique identifier for the linter
17
"""
18
19
name: Optional[str] = None
20
21
def run_check(self, ctx: RunContext):
22
"""
23
Check code using RunContext for error reporting.
24
25
Args:
26
ctx: RunContext containing file information and error collection
27
28
This method should:
29
1. Get linter-specific parameters from ctx.get_params(self.name)
30
2. Analyze the code in ctx.source or ctx.temp_filename
31
3. Report errors using ctx.push(source=self.name, **error_info)
32
33
Error info should include:
34
- lnum: int - Line number (1-based)
35
- col: int - Column number (1-based, default 0)
36
- text: str - Error message (stored as message attribute)
37
- etype: str - Error type ('E', 'W', 'F', etc.)
38
"""
39
```
40
41
### Legacy Linter Interface
42
43
Base class maintained for backward compatibility with older plugins.
44
45
```python { .api }
46
class Linter(metaclass=LinterMeta):
47
"""
48
Legacy linter base class for backward compatibility.
49
50
Attributes:
51
name: Optional[str] - Unique identifier for the linter
52
"""
53
54
name: Optional[str] = None
55
56
@classmethod
57
def add_args(cls, parser: ArgumentParser):
58
"""
59
Add linter-specific command line arguments.
60
61
Args:
62
parser: ArgumentParser to add options to
63
64
This method allows linters to register their own command line options
65
that will be available in the configuration system.
66
"""
67
68
def run(self, path: str, **meta) -> List[Dict[str, Any]]:
69
"""
70
Legacy linter run method.
71
72
Args:
73
path: File path to check
74
**meta: Additional metadata including 'code' and 'params'
75
76
Returns:
77
List[Dict]: List of error dictionaries with keys:
78
- lnum: int - Line number
79
- col: int - Column number (optional)
80
- text: str - Error message
81
- etype: str - Error type
82
"""
83
return []
84
```
85
86
### Execution Context Management
87
88
Context manager for linter execution with resource management and error collection.
89
90
```python { .api }
91
class RunContext:
92
"""
93
Execution context for linter operations with resource management.
94
95
Attributes:
96
errors: List[Error] - Collected errors
97
options: Optional[Namespace] - Configuration options
98
skip: bool - Whether to skip checking this file
99
ignore: Set[str] - Error codes to ignore
100
select: Set[str] - Error codes to select
101
linters: List[str] - Active linters for this file
102
filename: str - Original filename
103
"""
104
105
def __init__(
106
self,
107
filename: str,
108
source: str = None,
109
options: Namespace = None
110
):
111
"""
112
Initialize context for file checking.
113
114
Args:
115
filename: Path to file being checked
116
source: Source code string (if None, reads from file)
117
options: Configuration options
118
"""
119
120
def get_params(self, lname: str) -> Dict[str, Any]:
121
"""
122
Get linter-specific configuration parameters.
123
124
Args:
125
lname: Linter name
126
127
Returns:
128
Dict: Configuration parameters for the linter
129
130
Merges global options with linter-specific settings from
131
configuration sections like [pylama:lintername].
132
"""
133
134
def push(self, source: str, **err_info):
135
"""
136
Add error to the context.
137
138
Args:
139
source: Name of linter reporting the error
140
**err_info: Error information including:
141
- lnum: int - Line number
142
- col: int - Column number (default 0)
143
- text: str - Error message
144
- etype: str - Error type
145
146
Applies filtering based on ignore/select rules and file-specific
147
configuration before adding to errors list.
148
"""
149
150
def __enter__(self) -> 'RunContext':
151
"""Context manager entry."""
152
153
def __exit__(self, exc_type, exc_val, exc_tb):
154
"""Context manager exit with cleanup."""
155
```
156
157
### Plugin Registration
158
159
Automatic plugin registration system using metaclasses.
160
161
```python { .api }
162
class LinterMeta(type):
163
"""
164
Metaclass for automatic linter registration.
165
166
Automatically registers linters in the global LINTERS dictionary
167
when classes are defined with this metaclass.
168
"""
169
170
def __new__(mcs, name, bases, params):
171
"""
172
Register linter class if it has a name attribute.
173
174
Args:
175
name: Class name
176
bases: Base classes
177
params: Class attributes and methods
178
179
Returns:
180
type: Created linter class
181
"""
182
183
LINTERS: Dict[str, Type[LinterV2]] = {}
184
"""Global registry of available linters."""
185
```
186
187
## Built-in Linter Examples
188
189
### Pycodestyle Integration
190
191
```python
192
from pycodestyle import StyleGuide
193
from pylama.lint import LinterV2
194
from pylama.context import RunContext
195
196
class Linter(LinterV2):
197
"""Pycodestyle (PEP8) style checker integration."""
198
199
name = "pycodestyle"
200
201
def run_check(self, ctx: RunContext):
202
# Get linter-specific parameters
203
params = ctx.get_params("pycodestyle")
204
205
# Set max line length from global options
206
if ctx.options:
207
params.setdefault("max_line_length", ctx.options.max_line_length)
208
209
# Create style guide with parameters
210
style = StyleGuide(reporter=CustomReporter, **params)
211
212
# Check the file
213
style.check_files([ctx.temp_filename])
214
```
215
216
### Custom Linter Example
217
218
```python
219
import ast
220
from pylama.lint import LinterV2
221
from pylama.context import RunContext
222
223
class CustomLinter(LinterV2):
224
"""Example custom linter that checks for print statements."""
225
226
name = "no_print"
227
228
def run_check(self, ctx: RunContext):
229
try:
230
# Parse the source code
231
tree = ast.parse(ctx.source, ctx.filename)
232
233
# Walk the AST looking for print calls
234
for node in ast.walk(tree):
235
if (isinstance(node, ast.Call) and
236
isinstance(node.func, ast.Name) and
237
node.func.id == 'print'):
238
239
# Report error
240
ctx.push(
241
source=self.name,
242
lnum=node.lineno,
243
col=node.col_offset,
244
text="NP001 print statement found",
245
etype="W"
246
)
247
248
except SyntaxError as e:
249
# Report syntax error
250
ctx.push(
251
source=self.name,
252
lnum=e.lineno or 1,
253
col=e.offset or 0,
254
text=f"SyntaxError: {e.msg}",
255
etype="E"
256
)
257
```
258
259
## Usage Examples
260
261
### Creating a Custom Linter
262
263
```python
264
import re
265
from pylama.lint import LinterV2
266
from pylama.context import RunContext
267
268
class TodoLinter(LinterV2):
269
"""Linter that finds TODO comments."""
270
271
name = "todo"
272
273
@classmethod
274
def add_args(cls, parser):
275
parser.add_argument(
276
'--todo-keywords',
277
default='TODO,FIXME,XXX',
278
help='Comma-separated list of TODO keywords'
279
)
280
281
def run_check(self, ctx: RunContext):
282
params = ctx.get_params(self.name)
283
keywords = params.get('keywords', 'TODO,FIXME,XXX').split(',')
284
285
pattern = r'\b(' + '|'.join(keywords) + r')\b'
286
287
for i, line in enumerate(ctx.source.splitlines(), 1):
288
if re.search(pattern, line, re.IGNORECASE):
289
ctx.push(
290
source=self.name,
291
lnum=i,
292
col=0,
293
text=f"T001 TODO comment found: {line.strip()}",
294
etype="W"
295
)
296
```
297
298
### Plugin Entry Point
299
300
For external plugins, use setuptools entry points:
301
302
```python
303
# setup.py
304
setup(
305
name='pylama-custom',
306
entry_points={
307
'pylama.linter': [
308
'custom = my_plugin:CustomLinter',
309
],
310
},
311
)
312
```
313
314
### Configuration Integration
315
316
Custom linters automatically integrate with pylama's configuration system:
317
318
```ini
319
# pylama.ini
320
[pylama]
321
linters = pycodestyle,custom
322
323
[pylama:custom]
324
severity = warning
325
max_issues = 10
326
```
327
328
### Testing Custom Linters
329
330
```python
331
import unittest
332
from pylama.context import RunContext
333
from pylama.config import Namespace
334
from my_linter import CustomLinter
335
336
class TestCustomLinter(unittest.TestCase):
337
338
def test_custom_linter(self):
339
# Create test context
340
code = '''
341
def test_function():
342
print("This should trigger our linter")
343
return True
344
'''
345
346
options = Namespace()
347
ctx = RunContext('test.py', source=code, options=options)
348
349
# Run linter
350
linter = CustomLinter()
351
with ctx:
352
linter.run_check(ctx)
353
354
# Check results
355
self.assertEqual(len(ctx.errors), 1)
356
self.assertEqual(ctx.errors[0].source, 'custom')
357
self.assertIn('print statement', ctx.errors[0].text)
358
```
359
360
### Advanced Context Usage
361
362
```python
363
class AdvancedLinter(LinterV2):
364
name = "advanced"
365
366
def run_check(self, ctx: RunContext):
367
# Check if we should skip this file
368
if ctx.skip:
369
return
370
371
# Get configuration
372
params = ctx.get_params(self.name)
373
max_line_length = params.get('max_line_length', 79)
374
375
# Access file information
376
print(f"Checking {ctx.filename}")
377
print(f"Source length: {len(ctx.source)} characters")
378
379
# Check line lengths
380
for i, line in enumerate(ctx.source.splitlines(), 1):
381
if len(line) > max_line_length:
382
# Check if this error should be ignored
383
error_code = "E501"
384
if error_code not in ctx.ignore:
385
ctx.push(
386
source=self.name,
387
lnum=i,
388
col=max_line_length,
389
text=f"{error_code} line too long ({len(line)} > {max_line_length})",
390
etype="E"
391
)
392
```
393
394
## Plugin Discovery
395
396
Pylama automatically discovers and loads plugins through:
397
398
1. **Built-in plugins**: Located in `pylama/lint/` directory
399
2. **Entry point plugins**: Registered via setuptools entry points under `pylama.linter`
400
3. **Import-based discovery**: Uses `pkgutil.walk_packages()` to find linter modules
401
402
```python
403
# Built-in discovery in pylama/lint/__init__.py
404
from pkgutil import walk_packages
405
from importlib import import_module
406
407
# Import all modules in the lint package
408
for _, pname, _ in walk_packages([str(Path(__file__).parent)]):
409
try:
410
import_module(f"{__name__}.{pname}")
411
except ImportError:
412
pass
413
414
# Import entry point plugins
415
from pkg_resources import iter_entry_points
416
for entry in iter_entry_points("pylama.linter"):
417
if entry.name not in LINTERS:
418
try:
419
LINTERS[entry.name] = entry.load()
420
except ImportError:
421
pass
422
```