python-naming-linter 0.1.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.
- python_naming_linter/__init__.py +0 -0
- python_naming_linter/checkers/__init__.py +12 -0
- python_naming_linter/checkers/class_.py +160 -0
- python_naming_linter/checkers/function.py +137 -0
- python_naming_linter/checkers/module.py +92 -0
- python_naming_linter/checkers/package.py +37 -0
- python_naming_linter/checkers/variable.py +175 -0
- python_naming_linter/cli.py +192 -0
- python_naming_linter/config.py +119 -0
- python_naming_linter/matcher.py +69 -0
- python_naming_linter/reporter.py +16 -0
- python_naming_linter-0.1.0.dist-info/METADATA +317 -0
- python_naming_linter-0.1.0.dist-info/RECORD +16 -0
- python_naming_linter-0.1.0.dist-info/WHEEL +4 -0
- python_naming_linter-0.1.0.dist-info/entry_points.txt +2 -0
- python_naming_linter-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from python_naming_linter.checkers import Violation
|
|
7
|
+
from python_naming_linter.config import Rule
|
|
8
|
+
|
|
9
|
+
# Common built-in exception base classes used for the "Exception" heuristic
|
|
10
|
+
_EXCEPTION_BASE_CLASSES = {
|
|
11
|
+
"Exception",
|
|
12
|
+
"BaseException",
|
|
13
|
+
"ValueError",
|
|
14
|
+
"TypeError",
|
|
15
|
+
"RuntimeError",
|
|
16
|
+
"KeyError",
|
|
17
|
+
"IndexError",
|
|
18
|
+
"AttributeError",
|
|
19
|
+
"NotImplementedError",
|
|
20
|
+
"OSError",
|
|
21
|
+
"IOError",
|
|
22
|
+
"FileNotFoundError",
|
|
23
|
+
"PermissionError",
|
|
24
|
+
"TimeoutError",
|
|
25
|
+
"StopIteration",
|
|
26
|
+
"GeneratorExit",
|
|
27
|
+
"SystemExit",
|
|
28
|
+
"ArithmeticError",
|
|
29
|
+
"LookupError",
|
|
30
|
+
"EnvironmentError",
|
|
31
|
+
"ConnectionError",
|
|
32
|
+
"ImportError",
|
|
33
|
+
"NameError",
|
|
34
|
+
"OverflowError",
|
|
35
|
+
"RecursionError",
|
|
36
|
+
"MemoryError",
|
|
37
|
+
"UnicodeError",
|
|
38
|
+
"Warning",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_exception_like(name: str) -> bool:
|
|
43
|
+
"""Return True if the name looks like an exception class."""
|
|
44
|
+
return (
|
|
45
|
+
name in _EXCEPTION_BASE_CLASSES
|
|
46
|
+
or name.endswith("Error")
|
|
47
|
+
or name.endswith("Exception")
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _base_names(node: ast.ClassDef) -> list[str]:
|
|
52
|
+
"""Return the plain names of all direct base classes."""
|
|
53
|
+
names = []
|
|
54
|
+
for base in node.bases:
|
|
55
|
+
if isinstance(base, ast.Name):
|
|
56
|
+
names.append(base.id)
|
|
57
|
+
elif isinstance(base, ast.Attribute):
|
|
58
|
+
names.append(base.attr)
|
|
59
|
+
return names
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _matches_base_class_filter(node: ast.ClassDef, base_class_filter: str) -> bool:
|
|
63
|
+
"""Return True if the class satisfies the base_class filter."""
|
|
64
|
+
bases = _base_names(node)
|
|
65
|
+
if base_class_filter == "Exception":
|
|
66
|
+
return any(_is_exception_like(b) for b in bases)
|
|
67
|
+
return base_class_filter in bases
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _check_name(class_name: str, naming: dict) -> str | None:
|
|
71
|
+
"""Return an error message if class_name violates naming constraints, else None."""
|
|
72
|
+
|
|
73
|
+
if "prefix" in naming:
|
|
74
|
+
prefix = naming["prefix"]
|
|
75
|
+
if isinstance(prefix, list):
|
|
76
|
+
if not any(class_name.startswith(p) for p in prefix):
|
|
77
|
+
msg = (
|
|
78
|
+
f"Expected name to start with one of {prefix!r}, got '{class_name}'"
|
|
79
|
+
)
|
|
80
|
+
return msg
|
|
81
|
+
else:
|
|
82
|
+
if not class_name.startswith(prefix):
|
|
83
|
+
return f"Expected name to start with '{prefix}', got '{class_name}'"
|
|
84
|
+
|
|
85
|
+
if "suffix" in naming:
|
|
86
|
+
suffix = naming["suffix"]
|
|
87
|
+
if isinstance(suffix, list):
|
|
88
|
+
if not any(class_name.endswith(s) for s in suffix):
|
|
89
|
+
msg = f"Expected name to end with one of {suffix!r}, got '{class_name}'"
|
|
90
|
+
return msg
|
|
91
|
+
else:
|
|
92
|
+
if not class_name.endswith(suffix):
|
|
93
|
+
return f"Expected name to end with '{suffix}', got '{class_name}'"
|
|
94
|
+
|
|
95
|
+
if "regex" in naming:
|
|
96
|
+
pattern = naming["regex"]
|
|
97
|
+
if not re.search(pattern, class_name):
|
|
98
|
+
return f"Expected name to match '{pattern}', got '{class_name}'"
|
|
99
|
+
|
|
100
|
+
if "case" in naming:
|
|
101
|
+
case = naming["case"]
|
|
102
|
+
if case == "PascalCase":
|
|
103
|
+
if not re.fullmatch(r"[A-Z][a-zA-Z0-9]*", class_name):
|
|
104
|
+
return f"Expected PascalCase name, got '{class_name}'"
|
|
105
|
+
elif case == "snake_case":
|
|
106
|
+
if not re.fullmatch(r"[a-z_][a-z0-9_]*", class_name):
|
|
107
|
+
return f"Expected snake_case name, got '{class_name}'"
|
|
108
|
+
elif case == "UPPER_CASE":
|
|
109
|
+
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", class_name):
|
|
110
|
+
return f"Expected UPPER_CASE name, got '{class_name}'"
|
|
111
|
+
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def check_class(tree: ast.Module, rule: Rule, file_path: str) -> list[Violation]:
|
|
116
|
+
"""Check class names in tree against the given rule."""
|
|
117
|
+
naming = rule.naming
|
|
118
|
+
filters = rule.filter
|
|
119
|
+
|
|
120
|
+
base_class_filter = filters.get("base_class")
|
|
121
|
+
decorator_filter = filters.get("decorator")
|
|
122
|
+
|
|
123
|
+
violations: list[Violation] = []
|
|
124
|
+
|
|
125
|
+
for node in ast.walk(tree):
|
|
126
|
+
if not isinstance(node, ast.ClassDef):
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
class_name = node.name
|
|
130
|
+
|
|
131
|
+
# Apply base_class filter
|
|
132
|
+
if base_class_filter is not None:
|
|
133
|
+
if not _matches_base_class_filter(node, base_class_filter):
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# Apply decorator filter
|
|
137
|
+
if decorator_filter is not None:
|
|
138
|
+
decorator_names = []
|
|
139
|
+
for dec in node.decorator_list:
|
|
140
|
+
if isinstance(dec, ast.Name):
|
|
141
|
+
decorator_names.append(dec.id)
|
|
142
|
+
elif isinstance(dec, ast.Attribute):
|
|
143
|
+
decorator_names.append(dec.attr)
|
|
144
|
+
if decorator_filter not in decorator_names:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# Check naming
|
|
148
|
+
msg = _check_name(class_name, naming)
|
|
149
|
+
if msg is not None:
|
|
150
|
+
violations.append(
|
|
151
|
+
Violation(
|
|
152
|
+
rule_name=rule.name,
|
|
153
|
+
file_path=file_path,
|
|
154
|
+
lineno=node.lineno,
|
|
155
|
+
name=class_name,
|
|
156
|
+
message=msg,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return violations
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from python_naming_linter.checkers import Violation
|
|
7
|
+
from python_naming_linter.config import Rule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_return_type_name(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str | None:
|
|
11
|
+
"""Return the plain name of the function's return annotation, or None."""
|
|
12
|
+
annotation = node.returns
|
|
13
|
+
if annotation is None:
|
|
14
|
+
return None
|
|
15
|
+
if isinstance(annotation, ast.Name):
|
|
16
|
+
return annotation.id
|
|
17
|
+
if isinstance(annotation, ast.Attribute):
|
|
18
|
+
return annotation.attr
|
|
19
|
+
if isinstance(annotation, ast.Constant):
|
|
20
|
+
return str(annotation.value)
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _check_name(func_name: str, naming: dict) -> str | None:
|
|
25
|
+
"""Return error message if func_name violates naming constraints, else None."""
|
|
26
|
+
|
|
27
|
+
if "prefix" in naming:
|
|
28
|
+
prefix = naming["prefix"]
|
|
29
|
+
if isinstance(prefix, list):
|
|
30
|
+
if not any(func_name.startswith(p) for p in prefix):
|
|
31
|
+
msg = (
|
|
32
|
+
f"Expected name to start with one of {prefix!r}, got '{func_name}'"
|
|
33
|
+
)
|
|
34
|
+
return msg
|
|
35
|
+
else:
|
|
36
|
+
if not func_name.startswith(prefix):
|
|
37
|
+
return f"Expected name to start with '{prefix}', got '{func_name}'"
|
|
38
|
+
|
|
39
|
+
if "suffix" in naming:
|
|
40
|
+
suffix = naming["suffix"]
|
|
41
|
+
if isinstance(suffix, list):
|
|
42
|
+
if not any(func_name.endswith(s) for s in suffix):
|
|
43
|
+
return f"Expected name to end with one of {suffix!r}, got '{func_name}'"
|
|
44
|
+
else:
|
|
45
|
+
if not func_name.endswith(suffix):
|
|
46
|
+
return f"Expected name to end with '{suffix}', got '{func_name}'"
|
|
47
|
+
|
|
48
|
+
if "regex" in naming:
|
|
49
|
+
pattern = naming["regex"]
|
|
50
|
+
if not re.search(pattern, func_name):
|
|
51
|
+
return f"Expected name to match '{pattern}', got '{func_name}'"
|
|
52
|
+
|
|
53
|
+
if "case" in naming:
|
|
54
|
+
case = naming["case"]
|
|
55
|
+
if case == "snake_case":
|
|
56
|
+
if not re.fullmatch(r"[a-z_][a-z0-9_]*", func_name):
|
|
57
|
+
return f"Expected snake_case name, got '{func_name}'"
|
|
58
|
+
elif case == "UPPER_CASE":
|
|
59
|
+
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", func_name):
|
|
60
|
+
return f"Expected UPPER_CASE name, got '{func_name}'"
|
|
61
|
+
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _build_parent_map(tree: ast.Module) -> dict[int, ast.AST]:
|
|
66
|
+
"""Return a mapping from node id to its parent node."""
|
|
67
|
+
parent_map: dict[int, ast.AST] = {}
|
|
68
|
+
for node in ast.walk(tree):
|
|
69
|
+
for child in ast.iter_child_nodes(node):
|
|
70
|
+
parent_map[id(child)] = node
|
|
71
|
+
return parent_map
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_function(tree: ast.Module, rule: Rule, file_path: str) -> list[Violation]:
|
|
75
|
+
"""Check function/method names in tree against the given rule."""
|
|
76
|
+
naming = rule.naming
|
|
77
|
+
filters = rule.filter
|
|
78
|
+
|
|
79
|
+
target_filter = filters.get("target") # "method", "function", or None (both)
|
|
80
|
+
return_type_filter = filters.get("return_type") # e.g. "bool"
|
|
81
|
+
decorator_filter = filters.get("decorator") # e.g. "property"
|
|
82
|
+
|
|
83
|
+
parent_map = _build_parent_map(tree)
|
|
84
|
+
|
|
85
|
+
violations: list[Violation] = []
|
|
86
|
+
|
|
87
|
+
for node in ast.walk(tree):
|
|
88
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
func_name = node.name
|
|
92
|
+
|
|
93
|
+
# Skip dunder methods
|
|
94
|
+
if func_name.startswith("__") and func_name.endswith("__"):
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
# Determine if this is a method (direct parent is ClassDef)
|
|
98
|
+
parent = parent_map.get(id(node))
|
|
99
|
+
is_method = isinstance(parent, ast.ClassDef)
|
|
100
|
+
|
|
101
|
+
# Apply target filter
|
|
102
|
+
if target_filter == "method" and not is_method:
|
|
103
|
+
continue
|
|
104
|
+
if target_filter == "function" and is_method:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Apply return_type filter
|
|
108
|
+
if return_type_filter is not None:
|
|
109
|
+
actual_return = _get_return_type_name(node)
|
|
110
|
+
if actual_return != return_type_filter:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
# Apply decorator filter
|
|
114
|
+
if decorator_filter is not None:
|
|
115
|
+
decorator_names = []
|
|
116
|
+
for dec in node.decorator_list:
|
|
117
|
+
if isinstance(dec, ast.Name):
|
|
118
|
+
decorator_names.append(dec.id)
|
|
119
|
+
elif isinstance(dec, ast.Attribute):
|
|
120
|
+
decorator_names.append(dec.attr)
|
|
121
|
+
if decorator_filter not in decorator_names:
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# Check naming
|
|
125
|
+
msg = _check_name(func_name, naming)
|
|
126
|
+
if msg is not None:
|
|
127
|
+
violations.append(
|
|
128
|
+
Violation(
|
|
129
|
+
rule_name=rule.name,
|
|
130
|
+
file_path=file_path,
|
|
131
|
+
lineno=node.lineno,
|
|
132
|
+
name=func_name,
|
|
133
|
+
message=msg,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return violations
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from python_naming_linter.checkers import Violation
|
|
8
|
+
from python_naming_linter.config import Rule
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _to_snake_case(name: str) -> str:
|
|
12
|
+
"""Convert a CamelCase / PascalCase name to snake_case."""
|
|
13
|
+
s1 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
|
14
|
+
result = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1)
|
|
15
|
+
return result.lower()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_module(tree: ast.Module, rule: Rule, file_path: str) -> list[Violation]:
|
|
19
|
+
"""Check module filename against the given rule."""
|
|
20
|
+
module_name = Path(file_path).stem
|
|
21
|
+
|
|
22
|
+
# Skip __init__ files
|
|
23
|
+
if module_name == "__init__":
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
naming = rule.naming
|
|
27
|
+
violations: list[Violation] = []
|
|
28
|
+
|
|
29
|
+
if "source" in naming and naming.get("source") == "class_name":
|
|
30
|
+
transform = naming.get("transform", "snake_case")
|
|
31
|
+
if transform == "snake_case":
|
|
32
|
+
# Find the first ClassDef at module level (or anywhere via walk)
|
|
33
|
+
first_class: ast.ClassDef | None = None
|
|
34
|
+
for node in ast.walk(tree):
|
|
35
|
+
if isinstance(node, ast.ClassDef):
|
|
36
|
+
first_class = node
|
|
37
|
+
break
|
|
38
|
+
|
|
39
|
+
if first_class is None:
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
expected = _to_snake_case(first_class.name)
|
|
43
|
+
if module_name != expected:
|
|
44
|
+
msg = f"Expected module name '{expected}', got '{module_name}'"
|
|
45
|
+
violations.append(
|
|
46
|
+
Violation(
|
|
47
|
+
rule_name=rule.name,
|
|
48
|
+
file_path=file_path,
|
|
49
|
+
lineno=0,
|
|
50
|
+
name=module_name,
|
|
51
|
+
message=msg,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
elif "regex" in naming:
|
|
56
|
+
pattern = naming["regex"]
|
|
57
|
+
if not re.fullmatch(pattern, module_name):
|
|
58
|
+
msg = f"Expected module name to match '{pattern}', got '{module_name}'"
|
|
59
|
+
violations.append(
|
|
60
|
+
Violation(
|
|
61
|
+
rule_name=rule.name,
|
|
62
|
+
file_path=file_path,
|
|
63
|
+
lineno=0,
|
|
64
|
+
name=module_name,
|
|
65
|
+
message=msg,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
elif "case" in naming:
|
|
70
|
+
case = naming["case"]
|
|
71
|
+
msg: str | None = None
|
|
72
|
+
if case == "snake_case":
|
|
73
|
+
if not re.fullmatch(r"[a-z_][a-z0-9_]*", module_name):
|
|
74
|
+
msg = f"Expected snake_case module name, got '{module_name}'"
|
|
75
|
+
elif case == "UPPER_CASE":
|
|
76
|
+
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", module_name):
|
|
77
|
+
msg = f"Expected UPPER_CASE module name, got '{module_name}'"
|
|
78
|
+
elif case == "PascalCase":
|
|
79
|
+
if not re.fullmatch(r"[A-Z][a-zA-Z0-9]*", module_name):
|
|
80
|
+
msg = f"Expected PascalCase module name, got '{module_name}'"
|
|
81
|
+
if msg is not None:
|
|
82
|
+
violations.append(
|
|
83
|
+
Violation(
|
|
84
|
+
rule_name=rule.name,
|
|
85
|
+
file_path=file_path,
|
|
86
|
+
lineno=0,
|
|
87
|
+
name=module_name,
|
|
88
|
+
message=msg,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return violations
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from python_naming_linter.checkers import Violation
|
|
6
|
+
from python_naming_linter.config import Rule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_package(rule: Rule, package_name: str) -> list[Violation]:
|
|
10
|
+
naming = rule.naming
|
|
11
|
+
violations = []
|
|
12
|
+
|
|
13
|
+
if "case" in naming:
|
|
14
|
+
if naming["case"] == "snake_case" and package_name != package_name.lower():
|
|
15
|
+
violations.append(
|
|
16
|
+
Violation(
|
|
17
|
+
rule_name=rule.name,
|
|
18
|
+
file_path=package_name,
|
|
19
|
+
lineno=0,
|
|
20
|
+
name=package_name,
|
|
21
|
+
message="expected: snake_case",
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if "regex" in naming:
|
|
26
|
+
if not re.match(naming["regex"], package_name):
|
|
27
|
+
violations.append(
|
|
28
|
+
Violation(
|
|
29
|
+
rule_name=rule.name,
|
|
30
|
+
file_path=package_name,
|
|
31
|
+
lineno=0,
|
|
32
|
+
name=package_name,
|
|
33
|
+
message=f"expected pattern: {naming['regex']}",
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return violations
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from python_naming_linter.checkers import Violation
|
|
7
|
+
from python_naming_linter.config import Rule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _to_snake_case(name: str) -> str:
|
|
11
|
+
"""Convert a CamelCase / PascalCase name to snake_case."""
|
|
12
|
+
# Insert underscore before sequences of uppercase letters followed by lowercase
|
|
13
|
+
s1 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
|
14
|
+
result = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1)
|
|
15
|
+
return result.lower()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_upper_case(name: str) -> bool:
|
|
19
|
+
return bool(re.fullmatch(r"[A-Z][A-Z0-9_]*", name))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_annotation_name(annotation: ast.expr | None) -> str | None:
|
|
23
|
+
"""Extract the plain name from a type annotation node."""
|
|
24
|
+
if annotation is None:
|
|
25
|
+
return None
|
|
26
|
+
if isinstance(annotation, ast.Name):
|
|
27
|
+
return annotation.id
|
|
28
|
+
if isinstance(annotation, ast.Attribute):
|
|
29
|
+
return annotation.attr
|
|
30
|
+
# For subscripted types like List[X], use the outer name
|
|
31
|
+
if isinstance(annotation, ast.Subscript):
|
|
32
|
+
return _get_annotation_name(annotation.value)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_name_against_naming(
|
|
37
|
+
var_name: str,
|
|
38
|
+
annotation: ast.expr | None,
|
|
39
|
+
naming: dict,
|
|
40
|
+
) -> str | None:
|
|
41
|
+
"""Return error message string if name violates naming rule, else None."""
|
|
42
|
+
|
|
43
|
+
if "source" in naming and naming.get("source") == "type_annotation":
|
|
44
|
+
transform = naming.get("transform", "snake_case")
|
|
45
|
+
if transform == "snake_case":
|
|
46
|
+
type_name = _get_annotation_name(annotation)
|
|
47
|
+
if type_name is None:
|
|
48
|
+
# No annotation to derive from; skip
|
|
49
|
+
return None
|
|
50
|
+
expected = _to_snake_case(type_name)
|
|
51
|
+
# Allow exact match or {prefix}_{expected} form
|
|
52
|
+
if var_name == expected:
|
|
53
|
+
return None
|
|
54
|
+
if var_name.endswith("_" + expected):
|
|
55
|
+
return None
|
|
56
|
+
return f"Expected '{expected}' (or '<prefix>_{expected}'), got '{var_name}'"
|
|
57
|
+
|
|
58
|
+
if "case" in naming:
|
|
59
|
+
case = naming["case"]
|
|
60
|
+
if case == "UPPER_CASE":
|
|
61
|
+
if _is_upper_case(var_name):
|
|
62
|
+
return None
|
|
63
|
+
return f"Expected UPPER_CASE name, got '{var_name}'"
|
|
64
|
+
|
|
65
|
+
if "prefix" in naming:
|
|
66
|
+
prefix = naming["prefix"]
|
|
67
|
+
if not var_name.startswith(prefix):
|
|
68
|
+
return f"Expected name to start with '{prefix}', got '{var_name}'"
|
|
69
|
+
|
|
70
|
+
if "suffix" in naming:
|
|
71
|
+
suffix = naming["suffix"]
|
|
72
|
+
if not var_name.endswith(suffix):
|
|
73
|
+
return f"Expected name to end with '{suffix}', got '{var_name}'"
|
|
74
|
+
|
|
75
|
+
if "regex" in naming:
|
|
76
|
+
pattern = naming["regex"]
|
|
77
|
+
if not re.fullmatch(pattern, var_name):
|
|
78
|
+
return f"Expected name to match '{pattern}', got '{var_name}'"
|
|
79
|
+
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _collect_attributes(
|
|
84
|
+
tree: ast.Module,
|
|
85
|
+
) -> list[tuple[str, ast.expr | None, int]]:
|
|
86
|
+
"""Collect annotated assignments inside class bodies."""
|
|
87
|
+
results = []
|
|
88
|
+
for node in ast.walk(tree):
|
|
89
|
+
if isinstance(node, ast.ClassDef):
|
|
90
|
+
for item in node.body:
|
|
91
|
+
if isinstance(item, ast.AnnAssign):
|
|
92
|
+
if isinstance(item.target, ast.Name):
|
|
93
|
+
results.append((item.target.id, item.annotation, item.lineno))
|
|
94
|
+
return results
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _collect_parameters(
|
|
98
|
+
tree: ast.Module,
|
|
99
|
+
) -> list[tuple[str, ast.expr | None, int]]:
|
|
100
|
+
"""Collect function/method parameters, skipping self and cls."""
|
|
101
|
+
results = []
|
|
102
|
+
for node in ast.walk(tree):
|
|
103
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
104
|
+
args = node.args
|
|
105
|
+
all_args = args.posonlyargs + args.args + args.kwonlyargs
|
|
106
|
+
if args.vararg:
|
|
107
|
+
all_args.append(args.vararg)
|
|
108
|
+
if args.kwarg:
|
|
109
|
+
all_args.append(args.kwarg)
|
|
110
|
+
for arg in all_args:
|
|
111
|
+
if arg.arg in ("self", "cls"):
|
|
112
|
+
continue
|
|
113
|
+
results.append((arg.arg, arg.annotation, arg.lineno))
|
|
114
|
+
return results
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _collect_local_variables(
|
|
118
|
+
tree: ast.Module,
|
|
119
|
+
) -> list[tuple[str, ast.expr | None, int]]:
|
|
120
|
+
"""Collect annotated assignments inside function bodies."""
|
|
121
|
+
results = []
|
|
122
|
+
for node in ast.walk(tree):
|
|
123
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
124
|
+
for item in ast.walk(node):
|
|
125
|
+
if isinstance(item, ast.AnnAssign) and isinstance(
|
|
126
|
+
item.target, ast.Name
|
|
127
|
+
):
|
|
128
|
+
results.append((item.target.id, item.annotation, item.lineno))
|
|
129
|
+
return results
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _collect_constants(
|
|
133
|
+
tree: ast.Module,
|
|
134
|
+
) -> list[tuple[str, ast.expr | None, int]]:
|
|
135
|
+
"""Collect module-level assignments with UPPER_CASE names (constants)."""
|
|
136
|
+
results = []
|
|
137
|
+
for node in tree.body:
|
|
138
|
+
if isinstance(node, ast.Assign):
|
|
139
|
+
for target in node.targets:
|
|
140
|
+
if isinstance(target, ast.Name):
|
|
141
|
+
results.append((target.id, None, node.lineno))
|
|
142
|
+
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
143
|
+
results.append((node.target.id, node.annotation, node.lineno))
|
|
144
|
+
return results
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def check_variable(tree: ast.Module, rule: Rule, file_path: str) -> list[Violation]:
|
|
148
|
+
target = rule.filter.get("target", "")
|
|
149
|
+
naming = rule.naming
|
|
150
|
+
|
|
151
|
+
if target == "attribute":
|
|
152
|
+
candidates = _collect_attributes(tree)
|
|
153
|
+
elif target == "parameter":
|
|
154
|
+
candidates = _collect_parameters(tree)
|
|
155
|
+
elif target == "local_variable":
|
|
156
|
+
candidates = _collect_local_variables(tree)
|
|
157
|
+
elif target == "constant":
|
|
158
|
+
candidates = _collect_constants(tree)
|
|
159
|
+
else:
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
violations = []
|
|
163
|
+
for var_name, annotation, lineno in candidates:
|
|
164
|
+
msg = _check_name_against_naming(var_name, annotation, naming)
|
|
165
|
+
if msg is not None:
|
|
166
|
+
violations.append(
|
|
167
|
+
Violation(
|
|
168
|
+
rule_name=rule.name,
|
|
169
|
+
file_path=file_path,
|
|
170
|
+
lineno=lineno,
|
|
171
|
+
name=var_name,
|
|
172
|
+
message=msg,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return violations
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import fnmatch
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from python_naming_linter.checkers.class_ import check_class
|
|
10
|
+
from python_naming_linter.checkers.function import check_function
|
|
11
|
+
from python_naming_linter.checkers.module import check_module
|
|
12
|
+
from python_naming_linter.checkers.package import check_package
|
|
13
|
+
from python_naming_linter.checkers.variable import check_variable
|
|
14
|
+
from python_naming_linter.config import Rule, find_config, load_config
|
|
15
|
+
from python_naming_linter.matcher import matches_pattern_or_submodule
|
|
16
|
+
from python_naming_linter.reporter import format_violations
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _file_to_module(file_path: Path, root: Path) -> str:
|
|
20
|
+
relative = file_path.relative_to(root)
|
|
21
|
+
parts = relative.with_suffix("").parts
|
|
22
|
+
if parts[-1] == "__init__":
|
|
23
|
+
parts = parts[:-1]
|
|
24
|
+
return ".".join(parts)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _package_module(file_path: Path, root: Path) -> str:
|
|
28
|
+
"""Return the package (directory) module path for a file.
|
|
29
|
+
|
|
30
|
+
For ``contexts/boards/domain/models.py`` this returns
|
|
31
|
+
``contexts.boards.domain``.
|
|
32
|
+
"""
|
|
33
|
+
relative = file_path.relative_to(root)
|
|
34
|
+
parts = relative.with_suffix("").parts
|
|
35
|
+
if parts[-1] == "__init__":
|
|
36
|
+
parts = parts[:-1]
|
|
37
|
+
else:
|
|
38
|
+
parts = parts[:-1]
|
|
39
|
+
return ".".join(parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _normalize_pattern(pattern: str, project_root: Path) -> str:
|
|
43
|
+
"""Normalize a pattern so that bare directory names match all files within."""
|
|
44
|
+
clean = pattern.rstrip("/")
|
|
45
|
+
candidate = project_root / clean
|
|
46
|
+
if candidate.is_dir() or not any(c in clean for c in ("*", "?")):
|
|
47
|
+
clean = f"{clean}/**"
|
|
48
|
+
return clean
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _matches_any(path: Path, patterns: list[str]) -> bool:
|
|
52
|
+
return any(fnmatch.fnmatch(str(path), p) for p in patterns)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _find_python_files(
|
|
56
|
+
root: Path,
|
|
57
|
+
include: list[str] | None = None,
|
|
58
|
+
exclude: list[str] | None = None,
|
|
59
|
+
) -> list[Path]:
|
|
60
|
+
all_files = sorted(root.rglob("*.py"))
|
|
61
|
+
|
|
62
|
+
if include is not None:
|
|
63
|
+
normalized = [_normalize_pattern(p, root) for p in include]
|
|
64
|
+
all_files = [
|
|
65
|
+
f for f in all_files if _matches_any(f.relative_to(root), normalized)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
if exclude is not None:
|
|
69
|
+
normalized = [_normalize_pattern(p, root) for p in exclude]
|
|
70
|
+
all_files = [
|
|
71
|
+
f for f in all_files if not _matches_any(f.relative_to(root), normalized)
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
return all_files
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _get_rules_for_module(module: str, config) -> list[Rule]:
|
|
78
|
+
"""Return the list of Rule objects that apply to a given module."""
|
|
79
|
+
rule_map = {r.name: r for r in config.rules}
|
|
80
|
+
matching_rules: list[Rule] = []
|
|
81
|
+
for entry in config.apply:
|
|
82
|
+
if matches_pattern_or_submodule(entry.modules, module):
|
|
83
|
+
for rule_name in entry.rules:
|
|
84
|
+
rule = rule_map.get(rule_name)
|
|
85
|
+
if rule is not None and rule not in matching_rules:
|
|
86
|
+
matching_rules.append(rule)
|
|
87
|
+
return matching_rules
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _run_checker(
|
|
91
|
+
tree: ast.Module,
|
|
92
|
+
rule: Rule,
|
|
93
|
+
file_path: str,
|
|
94
|
+
package_name: str,
|
|
95
|
+
) -> list:
|
|
96
|
+
"""Dispatch to the appropriate checker based on rule.type."""
|
|
97
|
+
if rule.type == "variable":
|
|
98
|
+
return check_variable(tree, rule, file_path)
|
|
99
|
+
if rule.type == "function":
|
|
100
|
+
return check_function(tree, rule, file_path)
|
|
101
|
+
if rule.type == "class":
|
|
102
|
+
return check_class(tree, rule, file_path)
|
|
103
|
+
if rule.type == "module":
|
|
104
|
+
return check_module(tree, rule, file_path)
|
|
105
|
+
if rule.type == "package":
|
|
106
|
+
return check_package(rule, package_name)
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@click.group()
|
|
111
|
+
def main() -> None:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@main.command()
|
|
116
|
+
@click.option(
|
|
117
|
+
"--config",
|
|
118
|
+
"config_path",
|
|
119
|
+
default=None,
|
|
120
|
+
help="Path to config file.",
|
|
121
|
+
)
|
|
122
|
+
def check(config_path: str | None) -> None:
|
|
123
|
+
if config_path is not None:
|
|
124
|
+
config_file = Path(config_path)
|
|
125
|
+
if not config_file.exists():
|
|
126
|
+
click.echo(f"Error: Config file not found: {config_file}")
|
|
127
|
+
raise SystemExit(2)
|
|
128
|
+
root = config_file.resolve().parent
|
|
129
|
+
else:
|
|
130
|
+
config_file = find_config()
|
|
131
|
+
if config_file is None:
|
|
132
|
+
click.echo(
|
|
133
|
+
"Error: Config file not found. "
|
|
134
|
+
"Create .python-naming-linter.yaml or configure "
|
|
135
|
+
"[tool.python-naming-linter] in pyproject.toml."
|
|
136
|
+
)
|
|
137
|
+
raise SystemExit(2)
|
|
138
|
+
root = config_file.resolve().parent
|
|
139
|
+
|
|
140
|
+
config = load_config(config_file)
|
|
141
|
+
|
|
142
|
+
all_violations = []
|
|
143
|
+
python_files = _find_python_files(root, config.include, config.exclude)
|
|
144
|
+
|
|
145
|
+
checked_packages: set[tuple[str, str]] = set()
|
|
146
|
+
|
|
147
|
+
for file_path in python_files:
|
|
148
|
+
module = _file_to_module(file_path, root)
|
|
149
|
+
package = _package_module(file_path, root)
|
|
150
|
+
rules = _get_rules_for_module(module, config)
|
|
151
|
+
if not rules:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
source = file_path.read_text(encoding="utf-8")
|
|
155
|
+
try:
|
|
156
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
157
|
+
except SyntaxError:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
rel_path = str(file_path.relative_to(root))
|
|
161
|
+
file_violations = []
|
|
162
|
+
|
|
163
|
+
for rule in rules:
|
|
164
|
+
if rule.type == "package":
|
|
165
|
+
# Use the last segment of the package path as the package name
|
|
166
|
+
pkg_segment = package.split(".")[-1] if package else ""
|
|
167
|
+
key = (rule.name, package)
|
|
168
|
+
if key in checked_packages:
|
|
169
|
+
continue
|
|
170
|
+
checked_packages.add(key)
|
|
171
|
+
violations = check_package(rule, pkg_segment)
|
|
172
|
+
# Use the package path as the file_path for reporting
|
|
173
|
+
pkg_rel = package.replace(".", "/")
|
|
174
|
+
for v in violations:
|
|
175
|
+
all_violations.append(v)
|
|
176
|
+
output = format_violations(pkg_rel, [v])
|
|
177
|
+
if output:
|
|
178
|
+
click.echo(output)
|
|
179
|
+
else:
|
|
180
|
+
violations = _run_checker(tree, rule, rel_path, package)
|
|
181
|
+
file_violations.extend(violations)
|
|
182
|
+
|
|
183
|
+
if file_violations:
|
|
184
|
+
output = format_violations(rel_path, file_violations)
|
|
185
|
+
click.echo(output)
|
|
186
|
+
all_violations.extend(file_violations)
|
|
187
|
+
|
|
188
|
+
if all_violations:
|
|
189
|
+
click.echo(f"Found {len(all_violations)} violation(s).")
|
|
190
|
+
raise SystemExit(1)
|
|
191
|
+
else:
|
|
192
|
+
click.echo("No violations found.")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Rule:
|
|
11
|
+
name: str
|
|
12
|
+
type: str
|
|
13
|
+
naming: dict
|
|
14
|
+
filter: dict = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Apply:
|
|
19
|
+
name: str
|
|
20
|
+
rules: list[str]
|
|
21
|
+
modules: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Config:
|
|
26
|
+
rules: list[Rule]
|
|
27
|
+
apply: list[Apply]
|
|
28
|
+
include: list[str] | None = None
|
|
29
|
+
exclude: list[str] | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_rules(rules_data: list[dict]) -> list[Rule]:
|
|
33
|
+
rules = []
|
|
34
|
+
for r in rules_data:
|
|
35
|
+
rules.append(
|
|
36
|
+
Rule(
|
|
37
|
+
name=r["name"],
|
|
38
|
+
type=r["type"],
|
|
39
|
+
naming=r.get("naming", {}),
|
|
40
|
+
filter=r.get("filter", {}),
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
return rules
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_apply(apply_data: list[dict]) -> list[Apply]:
|
|
47
|
+
entries = []
|
|
48
|
+
for a in apply_data:
|
|
49
|
+
entries.append(
|
|
50
|
+
Apply(
|
|
51
|
+
name=a["name"],
|
|
52
|
+
rules=a["rules"],
|
|
53
|
+
modules=a["modules"],
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
return entries
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_yaml(path: Path) -> Config:
|
|
60
|
+
with open(path) as f:
|
|
61
|
+
data = yaml.safe_load(f)
|
|
62
|
+
return Config(
|
|
63
|
+
rules=_parse_rules(data.get("rules", [])),
|
|
64
|
+
apply=_parse_apply(data.get("apply", [])),
|
|
65
|
+
include=data.get("include"),
|
|
66
|
+
exclude=data.get("exclude"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _load_toml(path: Path) -> dict:
|
|
71
|
+
try:
|
|
72
|
+
import tomllib
|
|
73
|
+
except ImportError:
|
|
74
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
75
|
+
|
|
76
|
+
with open(path, "rb") as f:
|
|
77
|
+
return tomllib.load(f)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_pyproject_toml(path: Path) -> Config:
|
|
81
|
+
data = _load_toml(path)
|
|
82
|
+
tool_config = data["tool"]["python-naming-linter"]
|
|
83
|
+
return Config(
|
|
84
|
+
rules=_parse_rules(tool_config.get("rules", [])),
|
|
85
|
+
apply=_parse_apply(tool_config.get("apply", [])),
|
|
86
|
+
include=tool_config.get("include"),
|
|
87
|
+
exclude=tool_config.get("exclude"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _has_pnl_section(path: Path) -> bool:
|
|
92
|
+
data = _load_toml(path)
|
|
93
|
+
return "python-naming-linter" in data.get("tool", {})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_CONFIG_FILENAMES = [".python-naming-linter.yaml", "pyproject.toml"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find_config() -> Path | None:
|
|
100
|
+
current = Path.cwd().resolve()
|
|
101
|
+
while True:
|
|
102
|
+
for name in _CONFIG_FILENAMES:
|
|
103
|
+
candidate = current / name
|
|
104
|
+
if candidate.is_file():
|
|
105
|
+
if name == "pyproject.toml" and not _has_pnl_section(candidate):
|
|
106
|
+
continue
|
|
107
|
+
return candidate
|
|
108
|
+
parent = current.parent
|
|
109
|
+
if parent == current:
|
|
110
|
+
return None
|
|
111
|
+
current = parent
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_config(path: Path) -> Config:
|
|
115
|
+
if not path.exists():
|
|
116
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
117
|
+
if path.suffix == ".toml":
|
|
118
|
+
return _load_pyproject_toml(path)
|
|
119
|
+
return _load_yaml(path)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_CAPTURE_RE = re.compile(r"^\{(\w+)\}$")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def matches_pattern(pattern: str, module: str) -> bool:
|
|
9
|
+
return match_pattern_with_captures(pattern, module) is not None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def match_pattern_with_captures(pattern: str, module: str) -> dict[str, str] | None:
|
|
13
|
+
pattern_parts = pattern.split(".")
|
|
14
|
+
module_parts = module.split(".")
|
|
15
|
+
captures: dict[str, str] = {}
|
|
16
|
+
if _match_with_captures(pattern_parts, module_parts, captures):
|
|
17
|
+
return captures
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _match_with_captures(
|
|
22
|
+
pattern_parts: list[str],
|
|
23
|
+
module_parts: list[str],
|
|
24
|
+
captures: dict[str, str],
|
|
25
|
+
) -> bool:
|
|
26
|
+
if not pattern_parts and not module_parts:
|
|
27
|
+
return True
|
|
28
|
+
if not pattern_parts:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
if pattern_parts[0] == "**":
|
|
32
|
+
for i in range(1, len(module_parts) + 1):
|
|
33
|
+
snapshot = dict(captures)
|
|
34
|
+
if _match_with_captures(pattern_parts[1:], module_parts[i:], captures):
|
|
35
|
+
return True
|
|
36
|
+
captures.clear()
|
|
37
|
+
captures.update(snapshot)
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
if not module_parts:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
m = _CAPTURE_RE.match(pattern_parts[0])
|
|
44
|
+
if m:
|
|
45
|
+
name = m.group(1)
|
|
46
|
+
value = module_parts[0]
|
|
47
|
+
if name in captures:
|
|
48
|
+
if captures[name] != value:
|
|
49
|
+
return False
|
|
50
|
+
else:
|
|
51
|
+
captures[name] = value
|
|
52
|
+
return _match_with_captures(pattern_parts[1:], module_parts[1:], captures)
|
|
53
|
+
|
|
54
|
+
if pattern_parts[0] == "*" or pattern_parts[0] == module_parts[0]:
|
|
55
|
+
return _match_with_captures(pattern_parts[1:], module_parts[1:], captures)
|
|
56
|
+
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def matches_pattern_or_submodule(pattern: str, module: str) -> bool:
|
|
61
|
+
if matches_pattern(pattern, module):
|
|
62
|
+
return True
|
|
63
|
+
module_parts = module.split(".")
|
|
64
|
+
pattern_parts = pattern.split(".")
|
|
65
|
+
if len(module_parts) > len(pattern_parts):
|
|
66
|
+
prefix = ".".join(module_parts[: len(pattern_parts)])
|
|
67
|
+
if matches_pattern(pattern, prefix):
|
|
68
|
+
return True
|
|
69
|
+
return False
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from python_naming_linter.checkers import Violation
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def format_violations(file_path: str, violations: list[Violation]) -> str:
|
|
7
|
+
if not violations:
|
|
8
|
+
return ""
|
|
9
|
+
|
|
10
|
+
lines = []
|
|
11
|
+
for v in violations:
|
|
12
|
+
lines.append(f"{file_path}:{v.lineno}")
|
|
13
|
+
lines.append(f" [{v.rule_name}] {v.name} ({v.message})")
|
|
14
|
+
lines.append("")
|
|
15
|
+
|
|
16
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-naming-linter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A naming convention linter for Python projects
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: click>=8.0
|
|
18
|
+
Requires-Dist: pyyaml>=6.0
|
|
19
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pre-commit>=3.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
24
|
+
Requires-Dist: ty>=0.0.26; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# python-naming-linter
|
|
28
|
+
|
|
29
|
+
A naming convention linter for Python projects. Define custom naming rules and enforce them with a single CLI command.
|
|
30
|
+
|
|
31
|
+
## What It Does
|
|
32
|
+
|
|
33
|
+
- Define naming rules for variables, functions, classes, modules, and packages
|
|
34
|
+
- Apply rules to specific modules using pattern matching
|
|
35
|
+
- Integrate into CI or pre-commit to keep your naming conventions consistent
|
|
36
|
+
|
|
37
|
+
For Python developers who want to enforce team-specific naming conventions beyond what PEP 8 and ruff cover.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install python-naming-linter
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or with uv:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv add python-naming-linter
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
Create `.python-naming-linter.yaml` in your project root:
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
rules:
|
|
57
|
+
- name: bool-method-prefix
|
|
58
|
+
type: function
|
|
59
|
+
filter: { return_type: bool }
|
|
60
|
+
naming: { prefix: [is_, has_, should_] }
|
|
61
|
+
|
|
62
|
+
- name: exception-naming
|
|
63
|
+
type: class
|
|
64
|
+
filter: { base_class: Exception }
|
|
65
|
+
naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" }
|
|
66
|
+
|
|
67
|
+
apply:
|
|
68
|
+
- name: all
|
|
69
|
+
rules: [bool-method-prefix, exception-naming]
|
|
70
|
+
modules: "**"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Run:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pnl check
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Output:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
src/domain/service.py:12
|
|
83
|
+
[bool-method-prefix] validate (expected prefix: is_ | has_ | should_)
|
|
84
|
+
|
|
85
|
+
src/domain/exceptions.py:8
|
|
86
|
+
[exception-naming] FilterError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$)
|
|
87
|
+
|
|
88
|
+
Found 2 violation(s).
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Examples
|
|
92
|
+
|
|
93
|
+
### Variable Naming — Match Type Annotation
|
|
94
|
+
|
|
95
|
+
Enforce that variable names match their type annotation in snake_case:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
rules:
|
|
99
|
+
- name: attribute-matches-type
|
|
100
|
+
type: variable
|
|
101
|
+
filter: { target: attribute }
|
|
102
|
+
naming: { source: type_annotation, transform: snake_case }
|
|
103
|
+
|
|
104
|
+
apply:
|
|
105
|
+
- name: domain-layer
|
|
106
|
+
rules: [attribute-matches-type]
|
|
107
|
+
modules: contexts.*.domain
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
This catches `repo: SubscriptionRepository` (should be `subscription_repository`).
|
|
111
|
+
|
|
112
|
+
The `{prefix}_{expected}` form is also allowed — `source_object_context: ObjectContext` passes because it ends with `_object_context`.
|
|
113
|
+
|
|
114
|
+
### Module Naming — Match Class Name
|
|
115
|
+
|
|
116
|
+
Enforce that module filenames match the primary class they contain:
|
|
117
|
+
|
|
118
|
+
```yaml
|
|
119
|
+
rules:
|
|
120
|
+
- name: domain-module-naming
|
|
121
|
+
type: module
|
|
122
|
+
naming: { source: class_name, transform: snake_case }
|
|
123
|
+
|
|
124
|
+
apply:
|
|
125
|
+
- name: domain-layer
|
|
126
|
+
rules: [domain-module-naming]
|
|
127
|
+
modules: contexts.*.domain
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A file `custom.py` containing `class CustomObject` is a violation — it should be `custom_object.py`.
|
|
131
|
+
|
|
132
|
+
### Combining Rules Per Layer
|
|
133
|
+
|
|
134
|
+
Apply different rules to different parts of your codebase:
|
|
135
|
+
|
|
136
|
+
```yaml
|
|
137
|
+
rules:
|
|
138
|
+
- name: attribute-matches-type
|
|
139
|
+
type: variable
|
|
140
|
+
filter: { target: attribute }
|
|
141
|
+
naming: { source: type_annotation, transform: snake_case }
|
|
142
|
+
|
|
143
|
+
- name: bool-method-prefix
|
|
144
|
+
type: function
|
|
145
|
+
filter: { return_type: bool }
|
|
146
|
+
naming: { prefix: [is_, has_, should_] }
|
|
147
|
+
|
|
148
|
+
- name: exception-naming
|
|
149
|
+
type: class
|
|
150
|
+
filter: { base_class: Exception }
|
|
151
|
+
naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" }
|
|
152
|
+
|
|
153
|
+
- name: domain-module-naming
|
|
154
|
+
type: module
|
|
155
|
+
naming: { source: class_name, transform: snake_case }
|
|
156
|
+
|
|
157
|
+
- name: constant-upper-case
|
|
158
|
+
type: variable
|
|
159
|
+
filter: { target: constant }
|
|
160
|
+
naming: { case: UPPER_CASE }
|
|
161
|
+
|
|
162
|
+
apply:
|
|
163
|
+
- name: domain-layer
|
|
164
|
+
rules:
|
|
165
|
+
- attribute-matches-type
|
|
166
|
+
- bool-method-prefix
|
|
167
|
+
- domain-module-naming
|
|
168
|
+
- constant-upper-case
|
|
169
|
+
modules: contexts.*.domain
|
|
170
|
+
|
|
171
|
+
- name: global-exceptions
|
|
172
|
+
rules: [exception-naming]
|
|
173
|
+
modules: "**"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Configuration
|
|
177
|
+
|
|
178
|
+
### Rule Types
|
|
179
|
+
|
|
180
|
+
| Type | Target |
|
|
181
|
+
|------|--------|
|
|
182
|
+
| `variable` | Variable names (attribute, parameter, local_variable, constant) |
|
|
183
|
+
| `function` | Function/method names |
|
|
184
|
+
| `class` | Class names (including exceptions) |
|
|
185
|
+
| `module` | Module (file) names |
|
|
186
|
+
| `package` | Package (directory) names |
|
|
187
|
+
|
|
188
|
+
### Filter
|
|
189
|
+
|
|
190
|
+
Each rule can narrow its scope with type-specific filters:
|
|
191
|
+
|
|
192
|
+
| Type | Filter | Example Values |
|
|
193
|
+
|------|--------|----------------|
|
|
194
|
+
| `variable` | `target` | `attribute`, `parameter`, `local_variable`, `constant` |
|
|
195
|
+
| `function` | `target` | `method`, `function` |
|
|
196
|
+
| `function` | `return_type` | `bool` |
|
|
197
|
+
| `function` | `decorator` | `staticmethod` |
|
|
198
|
+
| `class` | `base_class` | `Exception` |
|
|
199
|
+
| `class` | `decorator` | `dataclass` |
|
|
200
|
+
|
|
201
|
+
### Naming Constraints
|
|
202
|
+
|
|
203
|
+
| Field | Description | Example |
|
|
204
|
+
|-------|-------------|---------|
|
|
205
|
+
| `prefix` | Name must start with one of the listed prefixes | `[is_, has_]` |
|
|
206
|
+
| `suffix` | Name must end with one of the listed suffixes | `[Repository, Service]` |
|
|
207
|
+
| `regex` | Name must match a regular expression | `"^[A-Z][a-zA-Z]+Error$"` |
|
|
208
|
+
| `source` + `transform` | Name must be derived from another element | `source: type_annotation`, `transform: snake_case` |
|
|
209
|
+
| `case` | Name must follow a casing convention | `snake_case`, `PascalCase`, `UPPER_CASE` |
|
|
210
|
+
|
|
211
|
+
### Include / Exclude
|
|
212
|
+
|
|
213
|
+
Control which files are scanned:
|
|
214
|
+
|
|
215
|
+
```yaml
|
|
216
|
+
include:
|
|
217
|
+
- src
|
|
218
|
+
exclude:
|
|
219
|
+
- src/generated/**
|
|
220
|
+
|
|
221
|
+
rules:
|
|
222
|
+
- name: ...
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
- **No `include` or `exclude`** — All `.py` files under the project root are scanned
|
|
226
|
+
- **`include` only** — Only files matching the given paths are scanned
|
|
227
|
+
- **`exclude` only** — All files except those matching the given paths are scanned
|
|
228
|
+
- **Both** — `include` is applied first, then `exclude` filters within that result
|
|
229
|
+
|
|
230
|
+
### Wildcard
|
|
231
|
+
|
|
232
|
+
`*` matches a single level in dotted module paths:
|
|
233
|
+
|
|
234
|
+
```yaml
|
|
235
|
+
modules: contexts.*.domain # matches contexts.boards.domain, contexts.auth.domain, ...
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`**` matches one or more levels:
|
|
239
|
+
|
|
240
|
+
```yaml
|
|
241
|
+
modules: contexts.**.domain # matches contexts.boards.domain, contexts.boards.sub.domain, ...
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Named Capture
|
|
245
|
+
|
|
246
|
+
`{name}` captures a single level (like `*`) and allows back-referencing:
|
|
247
|
+
|
|
248
|
+
```yaml
|
|
249
|
+
apply:
|
|
250
|
+
- name: domain-isolation
|
|
251
|
+
rules: [attribute-matches-type]
|
|
252
|
+
modules: contexts.{context}.domain
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### pyproject.toml
|
|
256
|
+
|
|
257
|
+
You can also configure in `pyproject.toml`:
|
|
258
|
+
|
|
259
|
+
```toml
|
|
260
|
+
[[tool.python-naming-linter.rules]]
|
|
261
|
+
name = "bool-method-prefix"
|
|
262
|
+
type = "function"
|
|
263
|
+
|
|
264
|
+
[tool.python-naming-linter.rules.filter]
|
|
265
|
+
return_type = "bool"
|
|
266
|
+
|
|
267
|
+
[tool.python-naming-linter.rules.naming]
|
|
268
|
+
prefix = ["is_", "has_", "should_"]
|
|
269
|
+
|
|
270
|
+
[[tool.python-naming-linter.apply]]
|
|
271
|
+
name = "all"
|
|
272
|
+
rules = ["bool-method-prefix"]
|
|
273
|
+
modules = "**"
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## CLI
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
# Check with auto-discovered config (searches upward from cwd)
|
|
280
|
+
pnl check
|
|
281
|
+
|
|
282
|
+
# Specify config file (project root = config file's parent directory)
|
|
283
|
+
pnl check --config path/to/config.yaml
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Exit codes:
|
|
287
|
+
|
|
288
|
+
- `0` — No violations
|
|
289
|
+
- `1` — Violations found
|
|
290
|
+
- `2` — Config file not found
|
|
291
|
+
|
|
292
|
+
If no `--config` is given, the tool searches upward from the current directory for `.python-naming-linter.yaml` or `pyproject.toml` (with `[tool.python-naming-linter]`). The config file's parent directory is used as the project root.
|
|
293
|
+
|
|
294
|
+
## Pre-commit
|
|
295
|
+
|
|
296
|
+
Add to `.pre-commit-config.yaml`:
|
|
297
|
+
|
|
298
|
+
```yaml
|
|
299
|
+
- repo: https://github.com/heumsi/python-naming-linter
|
|
300
|
+
rev: '' # Use the tag you want to point at (e.g., v0.1.0)
|
|
301
|
+
hooks:
|
|
302
|
+
- id: python-naming-linter
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
To pass custom options:
|
|
306
|
+
|
|
307
|
+
```yaml
|
|
308
|
+
- repo: https://github.com/heumsi/python-naming-linter
|
|
309
|
+
rev: ''
|
|
310
|
+
hooks:
|
|
311
|
+
- id: python-naming-linter
|
|
312
|
+
args: [--config, custom-config.yaml]
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## License
|
|
316
|
+
|
|
317
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
python_naming_linter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
python_naming_linter/cli.py,sha256=ITltPeDv7TeRWn563X4kk0BdjLEMe6TULflcsM6mRss,6311
|
|
3
|
+
python_naming_linter/config.py,sha256=AOqDWTsY-TW4uJe5FTiI5RuF8bVURUrMx0C-L8osO1o,2846
|
|
4
|
+
python_naming_linter/matcher.py,sha256=K5BWvlrLWecZnNUzViMN8CDcxsTrCU9SEU4VG5xAY-M,2049
|
|
5
|
+
python_naming_linter/reporter.py,sha256=tMqCFjpKPN7pzTMVqPy0GRL4sCG3LotRjYgVYbTkzPI,417
|
|
6
|
+
python_naming_linter/checkers/__init__.py,sha256=XbFREg3x8LpVLq5ns_enoK16iD37XndNv8QuTZSsf2Y,185
|
|
7
|
+
python_naming_linter/checkers/class_.py,sha256=iFsJ5XD77O1Frc-nkcNaAtPw3oh-i7IlPzc6J5aUONw,5005
|
|
8
|
+
python_naming_linter/checkers/function.py,sha256=QjqjG4nmNnn8tyeuNBL7DEVXrgoPW2tOA5S3DUnS3hk,4735
|
|
9
|
+
python_naming_linter/checkers/module.py,sha256=J-r9bHVxjfI9yQ6pnLERAGuZqeSeEqoyGqTaI-I_yOE,3165
|
|
10
|
+
python_naming_linter/checkers/package.py,sha256=tVRQIThr1QJVV5WgRNc5gmvHZfWYlnPjvYwDhhpbyIw,1084
|
|
11
|
+
python_naming_linter/checkers/variable.py,sha256=9tYYZq0cFsfL0UVJ4N0-cEZAjQjyea1xGHlRHelht9E,6093
|
|
12
|
+
python_naming_linter-0.1.0.dist-info/METADATA,sha256=IZMMwS7dj47cwK87PoPDcNo1JK9tF1odJl5VOT33KXA,8126
|
|
13
|
+
python_naming_linter-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
python_naming_linter-0.1.0.dist-info/entry_points.txt,sha256=B8XYwva_7u3ukDd8PvApVDMi4sf5yszMiVcaWuymhTM,54
|
|
15
|
+
python_naming_linter-0.1.0.dist-info/licenses/LICENSE,sha256=6jnaAo6a4d5VHjYOiCeh2k2tlMKfCXI4itz82GNbuMs,1063
|
|
16
|
+
python_naming_linter-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 heumsi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|