Skip to content

Commit 5e3c0c7

Browse files
committed
Increment version to 1.0.18, update README to clarify exit code behavior and analysis limits, enhance documentation on severity filtering, and improve performance detection for async loops. Refactor detector methods for better clarity and add tests for new functionality and suppression behavior.
1 parent e50e1d9 commit 5e3c0c7

15 files changed

Lines changed: 191 additions & 46 deletions

README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ A Python refactoring and optimization linter that uses AST analysis to identify
1717
- **Complexity**: High cyclomatic complexity functions
1818
- **Performance**: String concatenation in loops (thresholded), repeated uncached calls in loops, inefficient operations
1919
- **Boolean Logic**: Overcomplicated boolean expressions
20-
- **Loops**: Index patterns, nested loops with lookups, loop-invariant calls (dict comprehensions are R010)
20+
- **Loops**: Index patterns, nested loops with lookups, loop-invariant calls
2121
- **Duplication**: Duplicate code blocks
2222
- **Context Manager**: Missing `with` statements for resource operations
2323
- **Control Flow**: Unnecessary `else` after `return`/`raise`/`break`/`continue`
24-
- **Dictionary Operations**: Non-idiomatic dict patterns, missing `.get()`, unnecessary `.keys()`
24+
- **Dictionary Operations**: Non-idiomatic dict patterns, missing `.get()`, unnecessary `.keys()`, dict comprehensions (R010)
2525
- **Comparisons**: Chained comparisons, singleton checks, `type()` vs `isinstance()`
2626

2727
See [docs/RULES.md](docs/RULES.md) for the full rule catalog (C001–C006, P001–P007, B001/B004–B007, L001–L004, D001, R001–R016).
@@ -78,18 +78,24 @@ pyrefactor --config custom.toml src/
7878

7979
- `-c, --config`: Configuration file path; when omitted, auto-discover `pyproject.toml` (`[tool.pyrefactor]`), then `pyrefactor.ini`, then built-in defaults
8080
- `-g, --group-by`: Group by `file` or `severity` (default: `file`)
81-
- `--min-severity`: Minimum severity to report: `info`, `low`, `medium`, `high` (default: `info`)
81+
- `--min-severity`: Minimum severity to report: `info`, `low`, `medium`, `high` (default: `info`). Also filters which issues count toward the exit code.
8282
- `-j, --jobs`: Number of parallel workers (default: 4)
8383
- `-v, --verbose`: Enable verbose logging
8484
- `--fail-on-parse-errors`: Exit with code 1 when any file has a syntax or parse error
8585
- `--version`: Show version
8686

8787
### Exit Codes
8888

89-
- `0` - No MEDIUM/HIGH severity issues (INFO/LOW only). Per-file syntax or parse errors are reported in output but do not change the exit code unless `--fail-on-parse-errors` is set.
90-
- `1` - MEDIUM/HIGH severity issues found, or parse errors when `--fail-on-parse-errors` is used
89+
Exit codes are computed **after** applying `--min-severity`. For example, `--min-severity high` can exit `0` even when MEDIUM issues exist, because those issues are filtered out before the exit code is determined.
90+
91+
- `0` - No MEDIUM/HIGH severity issues remain after filtering (INFO/LOW only). Per-file syntax or parse errors are reported in output but do not change the exit code unless `--fail-on-parse-errors` is set.
92+
- `1` - MEDIUM/HIGH severity issues remain after filtering, or parse errors when `--fail-on-parse-errors` is used
9193
- `2` - Configuration, path, or orchestration error (invalid paths, missing config, no Python files to analyze)
9294

95+
### Analysis Limits
96+
97+
Files larger than **10 MB** are skipped with a parse-style error message. This limit is fixed and not configurable.
98+
9399
## Configuration
94100

95101
Configure via TOML file (e.g., `pyproject.toml`):
@@ -141,6 +147,8 @@ Configuration is searched in: `--config` → `pyproject.toml` → `pyrefactor.in
141147

142148
### INI configuration (`pyrefactor.ini`)
143149

150+
See the repository's [`pyrefactor.ini`](pyrefactor.ini) for a full annotated example with every detector section. A minimal example:
151+
144152
```ini
145153
[complexity]
146154
enabled = true

