thailint 0.9.0__py3-none-any.whl → 0.10.0__py3-none-any.whl

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.
src/cli.py CHANGED
@@ -2010,5 +2010,132 @@ def _execute_stateless_class_lint( # pylint: disable=too-many-arguments,too-man
2010
2010
  sys.exit(1 if stateless_class_violations else 0)
2011
2011
 
2012
2012
 
2013
+ # Collection Pipeline command helper functions
2014
+
2015
+
2016
+ def _setup_pipeline_orchestrator(
2017
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
2018
+ ):
2019
+ """Set up orchestrator for pipeline command."""
2020
+ from src.orchestrator.core import Orchestrator
2021
+ from src.utils.project_root import get_project_root
2022
+
2023
+ # Use provided project_root or fall back to auto-detection
2024
+ if project_root is None:
2025
+ first_path = path_objs[0] if path_objs else Path.cwd()
2026
+ search_start = first_path if first_path.is_dir() else first_path.parent
2027
+ project_root = get_project_root(search_start)
2028
+
2029
+ orchestrator = Orchestrator(project_root=project_root)
2030
+
2031
+ if config_file:
2032
+ _load_config_file(orchestrator, config_file, verbose)
2033
+
2034
+ return orchestrator
2035
+
2036
+
2037
+ def _apply_pipeline_config_override(orchestrator, min_continues: int | None, verbose: bool):
2038
+ """Apply min_continues override to orchestrator config."""
2039
+ if min_continues is None:
2040
+ return
2041
+
2042
+ if "collection_pipeline" not in orchestrator.config:
2043
+ orchestrator.config["collection_pipeline"] = {}
2044
+
2045
+ orchestrator.config["collection_pipeline"]["min_continues"] = min_continues
2046
+ if verbose:
2047
+ logger.debug(f"Overriding min_continues to {min_continues}")
2048
+
2049
+
2050
+ def _run_pipeline_lint(orchestrator, path_objs: list[Path], recursive: bool):
2051
+ """Execute collection-pipeline lint on files or directories."""
2052
+ all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
2053
+ return [v for v in all_violations if "collection-pipeline" in v.rule_id]
2054
+
2055
+
2056
+ @cli.command("pipeline")
2057
+ @click.argument("paths", nargs=-1, type=click.Path())
2058
+ @click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
2059
+ @format_option
2060
+ @click.option("--min-continues", type=int, help="Override min continue guards to flag (default: 1)")
2061
+ @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
2062
+ @click.pass_context
2063
+ def pipeline( # pylint: disable=too-many-arguments,too-many-positional-arguments
2064
+ ctx,
2065
+ paths: tuple[str, ...],
2066
+ config_file: str | None,
2067
+ format: str,
2068
+ min_continues: int | None,
2069
+ recursive: bool,
2070
+ ):
2071
+ """Check for collection pipeline anti-patterns in code.
2072
+
2073
+ Detects for loops with embedded if/continue filtering patterns that could
2074
+ be refactored to use collection pipelines (generator expressions, filter()).
2075
+
2076
+ PATHS: Files or directories to lint (defaults to current directory if none provided)
2077
+
2078
+ Examples:
2079
+
2080
+ \b
2081
+ # Check current directory (all Python files recursively)
2082
+ thai-lint pipeline
2083
+
2084
+ \b
2085
+ # Check specific directory
2086
+ thai-lint pipeline src/
2087
+
2088
+ \b
2089
+ # Check single file
2090
+ thai-lint pipeline src/app.py
2091
+
2092
+ \b
2093
+ # Only flag loops with 2+ continue guards
2094
+ thai-lint pipeline --min-continues 2 src/
2095
+
2096
+ \b
2097
+ # Get JSON output
2098
+ thai-lint pipeline --format json .
2099
+
2100
+ \b
2101
+ # Get SARIF output for CI/CD integration
2102
+ thai-lint pipeline --format sarif src/
2103
+
2104
+ \b
2105
+ # Use custom config file
2106
+ thai-lint pipeline --config .thailint.yaml src/
2107
+ """
2108
+ verbose = ctx.obj.get("verbose", False)
2109
+ project_root = _get_project_root_from_context(ctx)
2110
+
2111
+ if not paths:
2112
+ paths = (".",)
2113
+
2114
+ path_objs = [Path(p) for p in paths]
2115
+
2116
+ try:
2117
+ _execute_pipeline_lint(
2118
+ path_objs, config_file, format, min_continues, recursive, verbose, project_root
2119
+ )
2120
+ except Exception as e:
2121
+ _handle_linting_error(e, verbose)
2122
+
2123
+
2124
+ def _execute_pipeline_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
2125
+ path_objs, config_file, format, min_continues, recursive, verbose, project_root=None
2126
+ ):
2127
+ """Execute collection-pipeline lint."""
2128
+ _validate_paths_exist(path_objs)
2129
+ orchestrator = _setup_pipeline_orchestrator(path_objs, config_file, verbose, project_root)
2130
+ _apply_pipeline_config_override(orchestrator, min_continues, verbose)
2131
+ pipeline_violations = _run_pipeline_lint(orchestrator, path_objs, recursive)
2132
+
2133
+ if verbose:
2134
+ logger.info(f"Found {len(pipeline_violations)} collection-pipeline violation(s)")
2135
+
2136
+ format_violations(pipeline_violations, format)
2137
+ sys.exit(1 if pipeline_violations else 0)
2138
+
2139
+
2013
2140
  if __name__ == "__main__":
