python-code-validator 0.1.1__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.
- code_validator/__init__.py +25 -0
- code_validator/__main__.py +11 -0
- code_validator/cli.py +88 -0
- code_validator/components/__init__.py +0 -0
- code_validator/components/ast_utils.py +40 -0
- code_validator/components/definitions.py +88 -0
- code_validator/components/factories.py +243 -0
- code_validator/components/scope_handler.py +59 -0
- code_validator/config.py +99 -0
- code_validator/core.py +100 -0
- code_validator/exceptions.py +48 -0
- code_validator/output.py +90 -0
- code_validator/rules_library/__init__.py +0 -0
- code_validator/rules_library/basic_rules.py +167 -0
- code_validator/rules_library/constraint_logic.py +257 -0
- code_validator/rules_library/selector_nodes.py +319 -0
- python_code_validator-0.1.1.dist-info/METADATA +308 -0
- python_code_validator-0.1.1.dist-info/RECORD +22 -0
- python_code_validator-0.1.1.dist-info/WHEEL +5 -0
- python_code_validator-0.1.1.dist-info/entry_points.txt +2 -0
- python_code_validator-0.1.1.dist-info/licenses/LICENSE +21 -0
- python_code_validator-0.1.1.dist-info/top_level.txt +1 -0
code_validator/core.py
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
import ast
|
2
|
+
import json
|
3
|
+
|
4
|
+
from .components.ast_utils import enrich_ast_with_parents
|
5
|
+
from .components.definitions import Rule
|
6
|
+
from .components.factories import RuleFactory
|
7
|
+
from .config import AppConfig, LogLevel
|
8
|
+
from .exceptions import RuleParsingError
|
9
|
+
from .output import Console
|
10
|
+
|
11
|
+
|
12
|
+
class StaticValidator:
|
13
|
+
"""Orchestrates the static validation process."""
|
14
|
+
|
15
|
+
def __init__(self, config: AppConfig, console: Console):
|
16
|
+
"""Initializes the validator with configuration and an output handler."""
|
17
|
+
self._config = config
|
18
|
+
self._console = console
|
19
|
+
self._rule_factory = RuleFactory(self._console)
|
20
|
+
self._source_code: str = ""
|
21
|
+
self._ast_tree: ast.Module | None = None
|
22
|
+
self._validation_rules: list[Rule] = []
|
23
|
+
self._failed_rules: list[int] = []
|
24
|
+
|
25
|
+
@property
|
26
|
+
def failed_rules_id(self) -> list[int]:
|
27
|
+
"""Returns a list of rule IDs that failed during the last run."""
|
28
|
+
return self._failed_rules
|
29
|
+
|
30
|
+
def _load_source_code(self) -> None:
|
31
|
+
"""Loads the content of the student's solution file."""
|
32
|
+
self._console.print(f"Reading source file: {self._config.solution_path}")
|
33
|
+
try:
|
34
|
+
self._source_code = self._config.solution_path.read_text(encoding="utf-8")
|
35
|
+
except FileNotFoundError:
|
36
|
+
raise
|
37
|
+
except Exception as e:
|
38
|
+
raise RuleParsingError(f"Cannot read source file: {e}") from e
|
39
|
+
|
40
|
+
def _parse_ast_tree(self) -> bool:
|
41
|
+
"""Parses the loaded source code into an AST."""
|
42
|
+
self._console.print("Parsing Abstract Syntax Tree (AST)...")
|
43
|
+
try:
|
44
|
+
self._ast_tree = ast.parse(self._source_code)
|
45
|
+
enrich_ast_with_parents(self._ast_tree)
|
46
|
+
return True
|
47
|
+
except SyntaxError as e:
|
48
|
+
# Ищем правило check_syntax, чтобы вывести его кастомное сообщение
|
49
|
+
for rule in self._validation_rules:
|
50
|
+
if getattr(rule.config, "type", None) == "check_syntax":
|
51
|
+
self._console.print(rule.config.message, level="ERROR")
|
52
|
+
self._failed_rules.append(rule.config.rule_id)
|
53
|
+
return False
|
54
|
+
# Если такого правила нет, выводим стандартное сообщение
|
55
|
+
self._console.print(f"Syntax Error found: {e}", level="ERROR")
|
56
|
+
return False
|
57
|
+
|
58
|
+
def _load_and_parse_rules(self) -> None:
|
59
|
+
"""Loads and parses the JSON file with validation rules."""
|
60
|
+
self._console.print(f"Loading rules from: {self._config.rules_path}")
|
61
|
+
try:
|
62
|
+
rules_data = json.loads(self._config.rules_path.read_text(encoding="utf-8"))
|
63
|
+
raw_rules = rules_data.get("validation_rules")
|
64
|
+
if not isinstance(raw_rules, list):
|
65
|
+
raise RuleParsingError("`validation_rules` key not found or is not a list.")
|
66
|
+
|
67
|
+
self._validation_rules = [self._rule_factory.create(rule) for rule in raw_rules]
|
68
|
+
self._console.print(f"Successfully parsed {len(self._validation_rules)} rules.")
|
69
|
+
except json.JSONDecodeError as e:
|
70
|
+
raise RuleParsingError(f"Invalid JSON in rules file: {e}") from e
|
71
|
+
except FileNotFoundError:
|
72
|
+
raise
|
73
|
+
|
74
|
+
def run(self) -> bool:
|
75
|
+
"""Runs the entire validation process."""
|
76
|
+
try:
|
77
|
+
self._load_source_code()
|
78
|
+
self._load_and_parse_rules() # Загружаем правила до парсинга AST
|
79
|
+
|
80
|
+
if not self._parse_ast_tree():
|
81
|
+
return False
|
82
|
+
|
83
|
+
except (FileNotFoundError, RuleParsingError):
|
84
|
+
raise
|
85
|
+
|
86
|
+
for rule in self._validation_rules:
|
87
|
+
# check_syntax уже обработан в _parse_ast_tree, пропускаем его
|
88
|
+
if getattr(rule.config, "type", None) == "check_syntax":
|
89
|
+
continue
|
90
|
+
|
91
|
+
self._console.print(f"Executing rule: {rule.config.rule_id}", level=LogLevel.DEBUG)
|
92
|
+
is_passed = rule.execute(self._ast_tree, self._source_code)
|
93
|
+
if not is_passed:
|
94
|
+
self._console.print(rule.config.message, level="ERROR")
|
95
|
+
self._failed_rules.append(rule.config.rule_id)
|
96
|
+
if getattr(rule.config, "is_critical", False) or self._config.stop_on_first_fail:
|
97
|
+
self._console.print("Critical rule failed. Halting validation.", level="WARNING")
|
98
|
+
break
|
99
|
+
|
100
|
+
return not self._failed_rules
|
@@ -0,0 +1,48 @@
|
|
1
|
+
"""Defines custom exceptions for the code validator application.
|
2
|
+
|
3
|
+
These custom exception classes allow for more specific error handling and
|
4
|
+
provide clearer, more informative error messages throughout the application.
|
5
|
+
"""
|
6
|
+
|
7
|
+
|
8
|
+
class CodeValidatorError(Exception):
|
9
|
+
"""Base exception for all custom errors raised by this application."""
|
10
|
+
|
11
|
+
pass
|
12
|
+
|
13
|
+
|
14
|
+
class RuleParsingError(CodeValidatorError):
|
15
|
+
"""Raised when a validation rule in the JSON file is malformed or invalid.
|
16
|
+
|
17
|
+
This error indicates a problem with the configuration of a rule, not with
|
18
|
+
the code being validated.
|
19
|
+
|
20
|
+
Attributes:
|
21
|
+
rule_id (int | str | None): The ID of the rule that caused the error,
|
22
|
+
if available.
|
23
|
+
"""
|
24
|
+
|
25
|
+
def __init__(self, message: str, rule_id: int | str | None = None):
|
26
|
+
"""Initializes the RuleParsingError.
|
27
|
+
|
28
|
+
Args:
|
29
|
+
message (str): The specific error message describing the problem.
|
30
|
+
rule_id (int | str | None): The ID of the problematic rule.
|
31
|
+
"""
|
32
|
+
self.rule_id = rule_id
|
33
|
+
if rule_id:
|
34
|
+
super().__init__(f"Error parsing rule '{rule_id}': {message}")
|
35
|
+
else:
|
36
|
+
super().__init__(f"Error parsing rules file: {message}")
|
37
|
+
|
38
|
+
|
39
|
+
class ValidationFailedError(CodeValidatorError):
|
40
|
+
"""Raised to signal that the source code did not pass validation.
|
41
|
+
|
42
|
+
Note:
|
43
|
+
Currently, this exception is defined but not actively raised, as the
|
44
|
+
application handles validation failure via exit codes in the CLI.
|
45
|
+
It is kept for potential future use in a library context.
|
46
|
+
"""
|
47
|
+
|
48
|
+
pass
|
code_validator/output.py
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
"""Handles all console output and logging for the application.
|
2
|
+
|
3
|
+
This module provides a centralized way to manage user-facing messages and
|
4
|
+
internal logging. It ensures that all output is consistent and can be
|
5
|
+
controlled via configuration (e.g., log levels, silent mode).
|
6
|
+
"""
|
7
|
+
|
8
|
+
import logging
|
9
|
+
import sys
|
10
|
+
from typing import Literal
|
11
|
+
|
12
|
+
from .config import LogLevel
|
13
|
+
|
14
|
+
LOG_FORMAT = "%(asctime)s | %(filename)-15s | %(funcName)-15s (%(lineno)-3s) | [%(levelname)s] - %(message)s"
|
15
|
+
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
16
|
+
|
17
|
+
|
18
|
+
def setup_logging(log_level: LogLevel) -> logging.Logger:
|
19
|
+
"""Configures the root logger for the application.
|
20
|
+
|
21
|
+
Sets up the basic configuration for logging, including the level,
|
22
|
+
message format, and date format.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
log_level: The minimum level of logs to display.
|
26
|
+
|
27
|
+
Returns:
|
28
|
+
The configured root logger instance.
|
29
|
+
"""
|
30
|
+
logging.basicConfig(level=log_level, format=LOG_FORMAT, datefmt=DATE_FORMAT)
|
31
|
+
return logging.getLogger()
|
32
|
+
|
33
|
+
|
34
|
+
class Console:
|
35
|
+
"""A centralized handler for printing messages to stdout and logging.
|
36
|
+
|
37
|
+
This class abstracts all output operations, allowing for consistent
|
38
|
+
formatting and easy control over verbosity (e.g., silent mode). It ensures
|
39
|
+
that every user-facing message is also properly logged.
|
40
|
+
|
41
|
+
Attributes:
|
42
|
+
_logger (logging.Logger): The logger instance used for all log records.
|
43
|
+
_is_silent (bool): A flag to suppress printing to stdout.
|
44
|
+
_stdout (TextIO): The stream to write messages to (defaults to sys.stdout).
|
45
|
+
|
46
|
+
Example:
|
47
|
+
>>> import logging
|
48
|
+
>>> logger = logging.getLogger(__name__)
|
49
|
+
>>> console = Console(logger)
|
50
|
+
>>> console.print("This is an informational message.")
|
51
|
+
This is an informational message.
|
52
|
+
>>> console.print("This is a warning.", level="WARNING")
|
53
|
+
This is a warning.
|
54
|
+
"""
|
55
|
+
|
56
|
+
def __init__(self, logger: logging.Logger, *, is_silent: bool = False):
|
57
|
+
"""Initializes the Console handler.
|
58
|
+
|
59
|
+
Args:
|
60
|
+
logger: The configured logger instance to use for logging.
|
61
|
+
is_silent: If True, suppresses output to stdout. Defaults to False.
|
62
|
+
"""
|
63
|
+
self._logger = logger
|
64
|
+
self._is_silent = is_silent
|
65
|
+
self._stdout = sys.stdout
|
66
|
+
|
67
|
+
def print(
|
68
|
+
self,
|
69
|
+
message: str,
|
70
|
+
*,
|
71
|
+
level: LogLevel | Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = LogLevel.INFO,
|
72
|
+
) -> None:
|
73
|
+
"""Prints a message to stdout and logs it simultaneously.
|
74
|
+
|
75
|
+
The message is always sent to the logger with the specified level. It is
|
76
|
+
printed to the configured stdout stream only if the console is not in
|
77
|
+
silent mode.
|
78
|
+
|
79
|
+
Args:
|
80
|
+
message: The string message to be displayed and logged.
|
81
|
+
level: The logging level for the message. Accepts both LogLevel
|
82
|
+
enum members and their string representations.
|
83
|
+
Defaults to LogLevel.INFO.
|
84
|
+
"""
|
85
|
+
level_str = level.value.lower() if isinstance(level, LogLevel) else level.lower()
|
86
|
+
log_method = getattr(self._logger, level_str)
|
87
|
+
log_method(message)
|
88
|
+
|
89
|
+
if not self._is_silent:
|
90
|
+
print(message, file=self._stdout)
|
File without changes
|
@@ -0,0 +1,167 @@
|
|
1
|
+
# src/code_validator/rules_library/basic_rules.py
|
2
|
+
|
3
|
+
"""Contains concrete implementations of executable validation rules.
|
4
|
+
|
5
|
+
This module defines the handler classes for both "short" (pre-defined) and
|
6
|
+
"full" (custom selector/constraint) rules. Each class implements the `Rule`
|
7
|
+
protocol and encapsulates the logic for a specific type of validation check.
|
8
|
+
"""
|
9
|
+
|
10
|
+
import ast
|
11
|
+
import subprocess
|
12
|
+
import sys
|
13
|
+
|
14
|
+
from ..components.definitions import Constraint, Rule, Selector
|
15
|
+
from ..config import FullRuleConfig, ShortRuleConfig
|
16
|
+
from ..output import Console, LogLevel
|
17
|
+
|
18
|
+
|
19
|
+
class CheckSyntaxRule(Rule):
|
20
|
+
"""Handles the 'check_syntax' short rule.
|
21
|
+
|
22
|
+
Note:
|
23
|
+
The actual syntax validation is performed preemptively in the core
|
24
|
+
validator engine when it calls `ast.parse()`. Therefore, this rule's
|
25
|
+
`execute` method will only be called if the syntax is already valid.
|
26
|
+
Its primary purpose is to exist so that a `check_syntax` rule can be
|
27
|
+
formally defined in the JSON configuration.
|
28
|
+
"""
|
29
|
+
|
30
|
+
def __init__(self, config: ShortRuleConfig, console: Console):
|
31
|
+
"""Initializes the syntax check rule handler.
|
32
|
+
|
33
|
+
Args:
|
34
|
+
config: The configuration object for this short rule.
|
35
|
+
console: The console handler for output.
|
36
|
+
"""
|
37
|
+
self.config = config
|
38
|
+
self._console = console
|
39
|
+
|
40
|
+
def execute(self, tree: ast.Module | None, source_code: str | None = None) -> bool:
|
41
|
+
"""Confirms that syntax is valid.
|
42
|
+
|
43
|
+
This method is guaranteed to be called only after a successful AST parsing.
|
44
|
+
|
45
|
+
Returns:
|
46
|
+
Always returns True.
|
47
|
+
"""
|
48
|
+
self._console.print(f"Rule {self.config.rule_id}: Syntax is valid.", level=LogLevel.DEBUG)
|
49
|
+
return True
|
50
|
+
|
51
|
+
|
52
|
+
class CheckLinterRule(Rule):
|
53
|
+
"""Handles the 'check_linter_pep8' short rule by running flake8.
|
54
|
+
|
55
|
+
This rule executes the `flake8` linter as an external subprocess on the
|
56
|
+
source code to check for style and common programming errors. It is
|
57
|
+
configurable via the 'params' field in the JSON rule.
|
58
|
+
"""
|
59
|
+
|
60
|
+
def __init__(self, config: ShortRuleConfig, console: Console):
|
61
|
+
"""Initializes a PEP8 linter check rule handler."""
|
62
|
+
self.config = config
|
63
|
+
self._console = console
|
64
|
+
|
65
|
+
def execute(self, tree: ast.Module | None, source_code: str | None = None) -> bool:
|
66
|
+
"""Executes the flake8 linter on the source code via a subprocess.
|
67
|
+
|
68
|
+
It constructs a command-line call to `flake8`, passing the source code
|
69
|
+
via stdin. This approach ensures isolation and uses flake8's stable
|
70
|
+
CLI interface.
|
71
|
+
|
72
|
+
Args:
|
73
|
+
tree: Not used by this rule.
|
74
|
+
source_code: The raw source code string to be linted.
|
75
|
+
|
76
|
+
Returns:
|
77
|
+
True if no PEP8 violations are found, False otherwise.
|
78
|
+
"""
|
79
|
+
if not source_code:
|
80
|
+
self._console.print("Source code is empty, skipping PEP8 check.", level="WARNING")
|
81
|
+
return True
|
82
|
+
|
83
|
+
self._console.print(f"Rule {self.config.rule_id}: Running flake8 linter...", level="DEBUG")
|
84
|
+
|
85
|
+
params = self.config.params
|
86
|
+
args = [sys.executable, "-m", "flake8", "-"]
|
87
|
+
|
88
|
+
if select_list := params.get("select"):
|
89
|
+
args.append(f"--select={','.join(select_list)}")
|
90
|
+
elif ignore_list := params.get("ignore"):
|
91
|
+
args.append(f"--ignore={','.join(ignore_list)}")
|
92
|
+
|
93
|
+
try:
|
94
|
+
process = subprocess.run(
|
95
|
+
args,
|
96
|
+
input=source_code,
|
97
|
+
capture_output=True,
|
98
|
+
text=True,
|
99
|
+
encoding="utf-8",
|
100
|
+
check=False,
|
101
|
+
)
|
102
|
+
|
103
|
+
if process.returncode != 0 and process.stdout:
|
104
|
+
linter_output = process.stdout.strip()
|
105
|
+
self._console.print(f"Flake8 found issues:\n{linter_output}", level="DEBUG")
|
106
|
+
return False
|
107
|
+
elif process.returncode != 0:
|
108
|
+
self._console.print(f"Flake8 exited with code {process.returncode}:\n{process.stderr}", level="ERROR")
|
109
|
+
return False
|
110
|
+
|
111
|
+
self._console.print("PEP8 check passed.", level="DEBUG")
|
112
|
+
return True
|
113
|
+
except FileNotFoundError:
|
114
|
+
self._console.print("flake8 not found. Is it installed in the venv?", level="CRITICAL")
|
115
|
+
return False
|
116
|
+
except Exception as e:
|
117
|
+
self._console.print(f"An unexpected error occurred while running flake8: {e}", level="CRITICAL")
|
118
|
+
return False
|
119
|
+
|
120
|
+
|
121
|
+
class FullRuleHandler(Rule):
|
122
|
+
"""Handles a full, custom rule composed of a selector and a constraint.
|
123
|
+
|
124
|
+
This class acts as a generic executor for complex rules. It does not contain
|
125
|
+
any specific validation logic itself but instead orchestrates the interaction
|
126
|
+
between a Selector and a Constraint object.
|
127
|
+
|
128
|
+
Attributes:
|
129
|
+
config (FullRuleConfig): The dataclass object holding the rule's config.
|
130
|
+
_selector (Selector): The selector object responsible for finding nodes.
|
131
|
+
_constraint (Constraint): The constraint object for checking the nodes.
|
132
|
+
_console (Console): The console handler for logging.
|
133
|
+
"""
|
134
|
+
|
135
|
+
def __init__(self, config: FullRuleConfig, selector: Selector, constraint: Constraint, console: Console):
|
136
|
+
"""Initializes a full rule handler.
|
137
|
+
|
138
|
+
Args:
|
139
|
+
config: The configuration for the full rule.
|
140
|
+
selector: An initialized Selector object.
|
141
|
+
constraint: An initialized Constraint object.
|
142
|
+
console: The console handler for logging.
|
143
|
+
"""
|
144
|
+
self.config = config
|
145
|
+
self._selector = selector
|
146
|
+
self._constraint = constraint
|
147
|
+
self._console = console
|
148
|
+
|
149
|
+
def execute(self, tree: ast.Module | None, source_code: str | None = None) -> bool:
|
150
|
+
"""Executes the rule by running the selector and applying the constraint.
|
151
|
+
|
152
|
+
Args:
|
153
|
+
tree: The enriched AST of the source code.
|
154
|
+
source_code: Not used by this rule.
|
155
|
+
|
156
|
+
Returns:
|
157
|
+
The boolean result of applying the constraint to the selected nodes.
|
158
|
+
"""
|
159
|
+
if not tree:
|
160
|
+
self._console.print("AST not available, skipping rule.", level="WARNING")
|
161
|
+
return True
|
162
|
+
|
163
|
+
self._console.print(f"Applying selector: {self._selector.__class__.__name__}", level="DEBUG")
|
164
|
+
selected_nodes = self._selector.select(tree)
|
165
|
+
|
166
|
+
self._console.print(f"Applying constraint: {self._constraint.__class__.__name__}", level="DEBUG")
|
167
|
+
return self._constraint.check(selected_nodes)
|
@@ -0,0 +1,257 @@
|
|
1
|
+
"""Contains concrete implementations of all Constraint components.
|
2
|
+
|
3
|
+
Each class in this module implements the `Constraint` protocol and encapsulates
|
4
|
+
the logic for a specific condition that can be checked against a list of
|
5
|
+
AST nodes found by a Selector.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import ast
|
9
|
+
from typing import Any
|
10
|
+
|
11
|
+
from ..components.ast_utils import get_full_name
|
12
|
+
from ..components.definitions import Constraint
|
13
|
+
|
14
|
+
|
15
|
+
class IsRequiredConstraint(Constraint):
|
16
|
+
"""Checks that at least one node was found by the selector.
|
17
|
+
|
18
|
+
This constraint is used to enforce the presence of a required language
|
19
|
+
construct. It can also check for an exact number of occurrences.
|
20
|
+
|
21
|
+
JSON Params:
|
22
|
+
count (int, optional): If provided, checks if the number of found
|
23
|
+
nodes is exactly equal to this value.
|
24
|
+
"""
|
25
|
+
|
26
|
+
def __init__(self, **kwargs: Any):
|
27
|
+
"""Initializes the constraint.
|
28
|
+
|
29
|
+
Args:
|
30
|
+
**kwargs: Configuration for the constraint, e.g., 'count'.
|
31
|
+
"""
|
32
|
+
self.expected_count = kwargs.get("count")
|
33
|
+
|
34
|
+
def check(self, nodes: list[ast.AST]) -> bool:
|
35
|
+
"""Checks if the list of nodes is not empty or matches expected count."""
|
36
|
+
if self.expected_count is not None:
|
37
|
+
return len(nodes) == self.expected_count
|
38
|
+
return len(nodes) > 0
|
39
|
+
|
40
|
+
|
41
|
+
class IsForbiddenConstraint(Constraint):
|
42
|
+
"""Checks that no nodes were found by the selector.
|
43
|
+
|
44
|
+
This is the inverse of `IsRequiredConstraint` and is used to forbid certain
|
45
|
+
constructs, such as specific function calls or imports.
|
46
|
+
"""
|
47
|
+
|
48
|
+
def __init__(self, **kwargs: Any):
|
49
|
+
"""Initializes the constraint."""
|
50
|
+
pass
|
51
|
+
|
52
|
+
def check(self, nodes: list[ast.AST]) -> bool:
|
53
|
+
"""Checks if the list of nodes is empty."""
|
54
|
+
return not nodes
|
55
|
+
|
56
|
+
|
57
|
+
class MustInheritFromConstraint(Constraint):
|
58
|
+
"""Checks that a ClassDef node inherits from a specific parent class.
|
59
|
+
|
60
|
+
This constraint is designed to work with a selector that returns a single
|
61
|
+
`ast.ClassDef` node. It can resolve both simple names (e.g., `Exception`)
|
62
|
+
and attribute-based names (e.g., `arcade.Window`).
|
63
|
+
|
64
|
+
JSON Params:
|
65
|
+
parent_name (str): The expected name of the parent class.
|
66
|
+
"""
|
67
|
+
|
68
|
+
def __init__(self, **kwargs: Any):
|
69
|
+
self.parent_name_to_find: str | None = kwargs.get("parent_name")
|
70
|
+
|
71
|
+
def check(self, nodes: list[ast.AST]) -> bool:
|
72
|
+
"""Checks if the found class node inherits from the specified parent."""
|
73
|
+
if not self.parent_name_to_find or len(nodes) != 1:
|
74
|
+
return False
|
75
|
+
|
76
|
+
node = nodes[0]
|
77
|
+
if not isinstance(node, ast.ClassDef):
|
78
|
+
return False
|
79
|
+
|
80
|
+
for base in node.bases:
|
81
|
+
full_name = self._get_full_attribute_name(base)
|
82
|
+
if full_name == self.parent_name_to_find:
|
83
|
+
return True
|
84
|
+
return False
|
85
|
+
|
86
|
+
@staticmethod
|
87
|
+
def _get_full_attribute_name(node: ast.AST) -> str | None:
|
88
|
+
"""Recursively builds the full attribute name from a base class node."""
|
89
|
+
if isinstance(node, ast.Name):
|
90
|
+
return node.id
|
91
|
+
if isinstance(node, ast.Attribute):
|
92
|
+
base = MustInheritFromConstraint._get_full_attribute_name(node.value)
|
93
|
+
return f"{base}.{node.attr}" if base else node.attr
|
94
|
+
return None
|
95
|
+
|
96
|
+
|
97
|
+
class MustBeTypeConstraint(Constraint):
|
98
|
+
"""Checks the type of the value in an assignment statement.
|
99
|
+
|
100
|
+
It works for simple literals (numbers, strings, lists, etc.) and for
|
101
|
+
calls to built-in type constructors (e.g., `list()`, `dict()`).
|
102
|
+
|
103
|
+
JSON Params:
|
104
|
+
expected_type (str): The name of the type, e.g., "str", "int", "list".
|
105
|
+
"""
|
106
|
+
|
107
|
+
def __init__(self, **kwargs: Any):
|
108
|
+
self.expected_type_str: str | None = kwargs.get("expected_type")
|
109
|
+
self.type_map = {
|
110
|
+
"str": str,
|
111
|
+
"int": int,
|
112
|
+
"float": float,
|
113
|
+
"list": list,
|
114
|
+
"dict": dict,
|
115
|
+
"bool": bool,
|
116
|
+
"set": set,
|
117
|
+
"tuple": tuple,
|
118
|
+
}
|
119
|
+
self.constructor_map = {t: t for t in self.type_map}
|
120
|
+
|
121
|
+
def check(self, nodes: list[ast.AST]) -> bool:
|
122
|
+
"""Checks if the assigned value has the expected Python type."""
|
123
|
+
if not nodes or not self.expected_type_str:
|
124
|
+
return False
|
125
|
+
expected_py_type = self.type_map.get(self.expected_type_str)
|
126
|
+
if not expected_py_type:
|
127
|
+
return False
|
128
|
+
|
129
|
+
for node in nodes:
|
130
|
+
value_node = getattr(node, "value", None)
|
131
|
+
if value_node is None:
|
132
|
+
continue
|
133
|
+
|
134
|
+
if self._is_correct_type(value_node, expected_py_type):
|
135
|
+
continue
|
136
|
+
return False
|
137
|
+
return True
|
138
|
+
|
139
|
+
def _is_correct_type(self, value_node: ast.AST, expected_py_type: type) -> bool:
|
140
|
+
"""Checks a single value node against the expected type."""
|
141
|
+
try:
|
142
|
+
assigned_value = ast.literal_eval(value_node)
|
143
|
+
if isinstance(assigned_value, expected_py_type):
|
144
|
+
return True
|
145
|
+
except (ValueError, TypeError, SyntaxError):
|
146
|
+
pass
|
147
|
+
|
148
|
+
if isinstance(value_node, ast.Call):
|
149
|
+
func_name = getattr(value_node.func, "id", None)
|
150
|
+
expected_constructor = self.constructor_map.get(self.expected_type_str)
|
151
|
+
if func_name == expected_constructor:
|
152
|
+
return True
|
153
|
+
return False
|
154
|
+
|
155
|
+
|
156
|
+
class NameMustBeInConstraint(Constraint):
|
157
|
+
"""Checks if the name of a found node is in an allowed list of names.
|
158
|
+
|
159
|
+
This is useful for rules like restricting global variables to a pre-defined
|
160
|
+
set of constants.
|
161
|
+
|
162
|
+
JSON Params:
|
163
|
+
allowed_names (list[str]): A list of strings containing the allowed names.
|
164
|
+
"""
|
165
|
+
|
166
|
+
def __init__(self, **kwargs: Any):
|
167
|
+
self.allowed_names = set(kwargs.get("allowed_names", []))
|
168
|
+
|
169
|
+
@staticmethod
|
170
|
+
def _get_name(node: ast.AST) -> str | None:
|
171
|
+
"""Gets a name from various node types."""
|
172
|
+
if isinstance(node, (ast.Assign, ast.AnnAssign)):
|
173
|
+
target = node.targets[0] if isinstance(node, ast.Assign) else node.target
|
174
|
+
return get_full_name(target)
|
175
|
+
return getattr(node, "name", getattr(node, "id", None))
|
176
|
+
|
177
|
+
def check(self, nodes: list[ast.AST]) -> bool:
|
178
|
+
"""Checks if all found node names are in the allowed set."""
|
179
|
+
for node in nodes:
|
180
|
+
name_to_check = self._get_name(node)
|
181
|
+
if name_to_check and name_to_check not in self.allowed_names:
|
182
|
+
return False
|
183
|
+
return True
|
184
|
+
|
185
|
+
|
186
|
+
class ValueMustBeInConstraint(Constraint):
|
187
|
+
"""Checks if the value of a found literal node is in an allowed list.
|
188
|
+
|
189
|
+
This is primarily used to check for "magic numbers" or "magic strings",
|
190
|
+
allowing only a specific set of literal values to be present.
|
191
|
+
|
192
|
+
JSON Params:
|
193
|
+
allowed_values (list): A list of allowed literal values (e.g., [0, 1]).
|
194
|
+
"""
|
195
|
+
|
196
|
+
def __init__(self, **kwargs: Any):
|
197
|
+
self.allowed_values = set(kwargs.get("allowed_values", []))
|
198
|
+
|
199
|
+
def check(self, nodes: list[ast.AST]) -> bool:
|
200
|
+
"""Checks if all found literal values are in the allowed set."""
|
201
|
+
if not self.allowed_values:
|
202
|
+
return not nodes
|
203
|
+
|
204
|
+
for node in nodes:
|
205
|
+
if isinstance(node, ast.Constant):
|
206
|
+
if node.value not in self.allowed_values:
|
207
|
+
return False
|
208
|
+
else:
|
209
|
+
return False
|
210
|
+
return True
|
211
|
+
|
212
|
+
|
213
|
+
class MustHaveArgsConstraint(Constraint):
|
214
|
+
"""Checks that a FunctionDef node has a specific signature.
|
215
|
+
|
216
|
+
This constraint can check for an exact number of arguments or for an
|
217
|
+
exact sequence of argument names, ignoring `self` or `cls` in methods.
|
218
|
+
|
219
|
+
JSON Params:
|
220
|
+
count (int, optional): The exact number of arguments required.
|
221
|
+
names (list[str], optional): The exact list of argument names in order.
|
222
|
+
exact_match (bool, optional): Used with `names`. If False, only checks
|
223
|
+
for presence, not for exact list match. Defaults to True.
|
224
|
+
"""
|
225
|
+
|
226
|
+
def __init__(self, **kwargs: Any):
|
227
|
+
self.expected_count: int | None = kwargs.get("count")
|
228
|
+
self.expected_names: list[str] | None = kwargs.get("names")
|
229
|
+
self.exact_match: bool = kwargs.get("exact_match", True)
|
230
|
+
|
231
|
+
def check(self, nodes: list[ast.AST]) -> bool:
|
232
|
+
"""Checks if the function signature matches the criteria."""
|
233
|
+
if not nodes:
|
234
|
+
return True
|
235
|
+
if not all(isinstance(node, ast.FunctionDef) for node in nodes):
|
236
|
+
return False
|
237
|
+
|
238
|
+
for node in nodes:
|
239
|
+
actual_arg_names = [arg.arg for arg in node.args.args]
|
240
|
+
if hasattr(node, "parent") and isinstance(node.parent, ast.ClassDef):
|
241
|
+
if actual_arg_names:
|
242
|
+
actual_arg_names.pop(0)
|
243
|
+
|
244
|
+
if not self._check_single_node(actual_arg_names):
|
245
|
+
return False
|
246
|
+
return True
|
247
|
+
|
248
|
+
def _check_single_node(self, actual_arg_names: list[str]) -> bool:
|
249
|
+
"""Checks the argument list of a single function."""
|
250
|
+
if self.expected_names is not None:
|
251
|
+
if self.exact_match:
|
252
|
+
return actual_arg_names == self.expected_names
|
253
|
+
else:
|
254
|
+
return set(self.expected_names).issubset(set(actual_arg_names))
|
255
|
+
elif self.expected_count is not None:
|
256
|
+
return len(actual_arg_names) == self.expected_count
|
257
|
+
return False
|