docs/CONFIGURATION.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ The CLI does not override individual detector thresholds. Use `--config` to poin
124124

125125
Use `--fail-on-parse-errors` when integrating PyRefactor into CI: by default, syntax and parse errors are reported but do not affect the exit code; with this flag, any parse error causes exit code `1`.
126126

127+
`--min-severity` filters both console output and exit-code calculation. Issues below the minimum severity are omitted entirely.
128+
129+
## Analysis Limits
130+
131+
Files larger than **10 MB** are skipped with a parse-style error message. This limit is fixed in the implementation and is not configurable.
132+
127133
## Related Documentation
128134

129135
- [RULES.md](RULES.md) — rule IDs, severities, and examples

docs/RULES.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ B002/B003 are intentionally not used; boolean `== True`/`== False` checks are re
4545
| Rule | Severity | Description |
4646
|------|----------|-------------|
4747
| L001 | LOW | `for i in range(len(seq))` should use `enumerate()` |
48-
| L002 | LOW | Manual index increment pattern (`i += 1`) |
48+
| L002 | INFO | Manual index increment pattern (`i += 1`) |
4949
| L003 | MEDIUM | Deeply nested loops with membership or subscript lookups |
5050
| L004 | MEDIUM | Loop-invariant expensive call inside loop body |
5151

@@ -81,8 +81,8 @@ Covers `open`, `urlopen`, `ZipFile`, `Popen`, `Path.open()`, and related APIs.
8181
| Rule | Severity | Description |
8282
|------|----------|-------------|
8383
| R006 | LOW | `if key in d: x = d[key] else: x = default` should use `.get()` |
84-
| R007 | LOW | Consider `.items()` when iterating keys and accessing values |
85-
| R009 | LOW | Unnecessary `.keys()` in a membership test |
84+
| R007 | MEDIUM | Consider `.items()` when iterating keys and accessing values |
85+
| R009 | INFO | Unnecessary `.keys()` in a membership test |
8686
| R010 | LOW | `dict([...])` should be a dict comprehension |
8787

8888
R008 is reserved and not currently implemented.
@@ -114,8 +114,14 @@ y = 2
114114

115115
## Exit Codes
116116

117+
Exit codes are computed **after** applying `--min-severity`. Issues below the minimum severity are omitted from output and do not affect the exit code.
118+
117119
| Code | Meaning |
118120
|------|---------|
119-
| 0 | No MEDIUM/HIGH issues (after severity filter). Per-file syntax/parse errors are reported but exit 0. |
120-
| 1 | One or more MEDIUM/HIGH issues found |
121+
| 0 | No MEDIUM/HIGH issues remain after the severity filter. Per-file syntax/parse errors are reported but exit 0 unless `--fail-on-parse-errors` is set. |
122+
| 1 | One or more MEDIUM/HIGH issues remain after the severity filter, or any parse error when `--fail-on-parse-errors` is set |
121123
| 2 | Configuration, path, or orchestration error (invalid paths, no Python files to analyze) |
124+
125+
## Analysis Limits
126+
127+
Files larger than **10 MB** are skipped with a parse-style error message. This limit is fixed in the implementation and is not configurable.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pyrefactor"
7-
version = "1.0.17"
7+
version = "1.0.18"
88
description = "A Python refactoring and optimization linter that analyzes code for performance issues, complexity problems, and opportunities for improvement"
99
authors = [{name = "tboy1337"}]
1010
maintainers = [{name = "tboy1337"}]

src/pyrefactor/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ def main() -> int:
247247
return 2
248248

249249
# Create analyzer and analyze files
250+
if args.jobs < 1:
251+
logger.warning("--jobs must be at least 1; using 1 worker")
250252
max_workers = max(1, args.jobs)
251253
analyzer = Analyzer(config)
252254
result = _analyze_files_safely(analyzer, paths, max_workers, verbose=args.verbose)

