grind-t-lint-rules 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- grind_t_lint_rules-0.1.0/PKG-INFO +12 -0
- grind_t_lint_rules-0.1.0/pyproject.toml +130 -0
- grind_t_lint_rules-0.1.0/pyproject.toml.orig +92 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/__init__.py +4 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_adapters/__init__.py +0 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_adapters/fs_source_files.py +9 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_core/__init__.py +0 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_core/bases.py +12 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_core/public_classes.py +27 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_core/single_method_classes.py +67 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_core/violation.py +10 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_ports/__init__.py +0 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_ports/source_files.py +8 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_usecases/__init__.py +0 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_usecases/check_public_classes.py +19 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_usecases/check_single_method_classes.py +21 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_usecases/check_source_files.py +25 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_wiring/__init__.py +0 -0
- grind_t_lint_rules-0.1.0/src/lint_rules/_wiring/cli.py +12 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grind-t-lint-rules
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Custom lint rules for Python projects
|
|
5
|
+
Author: grind-t
|
|
6
|
+
Author-email: grind-t <grindt.gd@gmail.com>
|
|
7
|
+
License-Expression: ISC
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
10
|
+
Requires-Python: >=3.14
|
|
11
|
+
Project-URL: Homepage, https://github.com/grind-t/lint-rules-py
|
|
12
|
+
Project-URL: Issues, https://github.com/grind-t/lint-rules-py/issues
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "grind-t-lint-rules"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Custom lint rules for Python projects"
|
|
5
|
+
license = "ISC"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
classifiers = [
|
|
8
|
+
"Programming Language :: Python :: 3",
|
|
9
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
10
|
+
]
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[[project.authors]]
|
|
14
|
+
name = "grind-t"
|
|
15
|
+
email = "grindt.gd@gmail.com"
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/grind-t/lint-rules-py"
|
|
19
|
+
Issues = "https://github.com/grind-t/lint-rules-py/issues"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
lint-rules = "lint_rules._wiring.cli:main"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build>=0.12.9,<0.13.0"]
|
|
26
|
+
build-backend = "uv_build"
|
|
27
|
+
|
|
28
|
+
[tool.uv.build-backend]
|
|
29
|
+
module-name = "lint_rules"
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
select = [
|
|
33
|
+
"E",
|
|
34
|
+
"F",
|
|
35
|
+
"I",
|
|
36
|
+
"UP",
|
|
37
|
+
"B",
|
|
38
|
+
"SIM",
|
|
39
|
+
"RUF",
|
|
40
|
+
"PT",
|
|
41
|
+
"N",
|
|
42
|
+
"C4",
|
|
43
|
+
"PIE",
|
|
44
|
+
"PERF",
|
|
45
|
+
"FURB",
|
|
46
|
+
"PTH",
|
|
47
|
+
"T20",
|
|
48
|
+
"ANN",
|
|
49
|
+
"ASYNC",
|
|
50
|
+
"DTZ",
|
|
51
|
+
]
|
|
52
|
+
ignore = ["RUF001"]
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint.per-file-ignores]
|
|
55
|
+
"tests/**" = ["ANN"]
|
|
56
|
+
"benchmarks/**" = ["ANN"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
addopts = [
|
|
61
|
+
"--import-mode=importlib",
|
|
62
|
+
"--strict-markers",
|
|
63
|
+
"--strict-config",
|
|
64
|
+
"-ra",
|
|
65
|
+
]
|
|
66
|
+
xfail_strict = true
|
|
67
|
+
filterwarnings = ["error"]
|
|
68
|
+
|
|
69
|
+
[tool.poe.tasks]
|
|
70
|
+
fmt = "ruff format"
|
|
71
|
+
typecheck = "ty check"
|
|
72
|
+
test-unit = "pytest tests/core tests/usecases"
|
|
73
|
+
test-integration = "pytest tests --ignore=tests/core --ignore=tests/usecases"
|
|
74
|
+
test = "pytest"
|
|
75
|
+
bench = "pytest benchmarks --codspeed"
|
|
76
|
+
build = "uv build"
|
|
77
|
+
check = [
|
|
78
|
+
"lint",
|
|
79
|
+
"typecheck",
|
|
80
|
+
"test-unit",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
[[tool.poe.tasks.lint]]
|
|
84
|
+
cmd = "ruff check"
|
|
85
|
+
|
|
86
|
+
[[tool.poe.tasks.lint]]
|
|
87
|
+
cmd = "lint-imports"
|
|
88
|
+
|
|
89
|
+
[[tool.poe.tasks.lint]]
|
|
90
|
+
cmd = "lint-rules src"
|
|
91
|
+
|
|
92
|
+
[tool.importlinter]
|
|
93
|
+
root_package = "lint_rules"
|
|
94
|
+
include_external_packages = true
|
|
95
|
+
|
|
96
|
+
[[tool.importlinter.contracts]]
|
|
97
|
+
name = "Layers: dependencies point inward only"
|
|
98
|
+
type = "layers"
|
|
99
|
+
layers = [
|
|
100
|
+
"lint_rules._wiring",
|
|
101
|
+
"lint_rules._adapters | lint_rules._usecases",
|
|
102
|
+
"lint_rules._ports",
|
|
103
|
+
"lint_rules._core",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
[[tool.importlinter.contracts]]
|
|
107
|
+
name = "Core, ports and use cases do no I/O"
|
|
108
|
+
type = "forbidden"
|
|
109
|
+
source_modules = [
|
|
110
|
+
"lint_rules._core",
|
|
111
|
+
"lint_rules._ports",
|
|
112
|
+
"lint_rules._usecases",
|
|
113
|
+
]
|
|
114
|
+
forbidden_modules = [
|
|
115
|
+
"os",
|
|
116
|
+
"io",
|
|
117
|
+
"pathlib",
|
|
118
|
+
"socket",
|
|
119
|
+
"subprocess",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
[dependency-groups]
|
|
123
|
+
dev = [
|
|
124
|
+
"import-linter",
|
|
125
|
+
"poethepoet",
|
|
126
|
+
"pytest",
|
|
127
|
+
"pytest-codspeed",
|
|
128
|
+
"ruff",
|
|
129
|
+
"ty",
|
|
130
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "grind-t-lint-rules"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Custom lint rules for Python projects"
|
|
5
|
+
license = "ISC"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "grind-t", email = "grindt.gd@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
13
|
+
]
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/grind-t/lint-rules-py"
|
|
18
|
+
Issues = "https://github.com/grind-t/lint-rules-py/issues"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
lint-rules = "lint_rules._wiring.cli:main"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.12.9,<0.13.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[tool.uv.build-backend]
|
|
28
|
+
module-name = "lint_rules"
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
select = [
|
|
32
|
+
"E", "F", "I", "UP", "B", "SIM", "RUF", "PT", "N",
|
|
33
|
+
"C4", "PIE", "PERF", "FURB", "PTH", "T20", "ANN", "ASYNC", "DTZ",
|
|
34
|
+
]
|
|
35
|
+
ignore = ["RUF001"] # allow Cyrillic in strings
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint.per-file-ignores]
|
|
38
|
+
"tests/**" = ["ANN"]
|
|
39
|
+
"benchmarks/**" = ["ANN"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
addopts = ["--import-mode=importlib", "--strict-markers", "--strict-config", "-ra"]
|
|
44
|
+
xfail_strict = true
|
|
45
|
+
filterwarnings = ["error"]
|
|
46
|
+
|
|
47
|
+
[tool.poe.tasks]
|
|
48
|
+
fmt = "ruff format"
|
|
49
|
+
lint = [
|
|
50
|
+
{ cmd = "ruff check" },
|
|
51
|
+
{ cmd = "lint-imports" },
|
|
52
|
+
{ cmd = "lint-rules src" },
|
|
53
|
+
]
|
|
54
|
+
typecheck = "ty check"
|
|
55
|
+
test-unit = "pytest tests/core tests/usecases"
|
|
56
|
+
test-integration = "pytest tests --ignore=tests/core --ignore=tests/usecases"
|
|
57
|
+
test = "pytest"
|
|
58
|
+
bench = "pytest benchmarks --codspeed"
|
|
59
|
+
build = "uv build"
|
|
60
|
+
check = ["lint", "typecheck", "test-unit"]
|
|
61
|
+
|
|
62
|
+
[dependency-groups]
|
|
63
|
+
dev = [
|
|
64
|
+
"import-linter",
|
|
65
|
+
"poethepoet",
|
|
66
|
+
"pytest",
|
|
67
|
+
"pytest-codspeed",
|
|
68
|
+
"ruff",
|
|
69
|
+
"ty",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# ---------- Architecture rules enforced by lint-imports ----------
|
|
73
|
+
[tool.importlinter]
|
|
74
|
+
root_package = "lint_rules"
|
|
75
|
+
include_external_packages = true
|
|
76
|
+
|
|
77
|
+
[[tool.importlinter.contracts]]
|
|
78
|
+
name = "Layers: dependencies point inward only"
|
|
79
|
+
type = "layers"
|
|
80
|
+
layers = [
|
|
81
|
+
"lint_rules._wiring",
|
|
82
|
+
"lint_rules._adapters | lint_rules._usecases",
|
|
83
|
+
"lint_rules._ports",
|
|
84
|
+
"lint_rules._core",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
[[tool.importlinter.contracts]]
|
|
88
|
+
name = "Core, ports and use cases do no I/O"
|
|
89
|
+
type = "forbidden"
|
|
90
|
+
source_modules = ["lint_rules._core", "lint_rules._ports", "lint_rules._usecases"]
|
|
91
|
+
# also add adapters' third-party dependencies here (httpx, etc.)
|
|
92
|
+
forbidden_modules = ["os", "io", "pathlib", "socket", "subprocess"]
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from collections.abc import Iterable, Iterator
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def read_fs_source_files(roots: Iterable[str]) -> Iterator[tuple[str, str]]:
|
|
6
|
+
"""Python files from the local filesystem."""
|
|
7
|
+
for root in roots:
|
|
8
|
+
for path in sorted(Path(root).rglob("*.py")):
|
|
9
|
+
yield str(path), path.read_text(encoding="utf-8")
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def base_name(base: ast.expr) -> str:
|
|
5
|
+
"""Last name component of a base class expression, e.g. ``Protocol[T]``."""
|
|
6
|
+
if isinstance(base, ast.Subscript):
|
|
7
|
+
base = base.value
|
|
8
|
+
if isinstance(base, ast.Name):
|
|
9
|
+
return base.id
|
|
10
|
+
if isinstance(base, ast.Attribute):
|
|
11
|
+
return base.attr
|
|
12
|
+
return ""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
from lint_rules._core.bases import base_name
|
|
4
|
+
|
|
5
|
+
ENUM_BASES = {"Enum", "IntEnum", "StrEnum", "Flag", "IntFlag"}
|
|
6
|
+
EXCEPTION_SUFFIXES = ("Error", "Exception", "Warning")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _is_exempt(cls: ast.ClassDef, exempt: set[str]) -> bool:
|
|
10
|
+
names = [base_name(base) for base in cls.bases]
|
|
11
|
+
return any(
|
|
12
|
+
n in ENUM_BASES or n in exempt or n.endswith(EXCEPTION_SUFFIXES) for n in names
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def public_classes(tree: ast.Module) -> list[str]:
|
|
17
|
+
"""Public top-level classes of a module, excluding exceptions and enums."""
|
|
18
|
+
exempt: set[str] = set()
|
|
19
|
+
found: list[str] = []
|
|
20
|
+
for node in tree.body:
|
|
21
|
+
if not isinstance(node, ast.ClassDef):
|
|
22
|
+
continue
|
|
23
|
+
if _is_exempt(node, exempt):
|
|
24
|
+
exempt.add(node.name)
|
|
25
|
+
elif not node.name.startswith("_"):
|
|
26
|
+
found.append(node.name)
|
|
27
|
+
return found
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
|
|
4
|
+
from lint_rules._core.bases import base_name
|
|
5
|
+
|
|
6
|
+
FUNCTION_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
7
|
+
BLOCK_FIELDS = ("body", "orelse", "finalbody", "handlers", "cases")
|
|
8
|
+
BLOCK_TYPES = (ast.stmt, ast.excepthandler, ast.match_case)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _statements(tree: ast.Module) -> Iterator[ast.AST]:
|
|
12
|
+
"""Every statement block of a module, without descending into expressions.
|
|
13
|
+
|
|
14
|
+
Classes can only appear among statements, so this finds the same classes
|
|
15
|
+
as ``ast.walk`` while skipping the bulk of the tree.
|
|
16
|
+
"""
|
|
17
|
+
stack: list[ast.AST] = [tree]
|
|
18
|
+
while stack:
|
|
19
|
+
node = stack.pop()
|
|
20
|
+
yield node
|
|
21
|
+
for field in BLOCK_FIELDS:
|
|
22
|
+
block = getattr(node, field, None)
|
|
23
|
+
if isinstance(block, list):
|
|
24
|
+
stack.extend(child for child in block if isinstance(child, BLOCK_TYPES))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_filler(stmt: ast.stmt) -> bool:
|
|
28
|
+
"""Docstrings, ``...`` and ``pass``: statements that add nothing to a class."""
|
|
29
|
+
if isinstance(stmt, ast.Pass):
|
|
30
|
+
return True
|
|
31
|
+
return isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _single_method(cls: ast.ClassDef) -> str | None:
|
|
35
|
+
"""Name of the only method of a stateless class."""
|
|
36
|
+
if cls.decorator_list or cls.keywords:
|
|
37
|
+
return None
|
|
38
|
+
bases = {base_name(base) for base in cls.bases} - {"object"}
|
|
39
|
+
if bases - {"Protocol"}:
|
|
40
|
+
return None
|
|
41
|
+
methods: list[str] = []
|
|
42
|
+
for stmt in cls.body:
|
|
43
|
+
if isinstance(stmt, FUNCTION_TYPES):
|
|
44
|
+
methods.append(stmt.name)
|
|
45
|
+
elif not _is_filler(stmt):
|
|
46
|
+
return None
|
|
47
|
+
if len(methods) != 1 or methods == ["__init__"]:
|
|
48
|
+
return None
|
|
49
|
+
if "Protocol" in bases and methods == ["__call__"]:
|
|
50
|
+
return None
|
|
51
|
+
return methods[0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def single_method_classes(tree: ast.Module) -> list[tuple[str, str, int]]:
|
|
55
|
+
"""``(class, method, line)`` for every class that should be a function.
|
|
56
|
+
|
|
57
|
+
A class qualifies when its body is one method other than ``__init__`` and
|
|
58
|
+
nothing else: no decorators, no base classes other than ``Protocol``,
|
|
59
|
+
no class attributes. A ``Protocol`` whose only method is ``__call__`` is
|
|
60
|
+
the recommended replacement and is not reported.
|
|
61
|
+
"""
|
|
62
|
+
found = [
|
|
63
|
+
(node.name, method, node.lineno)
|
|
64
|
+
for node in _statements(tree)
|
|
65
|
+
if isinstance(node, ast.ClassDef) and (method := _single_method(node))
|
|
66
|
+
]
|
|
67
|
+
return sorted(found, key=lambda item: item[2])
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
from lint_rules._core.public_classes import public_classes
|
|
4
|
+
from lint_rules._core.violation import Violation
|
|
5
|
+
|
|
6
|
+
MARKER = "# allow-multiple-public-classes"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_public_classes(path: str, source: str, tree: ast.Module) -> list[Violation]:
|
|
10
|
+
if MARKER in source:
|
|
11
|
+
return []
|
|
12
|
+
classes = public_classes(tree)
|
|
13
|
+
if len(classes) <= 1:
|
|
14
|
+
return []
|
|
15
|
+
message = (
|
|
16
|
+
f"{len(classes)} public classes ({', '.join(classes)}); "
|
|
17
|
+
"split them into separate modules"
|
|
18
|
+
)
|
|
19
|
+
return [Violation(path, message)]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
from lint_rules._core.single_method_classes import single_method_classes
|
|
4
|
+
from lint_rules._core.violation import Violation
|
|
5
|
+
|
|
6
|
+
MARKER = "# allow-single-method-classes"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_single_method_classes(
|
|
10
|
+
path: str, source: str, tree: ast.Module
|
|
11
|
+
) -> list[Violation]:
|
|
12
|
+
if MARKER in source:
|
|
13
|
+
return []
|
|
14
|
+
return [
|
|
15
|
+
Violation(
|
|
16
|
+
path,
|
|
17
|
+
f"line {line}: class {cls} has a single method {method}; "
|
|
18
|
+
"use a function, a Callable alias or a Protocol with __call__",
|
|
19
|
+
)
|
|
20
|
+
for cls, method, line in single_method_classes(tree)
|
|
21
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from collections.abc import Callable, Iterable
|
|
3
|
+
|
|
4
|
+
from lint_rules._core.violation import Violation
|
|
5
|
+
from lint_rules._ports.source_files import ReadSourceFiles
|
|
6
|
+
from lint_rules._usecases.check_public_classes import check_public_classes
|
|
7
|
+
from lint_rules._usecases.check_single_method_classes import (
|
|
8
|
+
check_single_method_classes,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type Check = Callable[[str, str, ast.Module], list[Violation]]
|
|
12
|
+
|
|
13
|
+
CHECKS: tuple[Check, ...] = (check_public_classes, check_single_method_classes)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_source_files(
|
|
17
|
+
read_files: ReadSourceFiles, roots: Iterable[str]
|
|
18
|
+
) -> list[Violation]:
|
|
19
|
+
"""Run every check on each file, parsing it only once."""
|
|
20
|
+
violations = []
|
|
21
|
+
for path, source in read_files(roots):
|
|
22
|
+
tree = ast.parse(source)
|
|
23
|
+
for check in CHECKS:
|
|
24
|
+
violations.extend(check(path, source, tree))
|
|
25
|
+
return violations
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from lint_rules._adapters.fs_source_files import read_fs_source_files
|
|
4
|
+
from lint_rules._usecases.check_source_files import check_source_files
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main(argv: list[str] | None = None) -> int:
|
|
8
|
+
roots = (sys.argv[1:] if argv is None else argv) or ["src"]
|
|
9
|
+
violations = check_source_files(read_fs_source_files, roots)
|
|
10
|
+
for violation in violations:
|
|
11
|
+
sys.stderr.write(f"{violation}\n")
|
|
12
|
+
return int(bool(violations))
|