sergey-lint 0.1.1__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.
- sergey_lint-0.1.1/PKG-INFO +122 -0
- sergey_lint-0.1.1/README.md +113 -0
- sergey_lint-0.1.1/pyproject.toml +81 -0
- sergey_lint-0.1.1/sergey/__init__.py +1 -0
- sergey_lint-0.1.1/sergey/__main__.py +188 -0
- sergey_lint-0.1.1/sergey/analyzer.py +111 -0
- sergey_lint-0.1.1/sergey/config.py +136 -0
- sergey_lint-0.1.1/sergey/rules/__init__.py +26 -0
- sergey_lint-0.1.1/sergey/rules/base.py +77 -0
- sergey_lint-0.1.1/sergey/rules/docs.py +201 -0
- sergey_lint-0.1.1/sergey/rules/imports.py +230 -0
- sergey_lint-0.1.1/sergey/rules/naming.py +153 -0
- sergey_lint-0.1.1/sergey/rules/pydantic.py +425 -0
- sergey_lint-0.1.1/sergey/rules/structure.py +803 -0
- sergey_lint-0.1.1/sergey/server.py +80 -0
- sergey_lint-0.1.1/sergey_lint.egg-info/PKG-INFO +122 -0
- sergey_lint-0.1.1/sergey_lint.egg-info/SOURCES.txt +22 -0
- sergey_lint-0.1.1/sergey_lint.egg-info/dependency_links.txt +1 -0
- sergey_lint-0.1.1/sergey_lint.egg-info/entry_points.txt +2 -0
- sergey_lint-0.1.1/sergey_lint.egg-info/requires.txt +2 -0
- sergey_lint-0.1.1/sergey_lint.egg-info/top_level.txt +1 -0
- sergey_lint-0.1.1/setup.cfg +4 -0
- sergey_lint-0.1.1/tests/test_config.py +270 -0
- sergey_lint-0.1.1/tests/test_suppression.py +169 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sergey-lint
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pygls>=2.0.1
|
|
8
|
+
Requires-Dist: typer>=0.24.1
|
|
9
|
+
|
|
10
|
+
# sergey
|
|
11
|
+
|
|
12
|
+
A Python linter with opinionated rules about import style, naming, and code structure. Runs as a CLI tool or as an LSP server for editor integration.
|
|
13
|
+
|
|
14
|
+
The primary intent of Sergey is to enforce my personal stylistic rules upon agentic code.
|
|
15
|
+
However, you may also find these useful in standard development. Simultaneously, it is a testing
|
|
16
|
+
space for me for agentic coding.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv add sergey
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### CLI
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
sergey check path/to/file.py # check a single file
|
|
30
|
+
sergey check src/ tests/ # check directories (recursive)
|
|
31
|
+
sergey check . # check the whole project
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Exits with code `0` if no violations are found, `1` if any are found.
|
|
35
|
+
|
|
36
|
+
### LSP server
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
sergey serve
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Communicates over stdio using the Language Server Protocol. Configure your editor to launch this command as a language server for Python files.
|
|
43
|
+
|
|
44
|
+
## Rules
|
|
45
|
+
|
|
46
|
+
### Imports
|
|
47
|
+
|
|
48
|
+
| Rule | Description |
|
|
49
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
50
|
+
| **IMP001** | `from module import name` is disallowed when `name` is not itself a submodule. Use `import module` and reference `module.name` at call sites. Typing modules, `__future__`, and `collections.abc` are exempt (see IMP002 and IMP004). |
|
|
51
|
+
| **IMP002** | `import typing` and `import typing_extensions` are disallowed. Use `from typing import X` and `from typing_extensions import X` to import names directly. |
|
|
52
|
+
| **IMP003** | Dotted plain imports (`import os.path`) are disallowed. Use `from os import path` instead. `collections.abc` is exempt (see IMP004). |
|
|
53
|
+
| **IMP004** | `import collections.abc` is disallowed. Use `from collections.abc import X` to import names directly. |
|
|
54
|
+
|
|
55
|
+
The four rules together enforce a consistent import style: every name you use is either a bare module you imported at the top level, or a submodule you accessed via `from package import submodule`.
|
|
56
|
+
|
|
57
|
+
### Naming
|
|
58
|
+
|
|
59
|
+
| Rule | Description |
|
|
60
|
+
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
61
|
+
| **NAM001** | Functions annotated `-> bool` must start with a predicate prefix: `is_`, `has_`, `can_`, `should_`, `will_`, `did_`, or `was_`. Dunder methods are exempt. Leading underscores on private helpers are ignored (`_is_valid` passes). |
|
|
62
|
+
| **NAM002** | Single-character variable names are disallowed in assignments, for-loops, comprehensions, with-statements, and walrus expressions. The conventional throwaway `_` is exempt. |
|
|
63
|
+
| **NAM003** | Single-character function and method parameter names are disallowed. Covers positional-only, regular, and keyword-only parameters. `_`, `*args`, and `**kwargs` are exempt. Lambda parameters are not checked. |
|
|
64
|
+
|
|
65
|
+
### Documentation
|
|
66
|
+
|
|
67
|
+
| Rule | Description |
|
|
68
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
69
|
+
| **DOC001** | Functions that contain explicit `raise` statements must include a `Raises` section in their docstring. Bare re-raises (`raise` with no argument) are exempt. Raises inside nested functions or classes belong to those scopes and are not counted against the outer function. Functions with no docstring are not checked. Both Google style (`Raises:`) and NumPy style (`Raises` / `------`) are accepted. |
|
|
70
|
+
|
|
71
|
+
### Pydantic
|
|
72
|
+
|
|
73
|
+
| Rule | Description |
|
|
74
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
75
|
+
| **PDT001** | Every `BaseModel` subclass must have `model_config = ConfigDict(frozen=...)` with `frozen` explicitly set. This forces a deliberate decision about mutability. Both `frozen=True` and `frozen=False` are accepted; omitting `frozen` or omitting `model_config` entirely is flagged. |
|
|
76
|
+
| **PDT002** | Frozen `BaseModel` subclasses (`frozen=True`) must not have fields annotated with mutable types such as `list`, `dict`, `set`, `deque`, etc. Use immutable alternatives (`tuple`, `frozenset`, …) instead. The check recurses into generic parameters and union syntax, so `Optional[list[str]]` and `str \| list[int]` are both caught. `ClassVar` annotations are exempt. |
|
|
77
|
+
|
|
78
|
+
### Structure
|
|
79
|
+
|
|
80
|
+
| Rule | Description |
|
|
81
|
+
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
82
|
+
| **STR002** | Control-flow blocks nested deeper than 4 levels are flagged. Counted constructs: `if`/`elif`/`else`, `for`, `while`, `with`, `try`, `match`. `elif` branches count at the same depth as their leading `if`. Function, class, and lambda definitions reset the counter, so nested functions are judged independently. |
|
|
83
|
+
| **STR003** | `try` bodies containing more than 4 statements are flagged. Statements are counted recursively (an `if` with branches contributes 1 plus all contained statements). Only the `try:` body is counted — `except` and `finally` blocks are not subject to this rule. Nested functions and classes reset the count. |
|
|
84
|
+
| **STR004** | List and set literals inside functions that are never mutated and are not part of the function output (`return`/`yield`) should use immutable alternatives: `tuple` instead of `[]` and `frozenset` instead of `{}`. Only plain literals are checked; constructor calls and comprehensions are not covered. |
|
|
85
|
+
|
|
86
|
+
## Suppression
|
|
87
|
+
|
|
88
|
+
### Suppress a single line
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
x = some_function() # sergey: noqa
|
|
92
|
+
x = some_function() # sergey: noqa: NAM002
|
|
93
|
+
x = some_function() # sergey: noqa: NAM002, IMP001
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Suppress an entire file
|
|
97
|
+
|
|
98
|
+
Place this comment anywhere in the file (position does not matter):
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
# sergey: disable-file
|
|
102
|
+
# sergey: disable-file: IMP001
|
|
103
|
+
# sergey: disable-file: IMP001, IMP002
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
uv run ruff check . # lint
|
|
110
|
+
uv run ruff format . # format
|
|
111
|
+
uv run ty check # type check
|
|
112
|
+
uv run pytest # run tests
|
|
113
|
+
uv run sergey check . # run sergey on itself
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Adding a rule
|
|
117
|
+
|
|
118
|
+
1. Create or extend a module in `sergey/rules/` with a class that subclasses `base.Rule` and implements `check(tree, source) -> list[Diagnostic]`.
|
|
119
|
+
2. Register the rule in `sergey/rules/__init__.py` by adding an instance to `ALL_RULES`.
|
|
120
|
+
3. Add tests in `tests/rules/`.
|
|
121
|
+
|
|
122
|
+
Rule IDs follow the pattern `CAT###` where `CAT` is a short category prefix (`IMP`, `NAM`, `STR`, …) and `###` is a three-digit number.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# sergey
|
|
2
|
+
|
|
3
|
+
A Python linter with opinionated rules about import style, naming, and code structure. Runs as a CLI tool or as an LSP server for editor integration.
|
|
4
|
+
|
|
5
|
+
The primary intent of Sergey is to enforce my personal stylistic rules upon agentic code.
|
|
6
|
+
However, you may also find these useful in standard development. Simultaneously, it is a testing
|
|
7
|
+
space for me for agentic coding.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add sergey
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### CLI
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
sergey check path/to/file.py # check a single file
|
|
21
|
+
sergey check src/ tests/ # check directories (recursive)
|
|
22
|
+
sergey check . # check the whole project
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Exits with code `0` if no violations are found, `1` if any are found.
|
|
26
|
+
|
|
27
|
+
### LSP server
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
sergey serve
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Communicates over stdio using the Language Server Protocol. Configure your editor to launch this command as a language server for Python files.
|
|
34
|
+
|
|
35
|
+
## Rules
|
|
36
|
+
|
|
37
|
+
### Imports
|
|
38
|
+
|
|
39
|
+
| Rule | Description |
|
|
40
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
41
|
+
| **IMP001** | `from module import name` is disallowed when `name` is not itself a submodule. Use `import module` and reference `module.name` at call sites. Typing modules, `__future__`, and `collections.abc` are exempt (see IMP002 and IMP004). |
|
|
42
|
+
| **IMP002** | `import typing` and `import typing_extensions` are disallowed. Use `from typing import X` and `from typing_extensions import X` to import names directly. |
|
|
43
|
+
| **IMP003** | Dotted plain imports (`import os.path`) are disallowed. Use `from os import path` instead. `collections.abc` is exempt (see IMP004). |
|
|
44
|
+
| **IMP004** | `import collections.abc` is disallowed. Use `from collections.abc import X` to import names directly. |
|
|
45
|
+
|
|
46
|
+
The four rules together enforce a consistent import style: every name you use is either a bare module you imported at the top level, or a submodule you accessed via `from package import submodule`.
|
|
47
|
+
|
|
48
|
+
### Naming
|
|
49
|
+
|
|
50
|
+
| Rule | Description |
|
|
51
|
+
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
52
|
+
| **NAM001** | Functions annotated `-> bool` must start with a predicate prefix: `is_`, `has_`, `can_`, `should_`, `will_`, `did_`, or `was_`. Dunder methods are exempt. Leading underscores on private helpers are ignored (`_is_valid` passes). |
|
|
53
|
+
| **NAM002** | Single-character variable names are disallowed in assignments, for-loops, comprehensions, with-statements, and walrus expressions. The conventional throwaway `_` is exempt. |
|
|
54
|
+
| **NAM003** | Single-character function and method parameter names are disallowed. Covers positional-only, regular, and keyword-only parameters. `_`, `*args`, and `**kwargs` are exempt. Lambda parameters are not checked. |
|
|
55
|
+
|
|
56
|
+
### Documentation
|
|
57
|
+
|
|
58
|
+
| Rule | Description |
|
|
59
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
60
|
+
| **DOC001** | Functions that contain explicit `raise` statements must include a `Raises` section in their docstring. Bare re-raises (`raise` with no argument) are exempt. Raises inside nested functions or classes belong to those scopes and are not counted against the outer function. Functions with no docstring are not checked. Both Google style (`Raises:`) and NumPy style (`Raises` / `------`) are accepted. |
|
|
61
|
+
|
|
62
|
+
### Pydantic
|
|
63
|
+
|
|
64
|
+
| Rule | Description |
|
|
65
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
66
|
+
| **PDT001** | Every `BaseModel` subclass must have `model_config = ConfigDict(frozen=...)` with `frozen` explicitly set. This forces a deliberate decision about mutability. Both `frozen=True` and `frozen=False` are accepted; omitting `frozen` or omitting `model_config` entirely is flagged. |
|
|
67
|
+
| **PDT002** | Frozen `BaseModel` subclasses (`frozen=True`) must not have fields annotated with mutable types such as `list`, `dict`, `set`, `deque`, etc. Use immutable alternatives (`tuple`, `frozenset`, …) instead. The check recurses into generic parameters and union syntax, so `Optional[list[str]]` and `str \| list[int]` are both caught. `ClassVar` annotations are exempt. |
|
|
68
|
+
|
|
69
|
+
### Structure
|
|
70
|
+
|
|
71
|
+
| Rule | Description |
|
|
72
|
+
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
73
|
+
| **STR002** | Control-flow blocks nested deeper than 4 levels are flagged. Counted constructs: `if`/`elif`/`else`, `for`, `while`, `with`, `try`, `match`. `elif` branches count at the same depth as their leading `if`. Function, class, and lambda definitions reset the counter, so nested functions are judged independently. |
|
|
74
|
+
| **STR003** | `try` bodies containing more than 4 statements are flagged. Statements are counted recursively (an `if` with branches contributes 1 plus all contained statements). Only the `try:` body is counted — `except` and `finally` blocks are not subject to this rule. Nested functions and classes reset the count. |
|
|
75
|
+
| **STR004** | List and set literals inside functions that are never mutated and are not part of the function output (`return`/`yield`) should use immutable alternatives: `tuple` instead of `[]` and `frozenset` instead of `{}`. Only plain literals are checked; constructor calls and comprehensions are not covered. |
|
|
76
|
+
|
|
77
|
+
## Suppression
|
|
78
|
+
|
|
79
|
+
### Suppress a single line
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
x = some_function() # sergey: noqa
|
|
83
|
+
x = some_function() # sergey: noqa: NAM002
|
|
84
|
+
x = some_function() # sergey: noqa: NAM002, IMP001
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Suppress an entire file
|
|
88
|
+
|
|
89
|
+
Place this comment anywhere in the file (position does not matter):
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# sergey: disable-file
|
|
93
|
+
# sergey: disable-file: IMP001
|
|
94
|
+
# sergey: disable-file: IMP001, IMP002
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
uv run ruff check . # lint
|
|
101
|
+
uv run ruff format . # format
|
|
102
|
+
uv run ty check # type check
|
|
103
|
+
uv run pytest # run tests
|
|
104
|
+
uv run sergey check . # run sergey on itself
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Adding a rule
|
|
108
|
+
|
|
109
|
+
1. Create or extend a module in `sergey/rules/` with a class that subclasses `base.Rule` and implements `check(tree, source) -> list[Diagnostic]`.
|
|
110
|
+
2. Register the rule in `sergey/rules/__init__.py` by adding an instance to `ALL_RULES`.
|
|
111
|
+
3. Add tests in `tests/rules/`.
|
|
112
|
+
|
|
113
|
+
Rule IDs follow the pattern `CAT###` where `CAT` is a short category prefix (`IMP`, `NAM`, `STR`, …) and `###` is a three-digit number.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sergey-lint"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pygls>=2.0.1",
|
|
9
|
+
"typer>=0.24.1",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
sergey = "sergey.__main__:main"
|
|
14
|
+
|
|
15
|
+
[tool.uv]
|
|
16
|
+
package = true
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
include = ["sergey*"]
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"prek>=0.3.3",
|
|
24
|
+
"pytest>=9.0.2",
|
|
25
|
+
"ruff>=0.15.2",
|
|
26
|
+
"ty>=0.0.18",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.ruff.lint]
|
|
30
|
+
preview = false
|
|
31
|
+
select = ["ALL"]
|
|
32
|
+
ignore = [
|
|
33
|
+
"PYI063", # Preview rule not correctly ignored.
|
|
34
|
+
"SIM300", # Can cause mypy issues in SQLAlchemy where statements.
|
|
35
|
+
# Recommended ignores by Astral https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
|
|
36
|
+
"W191", # Tab indentation
|
|
37
|
+
"E111", # Indentation with invalid multiple
|
|
38
|
+
"E114", # Indentation with invalid multiple comment
|
|
39
|
+
"E117", # Over indented
|
|
40
|
+
"D206", # Docstring tab indentation
|
|
41
|
+
"D300", # Triple single quotes
|
|
42
|
+
"Q000", # Bad quotes inline string
|
|
43
|
+
"Q001", # Bad quotes multiline string
|
|
44
|
+
"Q002", # Bad quotes docstring
|
|
45
|
+
"Q003", # Avoidable escaped quote
|
|
46
|
+
"COM812", # Missing trailing comma
|
|
47
|
+
"COM819", # Prohibited trailing comma
|
|
48
|
+
"ISC002", # Multi-line implicit string concatenation
|
|
49
|
+
]
|
|
50
|
+
fixable = ["ALL"]
|
|
51
|
+
unfixable = []
|
|
52
|
+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint.pydocstyle]
|
|
55
|
+
convention = "google"
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint.per-file-ignores]
|
|
58
|
+
"tests/**/*.py" = [
|
|
59
|
+
"S101", # asserts should be used in pytest
|
|
60
|
+
"SLF001", # accessing private members in tests is fine
|
|
61
|
+
"INP001", # tests should not be a module
|
|
62
|
+
"ARG001", # tests can have unused arguments (fixtures with side-effects)
|
|
63
|
+
"D1", # docstrings not required on test classes/methods
|
|
64
|
+
"D2", # docstring formatting rules not relevant in tests
|
|
65
|
+
"PLR2004", # magic values in assertions are fine in tests
|
|
66
|
+
]
|
|
67
|
+
"sergey/rules/*.py" = [
|
|
68
|
+
"ARG002", # `source` is part of the Rule.check() interface; not all rules use it
|
|
69
|
+
]
|
|
70
|
+
".claude/hooks/*.py" = [
|
|
71
|
+
"S603", # subprocess inputs are controlled (file paths from Claude Code)
|
|
72
|
+
"S607", # partial path for `uv` is intentional
|
|
73
|
+
]
|
|
74
|
+
"local/**/*" = ["ALL"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.format]
|
|
77
|
+
preview = false
|
|
78
|
+
quote-style = "double"
|
|
79
|
+
indent-style = "space"
|
|
80
|
+
skip-magic-trailing-comma = false
|
|
81
|
+
line-ending = "auto"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""sergey: an opinionated Python LSP for LLM agent loops."""
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Entry point: sergey [check <path>... | serve]."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
from typing import Annotated, Final
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from sergey.rules import base as rules_base
|
|
9
|
+
|
|
10
|
+
app = typer.Typer()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _apply_fixes(source: str, diagnostics: list[rules_base.Diagnostic]) -> str:
|
|
14
|
+
"""Return *source* with all fixable diagnostics applied.
|
|
15
|
+
|
|
16
|
+
Fixes are applied from bottom to top so that earlier offsets remain valid
|
|
17
|
+
after each replacement. When multiple diagnostics share the same range
|
|
18
|
+
(e.g. two dotted aliases on one import statement) only the first fix
|
|
19
|
+
encountered is applied — they are identical by construction.
|
|
20
|
+
"""
|
|
21
|
+
# Deduplicate by range; preserve insertion order (already sorted top→bottom).
|
|
22
|
+
seen_ranges: set[tuple[int, int, int, int]] = set()
|
|
23
|
+
unique: list[rules_base.Diagnostic] = []
|
|
24
|
+
for diag in diagnostics:
|
|
25
|
+
if diag.fix is None:
|
|
26
|
+
continue
|
|
27
|
+
key = (diag.line, diag.col, diag.end_line, diag.end_col)
|
|
28
|
+
if key not in seen_ranges:
|
|
29
|
+
seen_ranges.add(key)
|
|
30
|
+
unique.append(diag)
|
|
31
|
+
|
|
32
|
+
# Apply bottom→top to keep earlier positions stable.
|
|
33
|
+
for diag in reversed(unique):
|
|
34
|
+
lines = source.splitlines(keepends=True)
|
|
35
|
+
# Build character offsets for start and end of the diagnostic range.
|
|
36
|
+
start = sum(len(lines[idx]) for idx in range(diag.line - 1)) + diag.col
|
|
37
|
+
end = sum(len(lines[idx]) for idx in range(diag.end_line - 1)) + diag.end_col
|
|
38
|
+
if diag.fix is None: # pragma: no cover
|
|
39
|
+
continue
|
|
40
|
+
source = source[:start] + diag.fix.replacement + source[end:]
|
|
41
|
+
return source
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Directories that are never interesting to analyse.
|
|
45
|
+
_SKIP_DIRS: Final[frozenset[str]] = frozenset(
|
|
46
|
+
{".venv", "venv", "__pycache__", ".git", "node_modules", "build", "dist", ".tox"}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _collect_python_files(root: pathlib.Path) -> list[pathlib.Path]:
|
|
51
|
+
"""Recursively find .py files under root, skipping non-source directories."""
|
|
52
|
+
return sorted(
|
|
53
|
+
py_file
|
|
54
|
+
for py_file in root.rglob("*.py")
|
|
55
|
+
if not any(part in _SKIP_DIRS for part in py_file.parts)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _git_diff_python_files() -> list[pathlib.Path]:
|
|
60
|
+
"""Return .py files changed relative to HEAD in the current git repository.
|
|
61
|
+
|
|
62
|
+
Returns an empty list when git is unavailable or the directory is not a
|
|
63
|
+
git repository.
|
|
64
|
+
"""
|
|
65
|
+
import subprocess # noqa: PLC0415
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
root_proc = subprocess.run(
|
|
69
|
+
["git", "rev-parse", "--show-toplevel"], # noqa: S607
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
check=False,
|
|
73
|
+
)
|
|
74
|
+
diff_proc = subprocess.run(
|
|
75
|
+
["git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD"], # noqa: S607
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
check=False,
|
|
79
|
+
)
|
|
80
|
+
except OSError:
|
|
81
|
+
return []
|
|
82
|
+
if root_proc.returncode != 0 or diff_proc.returncode != 0:
|
|
83
|
+
return []
|
|
84
|
+
git_root = pathlib.Path(root_proc.stdout.strip())
|
|
85
|
+
return [
|
|
86
|
+
git_root / line
|
|
87
|
+
for line in diff_proc.stdout.splitlines()
|
|
88
|
+
if line.endswith(".py")
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _resolve_files(
|
|
93
|
+
paths: list[pathlib.Path] | None,
|
|
94
|
+
*,
|
|
95
|
+
diff: bool,
|
|
96
|
+
) -> list[pathlib.Path]:
|
|
97
|
+
"""Expand paths and optionally the git diff into a deduplicated .py file list."""
|
|
98
|
+
candidates: list[pathlib.Path] = []
|
|
99
|
+
if diff:
|
|
100
|
+
candidates.extend(_git_diff_python_files())
|
|
101
|
+
for raw_path in paths or []:
|
|
102
|
+
if raw_path.is_dir():
|
|
103
|
+
candidates.extend(_collect_python_files(raw_path))
|
|
104
|
+
else:
|
|
105
|
+
candidates.append(raw_path)
|
|
106
|
+
seen: set[pathlib.Path] = set()
|
|
107
|
+
unique: list[pathlib.Path] = []
|
|
108
|
+
for file_path in candidates:
|
|
109
|
+
resolved = file_path.resolve()
|
|
110
|
+
if resolved not in seen:
|
|
111
|
+
seen.add(resolved)
|
|
112
|
+
unique.append(file_path)
|
|
113
|
+
return unique
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command(no_args_is_help=True)
|
|
117
|
+
def check(
|
|
118
|
+
paths: Annotated[
|
|
119
|
+
list[pathlib.Path] | None,
|
|
120
|
+
typer.Argument(help="Files or directories to check."),
|
|
121
|
+
] = None,
|
|
122
|
+
diff: Annotated[ # noqa: FBT002
|
|
123
|
+
bool,
|
|
124
|
+
typer.Option("--diff", help="Check .py files changed in the current git diff."),
|
|
125
|
+
] = False,
|
|
126
|
+
fix: Annotated[ # noqa: FBT002
|
|
127
|
+
bool,
|
|
128
|
+
typer.Option("--fix", help="Apply auto-fixes for fixable violations."),
|
|
129
|
+
] = False,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Check one or more files/directories for rule violations.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
typer.Exit: With code 1 if any unfixed violations remain.
|
|
135
|
+
"""
|
|
136
|
+
from sergey import analyzer as sergey_analyzer # noqa: PLC0415
|
|
137
|
+
from sergey import config as sergey_config # noqa: PLC0415
|
|
138
|
+
from sergey import rules # noqa: PLC0415
|
|
139
|
+
|
|
140
|
+
python_files = _resolve_files(paths, diff=diff)
|
|
141
|
+
cfg = sergey_config.load_config()
|
|
142
|
+
active_rules = sergey_config.filter_rules(rules.ALL_RULES, cfg)
|
|
143
|
+
active_rules = sergey_config.configure_rules(active_rules, cfg)
|
|
144
|
+
analyzer = sergey_analyzer.Analyzer(rules=active_rules)
|
|
145
|
+
found_any = False
|
|
146
|
+
|
|
147
|
+
for file_path in python_files:
|
|
148
|
+
try:
|
|
149
|
+
source = file_path.read_text()
|
|
150
|
+
except OSError as e:
|
|
151
|
+
typer.echo(f"error: {e}", err=True)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
diagnostics = analyzer.analyze(source)
|
|
155
|
+
|
|
156
|
+
if fix:
|
|
157
|
+
fixed_source = _apply_fixes(source, diagnostics)
|
|
158
|
+
if fixed_source != source:
|
|
159
|
+
file_path.write_text(fixed_source)
|
|
160
|
+
# Re-analyze to report any remaining violations.
|
|
161
|
+
diagnostics = analyzer.analyze(fixed_source)
|
|
162
|
+
|
|
163
|
+
for diag in diagnostics:
|
|
164
|
+
typer.echo(
|
|
165
|
+
f"{file_path}:{diag.line}:{diag.col}: {diag.rule_id} {diag.message}"
|
|
166
|
+
)
|
|
167
|
+
if diagnostics:
|
|
168
|
+
found_any = True
|
|
169
|
+
|
|
170
|
+
if found_any:
|
|
171
|
+
raise typer.Exit(code=1)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@app.command(no_args_is_help=True)
|
|
175
|
+
def serve() -> None:
|
|
176
|
+
"""Run the LSP server over stdio."""
|
|
177
|
+
from sergey import server # noqa: PLC0415
|
|
178
|
+
|
|
179
|
+
server.start()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main() -> None:
|
|
183
|
+
"""Dispatch to CLI check mode or LSP server mode."""
|
|
184
|
+
app()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
main()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Orchestrates rule execution against a parsed AST."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, Final
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from sergey.rules import base
|
|
11
|
+
|
|
12
|
+
# Matches: # sergey: noqa (suppress all rules on this line)
|
|
13
|
+
# # sergey: noqa: IMP001 (suppress specific rules on this line)
|
|
14
|
+
_LINE_NOQA_PAT: Final = re.compile(
|
|
15
|
+
r"#\s*sergey:\s*noqa(?::\s*([A-Z0-9][A-Z0-9,\s]*))?",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Matches: # sergey: disable-file (suppress all rules in this file)
|
|
20
|
+
# # sergey: disable-file: IMP001 (suppress specific rules in this file)
|
|
21
|
+
_FILE_DISABLE_PAT: Final = re.compile(
|
|
22
|
+
r"#\s*sergey:\s*disable-file(?::\s*([A-Z0-9][A-Z0-9,\s]*))?",
|
|
23
|
+
re.IGNORECASE,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rule_ids(raw: str | None) -> frozenset[str] | None:
|
|
28
|
+
"""Parse rule IDs from a suppression comment capture group.
|
|
29
|
+
|
|
30
|
+
Returns None to indicate all rules are suppressed, or a frozenset of
|
|
31
|
+
specific uppercased rule IDs.
|
|
32
|
+
"""
|
|
33
|
+
if not raw or not raw.strip():
|
|
34
|
+
return None
|
|
35
|
+
ids = frozenset(part.strip().upper() for part in raw.split(",") if part.strip())
|
|
36
|
+
return ids or None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_covered(suppressed: frozenset[str] | None, rule_id: str) -> bool:
|
|
40
|
+
"""Return True if rule_id falls within the suppression set.
|
|
41
|
+
|
|
42
|
+
None means all rules are suppressed.
|
|
43
|
+
"""
|
|
44
|
+
return suppressed is None or rule_id in suppressed
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _apply_suppressions(
|
|
48
|
+
diagnostics: list[base.Diagnostic],
|
|
49
|
+
source: str,
|
|
50
|
+
) -> list[base.Diagnostic]:
|
|
51
|
+
"""Remove diagnostics covered by inline sergey suppression comments."""
|
|
52
|
+
lines = source.splitlines()
|
|
53
|
+
|
|
54
|
+
file_sup_active = False
|
|
55
|
+
file_sup_rules: frozenset[str] | None = None
|
|
56
|
+
line_sups: dict[int, frozenset[str] | None] = {}
|
|
57
|
+
|
|
58
|
+
for lineno, line_text in enumerate(lines, start=1):
|
|
59
|
+
file_match = _FILE_DISABLE_PAT.search(line_text)
|
|
60
|
+
if file_match:
|
|
61
|
+
file_sup_active = True
|
|
62
|
+
file_sup_rules = _rule_ids(file_match.group(1))
|
|
63
|
+
|
|
64
|
+
line_match = _LINE_NOQA_PAT.search(line_text)
|
|
65
|
+
if line_match:
|
|
66
|
+
line_sups[lineno] = _rule_ids(line_match.group(1))
|
|
67
|
+
|
|
68
|
+
return [
|
|
69
|
+
diag
|
|
70
|
+
for diag in diagnostics
|
|
71
|
+
if not (
|
|
72
|
+
(file_sup_active and _is_covered(file_sup_rules, diag.rule_id))
|
|
73
|
+
or (
|
|
74
|
+
diag.line in line_sups
|
|
75
|
+
and _is_covered(line_sups[diag.line], diag.rule_id)
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Analyzer:
|
|
82
|
+
"""Runs all registered rules against a source file."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, rules: list[base.Rule]) -> None:
|
|
85
|
+
"""Initialize with a list of rule instances.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
rules: Rule instances to run on every analysis request.
|
|
89
|
+
"""
|
|
90
|
+
self.rules = rules
|
|
91
|
+
|
|
92
|
+
def analyze(self, source: str) -> list[base.Diagnostic]:
|
|
93
|
+
"""Parse source, run all rules, and apply inline suppressions.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
source: Raw Python source code to analyze.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Diagnostics sorted by (line, col) with suppressed entries removed.
|
|
100
|
+
Returns an empty list if the source cannot be parsed.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
tree = ast.parse(source)
|
|
104
|
+
except SyntaxError:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
diagnostics = sorted(
|
|
108
|
+
[diag for rule in self.rules for diag in rule.check(tree, source)],
|
|
109
|
+
key=lambda diag: (diag.line, diag.col),
|
|
110
|
+
)
|
|
111
|
+
return _apply_suppressions(diagnostics, source)
|