src/pyrefactor/detectors/boolean_logic.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Boolean logic detector for PyRefactor."""
22

33
import ast
4-
from typing import Union
54

65
from ..ast_visitor import BaseDetector
76
from ..models import Severity
@@ -68,18 +67,22 @@ def _check_boolean_singleton_comparison(self, node: ast.Compare) -> None:
6867
)
6968
return
7069

71-
def visit_FunctionDef(
72-
self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
70+
def _visit_function_scope(
71+
self, node: ast.FunctionDef | ast.AsyncFunctionDef
7372
) -> None:
7473
"""Track function context for early return detection."""
7574
old_function = self.current_function
7675
self.current_function = node
7776
self.generic_visit(node)
7877
self.current_function = old_function
7978

79+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
80+
"""Track function context for early return detection."""
81+
self._visit_function_scope(node)
82+
8083
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
8184
"""Track async function context for early return detection."""
82-
self.visit_FunctionDef(node)
85+
self._visit_function_scope(node)
8386

8487
def visit_If(self, node: ast.If) -> None:
8588
"""Check for opportunities to use early returns."""

src/pyrefactor/detectors/comparisons.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -324,14 +324,6 @@ def _check_unidiomatic_typecheck(self, node: ast.Compare) -> None:
324324
),
325325
)
326326

327-
def visit_Call(self, node: ast.Call) -> None:
328-
"""Visit call nodes for comparison checks."""
329-
if self.is_suppressed(node):
330-
self.generic_visit(node)
331-
return
332-
333-
self.generic_visit(node)
334-
335327
def _get_op_str(self, op: ast.cmpop) -> Optional[str]:
336328
"""Convert comparison operator to string."""
337329
op_map = {

src/pyrefactor/detectors/dict_operations.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Dictionary operations detector for PyRefactor."""
22

33
import ast
4-
from typing import Optional, Tuple, cast
4+
from typing import cast
55

66
from ..ast_visitor import BaseDetector
77
from ..models import Severity
@@ -67,7 +67,7 @@ def _is_valid_dict_get_structure(self, node: ast.If) -> bool:
6767

6868
def _extract_dict_get_components(
6969
self, node: ast.If
70-
) -> Optional[Tuple[str, str, str, str]]:
70+
) -> tuple[str, str, str, str] | None:
7171
"""Extract variable names and values for dict.get() suggestion."""
7272
if_stmt = node.body[0]
7373
else_stmt = node.orelse[0]
@@ -123,7 +123,7 @@ def _validate_assignment_structure(
123123

124124
def _extract_condition_data(
125125
self, test: ast.expr
126-
) -> Optional[Tuple[ast.Name, ast.Name]]:
126+
) -> tuple[ast.Name, ast.Name] | None:
127127
"""Extract key and dict names from the condition."""
128128
if not isinstance(test, ast.Compare):
129129
return None
@@ -336,7 +336,7 @@ def _check_dict_comprehension(self, node: ast.Call) -> None:
336336
"for better readability and performance",
337337
)
338338

339-
def _get_name(self, node: ast.AST) -> Optional[str]:
339+
def _get_name(self, node: ast.AST) -> str | None:
340340
"""Extract the name from a node."""
341341
if isinstance(node, ast.Name):
342342
return node.id

src/pyrefactor/detectors/performance.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def _is_suppressed_for_rule(self, node: ast.AST, rule_id: str) -> bool:
131131
"""Return True when a node is suppressed for a specific rule."""
132132
return self.is_suppressed(node, rule_id)
133133

134-
def _visit_loop(self, node: ast.For | ast.While) -> None:
134+
def _visit_loop(self, node: ast.For | ast.While | ast.AsyncFor) -> None:
135135
"""Track loop entry, analyze body, then exit."""
136136
self.loop_stack.append(node)
137137
self.in_loop = True
@@ -144,6 +144,10 @@ def visit_For(self, node: ast.For) -> None:
144144
"""Track when we're inside a for loop."""
145145
self._visit_loop(node)
146146