2014
2141
  cli()
src/config.py CHANGED
@@ -103,9 +103,8 @@ def _load_from_explicit_path(config_path: Path) -> dict[str, Any]:
103
103
 
104
104
  def _load_from_default_locations() -> dict[str, Any]:
105
105
  """Load config from default locations."""
106
- for location in CONFIG_LOCATIONS:
107
- if not location.exists():
108
- continue
106
+ existing_locations = (loc for loc in CONFIG_LOCATIONS if loc.exists())
107
+ for location in existing_locations:
109
108
  loaded_config = _try_load_from_location(location)
110
109
  if loaded_config:
111
110
  return loaded_config
@@ -20,6 +20,7 @@ Implementation: Package traversal with pkgutil, class introspection with inspect
20
20
  import importlib
21
21
  import inspect
22
22
  import pkgutil
23
+ from types import ModuleType
23
24
  from typing import Any
24
25
 
25
26
  from .base import BaseLintRule
@@ -87,19 +88,51 @@ def _discover_from_module(module_path: str) -> list[BaseLintRule]:
87
88
  Returns:
88
89
  List of discovered rule instances
89
90
  """
91
+ module = _try_import_module(module_path)
92
+ if module is None:
93
+ return []
94
+ return _extract_rules_from_module(module)
95
+
96
+
97
+ def _try_import_module(module_path: str) -> ModuleType | None:
98
+ """Try to import a module, returning None on failure.
99
+
100
+ Args:
101
+ module_path: Full module path to import
102
+
103
+ Returns:
104
+ Module object or None if import fails
105
+ """
90
106
  try:
91
- module = importlib.import_module(module_path)
107
+ return importlib.import_module(module_path)
92
108
  except (ImportError, AttributeError):
93
- return []
109
+ return None
94
110
 
95
- rules = []
96
- for _name, obj in inspect.getmembers(module):
97
- if not _is_rule_class(obj):
98
- continue
99
- rule_instance = _try_instantiate_rule(obj)
100
- if rule_instance:
101
- rules.append(rule_instance)
102
- return rules
111
+
112
+ def _extract_rules_from_module(module: ModuleType) -> list[BaseLintRule]:
113
+ """Extract rule instances from a module.
114
+
115
+ Args:
116
+ module: Imported module to scan
117
+
118
+ Returns:
119
+ List of discovered rule instances
120
+ """
121
+ rule_classes = [obj for _name, obj in inspect.getmembers(module) if _is_rule_class(obj)]
122
+ return _instantiate_rules(rule_classes)
123
+
124
+
125
+ def _instantiate_rules(rule_classes: list[type[BaseLintRule]]) -> list[BaseLintRule]:
126
+ """Instantiate a list of rule classes.
127
+
128
+ Args:
129
+ rule_classes: List of rule classes to instantiate
130
+
131
+ Returns:
132
+ List of successfully instantiated rules
133
+ """
134
+ instances = (_try_instantiate_rule(cls) for cls in rule_classes)
135
+ return [inst for inst in instances if inst is not None]
103
136
 
104
137
 
105
138
  def _try_instantiate_rule(rule_class: type[BaseLintRule]) -> BaseLintRule | None:
@@ -0,0 +1,90 @@
1
+ """
2
+ Purpose: Collection pipeline linter package initialization
3
+
4
+ Scope: Exports for collection-pipeline linter module
5
+
6
+ Overview: Initializes the collection-pipeline linter package and exposes the main rule class
7
+ for external use. Exports CollectionPipelineRule as the primary interface for the linter,
8
+ allowing the orchestrator to discover and instantiate the rule. Also exports configuration
9
+ and detector classes for advanced use cases. Provides a convenience lint() function for
10
+ direct usage without orchestrator setup. This module serves as the entry point for
11
+ the collection-pipeline linter functionality within the thai-lint framework.
12
+
13
+ Dependencies: CollectionPipelineRule, CollectionPipelineConfig, PipelinePatternDetector
14
+
15
+ Exports: CollectionPipelineRule (primary), CollectionPipelineConfig, PipelinePatternDetector, lint
16
+
17
+ Interfaces: Standard Python package initialization with __all__ for explicit exports
18
+
19
+ Implementation: Simple re-export pattern for package interface, convenience lint function
20
+ """
21
+
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from .config import DEFAULT_MIN_CONTINUES, CollectionPipelineConfig
26
+ from .detector import PatternMatch, PipelinePatternDetector
27
+ from .linter import CollectionPipelineRule
28
+
29
+ __all__ = [
30
+ "CollectionPipelineRule",
31
+ "CollectionPipelineConfig",
32
+ "PipelinePatternDetector",
33
+ "PatternMatch",
34
+ "lint",
35
+ ]
36
+
37
+
38
+ def lint(
39
+ path: Path | str,
40
+ config: dict[str, Any] | None = None,
41
+ min_continues: int = DEFAULT_MIN_CONTINUES,
42
+ ) -> list:
43
+ """Lint a file or directory for collection pipeline violations.
44
+
45
+ Args:
46
+ path: Path to file or directory to lint
47
+ config: Configuration dict (optional, uses defaults if not provided)
48
+ min_continues: Minimum if/continue patterns to flag (default: 1)
49
+
50
+ Returns:
51
+ List of violations found
52
+
53
+ Example:
54
+ >>> from src.linters.collection_pipeline import lint
55
+ >>> violations = lint('src/my_module.py', min_continues=2)
56
+ >>> for v in violations:
57
+ ... print(f"{v.file_path}:{v.line} - {v.message}")
58
+ """
59
+ path_obj = Path(path) if isinstance(path, str) else path
60
+ project_root = path_obj if path_obj.is_dir() else path_obj.parent
61
+
62
+ orchestrator = _setup_pipeline_orchestrator(project_root, config, min_continues)
63
+ violations = _execute_pipeline_lint(orchestrator, path_obj)
64
+
65
+ return [v for v in violations if "collection-pipeline" in v.rule_id]
66
+
67
+
68
+ def _setup_pipeline_orchestrator(
69
+ project_root: Path, config: dict[str, Any] | None, min_continues: int
70
+ ) -> Any:
71
+ """Set up orchestrator with collection-pipeline config."""
72
+ from src.orchestrator.core import Orchestrator
73
+
74
+ orchestrator = Orchestrator(project_root=project_root)
75
+
76
+ if config:
77
+ orchestrator.config["collection-pipeline"] = config
78
+ else:
79
+ orchestrator.config["collection-pipeline"] = {"min_continues": min_continues}
80
+
81
+ return orchestrator
82
+
83
+
84
+ def _execute_pipeline_lint(orchestrator: Any, path_obj: Path) -> list:
85
+ """Execute linting on file or directory."""
86
+ if path_obj.is_file():
87
+ return orchestrator.lint_file(path_obj)
88
+ if path_obj.is_dir():
89
+ return orchestrator.lint_directory(path_obj)
90
+ return []
@@ -0,0 +1,63 @@
1
+ """
2
+ Purpose: Configuration dataclass for collection-pipeline linter
3
+
4
+ Scope: Define configurable options for embedded filtering pattern detection
5
+
6
+ Overview: Provides CollectionPipelineConfig for customizing linter behavior including
7
+ minimum number of continue patterns to flag, enable/disable toggle, and ignore
8
+ patterns. Integrates with the orchestrator's configuration system to allow users
9
+ to customize collection-pipeline detection via .thailint.yaml configuration files.
10
+ Follows the same configuration pattern as other thai-lint linters.
11
+
12
+ Dependencies: dataclasses, typing
13
+
14
+ Exports: CollectionPipelineConfig dataclass, DEFAULT_MIN_CONTINUES constant
15
+
16
+ Interfaces: CollectionPipelineConfig.from_dict() class method for configuration loading
17
+
18
+ Implementation: Dataclass with sensible defaults and config loading from dictionary
19
+ """
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ # Default threshold for minimum continue guards to flag
25
+ DEFAULT_MIN_CONTINUES = 1
26
+
27
+
28
+ @dataclass
29
+ class CollectionPipelineConfig:
30
+ """Configuration for collection-pipeline linter."""
31
+
32
+ enabled: bool = True
33
+ """Whether the linter is enabled."""
34
+
35
+ min_continues: int = DEFAULT_MIN_CONTINUES
36
+ """Minimum number of if/continue patterns required to flag a violation."""
37
+
38
+ ignore: list[str] = field(default_factory=list)
39
+ """File patterns to ignore."""
40
+
41
+ def __post_init__(self) -> None:
42
+ """Validate configuration values."""
43
+ if self.min_continues < 1:
44
+ raise ValueError(f"min_continues must be at least 1, got {self.min_continues}")
45
+
46
+ @classmethod
47
+ def from_dict(
48
+ cls, config: dict[str, Any], language: str | None = None
49
+ ) -> "CollectionPipelineConfig":
50
+ """Load configuration from dictionary.
51
+
52
+ Args:
53
+ config: Dictionary containing configuration values
54
+ language: Programming language (unused, for interface compatibility)
55
+
56
+ Returns:
57
+ CollectionPipelineConfig instance with values from dictionary
58
+ """
59
+ return cls(
60
+ enabled=config.get("enabled", True),
61
+ min_continues=config.get("min_continues", DEFAULT_MIN_CONTINUES),
62
+ ignore=config.get("ignore", []),
63
+ )
@@ -0,0 +1,100 @@
1
+ """
2
+ Purpose: Analyze continue guard patterns in for loops
3
+
4
+ Scope: Extract and validate if/continue patterns from loop bodies
5
+
6
+ Overview: Provides helper functions for analyzing continue guard patterns in for loop
7
+ bodies. Handles extraction of sequential if/continue statements, validation of
8
+ simple continue-only patterns, and detection of side effects in conditions.
9
+ Separates pattern analysis logic from main detection for better maintainability.
10
+
11
+ Dependencies: ast module for Python AST processing
12
+
13
+ Exports: extract_continue_patterns, is_continue_only, has_side_effects, has_body_after_continues
14
+
15
+ Interfaces: Functions for analyzing continue patterns in AST structures
16
+
17
+ Implementation: AST-based pattern matching for continue guard identification
18
+ """
19
+
20
+ import ast
21
+
22
+
23
+ def extract_continue_patterns(body: list[ast.stmt]) -> list[ast.If]:
24
+ """Extract leading if statements that only contain continue.
25
+
26
+ Args:
27
+ body: List of statements in for loop body
28
+
29
+ Returns:
30
+ List of ast.If nodes that are continue guards
31
+ """
32
+ continues: list[ast.If] = []
33
+ for stmt in body:
34
+ if not isinstance(stmt, ast.If):
35
+ break
36
+ if not is_continue_only(stmt):
37
+ break
38
+ continues.append(stmt)
39
+ return continues
40
+
41
+
42
+ def is_continue_only(if_node: ast.If) -> bool:
43
+ """Check if an if statement only contains continue.
44
+
45
+ Args:
46
+ if_node: AST If node to check
47
+
48
+ Returns:
49
+ True if the if statement is a simple continue guard
50
+ """
51
+ if len(if_node.body) != 1:
52
+ return False
53
+ if not isinstance(if_node.body[0], ast.Continue):
54
+ return False
55
+ if if_node.orelse:
56
+ return False
57
+ return True
58
+
59
+
60
+ def has_side_effects(continues: list[ast.If]) -> bool:
61
+ """Check if any condition has side effects.
62
+
63
+ Args:
64
+ continues: List of continue guard if statements
65
+
66
+ Returns:
67
+ True if any condition has side effects (e.g., walrus operator)
68
+ """
69
+ for if_node in continues:
70
+ if _condition_has_side_effects(if_node.test):
71
+ return True
72
+ return False
73
+
74
+
75
+ def _condition_has_side_effects(node: ast.expr) -> bool:
76
+ """Check if expression has side effects.
77
+
78
+ Args:
79
+ node: AST expression node to check
80
+
81
+ Returns:
82
+ True if expression has side effects
83
+ """
84
+ for child in ast.walk(node):
85
+ if isinstance(child, ast.NamedExpr):
86
+ return True
87
+ return False
88
+
89
+
90
+ def has_body_after_continues(body: list[ast.stmt], num_continues: int) -> bool:
91
+ """Check if there are statements after continue guards.
92
+
93
+ Args:
94
+ body: List of statements in for loop body
95
+ num_continues: Number of continue guards detected
96
+
97
+ Returns:
98
+ True if there are statements after the continue guards
99
+ """
100
+ return len(body) > num_continues
@@ -0,0 +1,130 @@
1
+ """
2
+ Purpose: AST-based detection of collection pipeline anti-patterns
3
+
4
+ Scope: Pattern matching for for loops with embedded filtering via if/continue
5
+
6
+ Overview: Implements the core detection logic for identifying imperative loop patterns
7
+ that use if/continue for filtering instead of collection pipelines. Uses Python's
8
+ AST module to analyze code structure and identify refactoring opportunities. Detects
9
+ patterns like 'for x in iter: if not cond: continue; action(x)' and suggests
10
+ refactoring to generator expressions or filter(). Handles edge cases like walrus
11
+ operators (side effects), else branches, and empty loop bodies.
12
+
13
+ Dependencies: ast module, continue_analyzer, suggestion_builder
14
+
15
+ Exports: PipelinePatternDetector class, PatternMatch dataclass
16
+
17
+ Interfaces: PipelinePatternDetector.detect_patterns() -> list[PatternMatch]
18
+
19
+ Implementation: AST visitor pattern with delegated pattern matching and suggestion generation
20
+ """
21
+
22
+ import ast
23
+ from dataclasses import dataclass
24
+
25
+ from . import continue_analyzer, suggestion_builder
26
+
27
+
28
+ @dataclass
29
+ class PatternMatch:
30
+ """Represents a detected anti-pattern."""
31
+
32
+ line_number: int
33
+ """Line number where the for loop starts (1-indexed)."""
34
+
35
+ loop_var: str
36
+ """Name of the loop variable."""
37
+
38
+ iterable: str
39
+ """Source representation of the iterable."""
40
+
41
+ conditions: list[str]
42
+ """List of filter conditions (inverted from continue guards)."""
43
+
44
+ has_side_effects: bool
45
+ """Whether any condition has side effects."""
46
+
47
+ suggestion: str
48
+ """Refactoring suggestion as a code snippet."""
49
+
50
+
51
+ class PipelinePatternDetector(ast.NodeVisitor):
52
+ """Detects for loops with embedded filtering via if/continue patterns."""
53
+
54
+ def __init__(self, source_code: str) -> None:
55
+ """Initialize detector with source code.
56
+
57
+ Args:
58
+ source_code: Python source code to analyze
59
+ """
60
+ self.source_code = source_code
61
+ self.matches: list[PatternMatch] = []
62
+
63
+ def detect_patterns(self) -> list[PatternMatch]:
64
+ """Analyze source code and return detected patterns.
65
+
66
+ Returns:
67
+ List of PatternMatch objects for each detected anti-pattern
68
+ """
69
+ try:
70
+ tree = ast.parse(self.source_code)
71
+ self.visit(tree)
72
+ except SyntaxError:
73
+ pass # Invalid Python, return empty list
74
+ return self.matches
75
+
76
+ def visit_For(self, node: ast.For) -> None: # pylint: disable=invalid-name
77
+ """Visit for loop and check for filtering patterns.
78
+
79
+ Args:
80
+ node: AST For node to analyze
81
+ """
82
+ match = self._analyze_for_loop(node)
83
+ if match is not None:
84
+ self.matches.append(match)
85
+ self.generic_visit(node)
86
+
87
+ def _analyze_for_loop(self, node: ast.For) -> PatternMatch | None:
88
+ """Analyze a for loop for embedded filtering patterns.
89
+
90
+ Args:
91
+ node: AST For node to analyze
92
+
93
+ Returns:
94
+ PatternMatch if pattern detected, None otherwise
95
+ """
96
+ continues = continue_analyzer.extract_continue_patterns(node.body)
97
+ if not continues:
98
+ return None
99
+
100
+ if continue_analyzer.has_side_effects(continues):
101
+ return None
102
+
103
+ if not continue_analyzer.has_body_after_continues(node.body, len(continues)):
104
+ return None
105
+
106
+ return self._create_match(node, continues)
107
+
108
+ def _create_match(self, for_node: ast.For, continues: list[ast.If]) -> PatternMatch:
109
+ """Create a PatternMatch from detected pattern.
110
+
111
+ Args:
112
+ for_node: AST For node
113
+ continues: List of continue guard if statements
114
+
115
+ Returns:
116
+ PatternMatch object with detection information
117
+ """
118
+ loop_var = suggestion_builder.get_target_name(for_node.target)
119
+ iterable = ast.unparse(for_node.iter)
120
+ conditions = [suggestion_builder.invert_condition(c.test) for c in continues]
121
+ suggestion = suggestion_builder.build_suggestion(loop_var, iterable, conditions)
122
+
123
+ return PatternMatch(
124
+ line_number=for_node.lineno,
125
+ loop_var=loop_var,
126
+ iterable=iterable,
127
+ conditions=conditions,
128
+ has_side_effects=False,
129
+ suggestion=suggestion,
130
+ )