147+
def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
148+
"""Track when we're inside an async for loop."""
149+
self._visit_loop(node)
150+
147151
def visit_While(self, node: ast.While) -> None:
148152
"""Track when we're inside a while loop."""
149153
self._visit_loop(node)
@@ -172,12 +176,16 @@ def _is_string_initializer(self, node: ast.expr) -> bool:
172176
"""Return True if an expression initializes a string value."""
173177
return isinstance(node, ast.Constant) and isinstance(node.value, str)
174178

175-
def _check_loop_performance(self, loop_node: ast.For | ast.While) -> None:
179+
def _check_loop_performance(
180+
self, loop_node: ast.For | ast.While | ast.AsyncFor
181+
) -> None:
176182
"""Check loop body for concatenation and duplicate call patterns."""
177183
self._check_string_concatenations(loop_node)
178184
self._check_duplicate_calls(loop_node)
179185

180-
def _check_string_concatenations(self, loop_node: ast.For | ast.While) -> None:
186+
def _check_string_concatenations(
187+
self, loop_node: ast.For | ast.While | ast.AsyncFor
188+
) -> None:
181189
"""Report P001 when string += count meets threshold."""
182190
counter = _LoopBodyConcatCounter(
183191
self._is_suppressed_for_rule,
@@ -200,7 +208,9 @@ def _check_string_concatenations(self, loop_node: ast.For | ast.While) -> None:
200208
),
201209
)
202210

203-
def _check_duplicate_calls(self, loop_node: ast.For | ast.While) -> None:
211+
def _check_duplicate_calls(
212+
self, loop_node: ast.For | ast.While | ast.AsyncFor
213+
) -> None:
204214
"""Report P007 when identical calls repeat within a loop body."""
205215
counter = _LoopBodyCallCounter(self._is_suppressed_for_rule, "P007")
206216
counter.visit(loop_node)

tests/test_ast_visitor.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
import ast
44

55
from pyrefactor.ast_visitor import (
6+
BaseDetector,
67
build_parent_map,
78
calculate_cyclomatic_complexity,
89
count_branches,
910
count_nesting_depth,
1011
node_col_offset,
1112
node_lineno,
1213
)
14+
from pyrefactor.config import Config
1315

1416

1517
class TestNodeLineno:
@@ -154,3 +156,34 @@ def func(value):
154156
func_def = tree.body[0]
155157
assert isinstance(func_def, ast.FunctionDef)
156158
assert count_branches(func_def) >= 2
159+
160+
161+
class _SuppressionProbeDetector(BaseDetector):
162+
"""Minimal detector for exercising suppression helpers."""
163+
164+
def get_detector_name(self) -> str:
165+
return "suppression_probe"
166+
167+
168+
class TestBaseDetectorSuppression:
169+
"""Tests for BaseDetector suppression behavior."""
170+
171+
def test_noqa_suppresses_all_rules(self) -> None:
172+
"""Test blanket # noqa suppresses every rule on the line."""
173+
source_lines = ["x = 1 # noqa"]
174+
detector = _SuppressionProbeDetector(Config(), "test.py", source_lines)
175+
tree = ast.parse("x = 1")
176+
node = tree.body[0]
177+
178+
assert detector.is_suppressed(node, "C001") is True
179+
assert detector.is_suppressed(node, "P001") is True
180+
181+
def test_rule_scoped_noqa_does_not_suppress_other_rules(self) -> None:
182+
"""Test rule-specific pyrefactor ignore does not blanket-suppress."""
183+
source_lines = ["x = 1 # pyrefactor: ignore C001"]
184+
detector = _SuppressionProbeDetector(Config(), "test.py", source_lines)
185+
tree = ast.parse("x = 1")
186+
node = tree.body[0]
187+
188+
assert detector.is_suppressed(node, "C001") is True
189+
assert detector.is_suppressed(node, "P001") is False

0 commit comments

Comments
 (0)