Coverage for pycommons / dev / building / static_analysis.py: 94%
63 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 03:04 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 03:04 +0000
1"""Perform the static code analysis."""
3from argparse import ArgumentParser
4from os import chdir, getcwd
5from typing import Any, Callable, Final, Iterable
7from pycommons.dev.building.build_info import (
8 BuildInfo,
9 parse_project_arguments,
10 replace_in_cmd,
11)
12from pycommons.io.arguments import pycommons_argparser
13from pycommons.io.console import logger
14from pycommons.io.path import Path, directory_path
15from pycommons.processes.shell import Command
16from pycommons.types import type_error
19def __exec(arguments: Iterable[str],
20 info: BuildInfo,
21 errors: Callable[[str], Any]) -> None:
22 """
23 Execute a command.
25 :param arguments: the arguments
26 :param info: the build info
27 :param errors: the error collector
28 """
29 cmd: Final[Command] = info.command(arguments)
30 try:
31 cmd.execute(False)
32 except ValueError as ve:
33 errors(f"{cmd} failed with {ve!r}.")
36#: the files to exclude
37__EXCLUDES: Final[str] =\
38 ".svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.nox,.eggs,*.egg,.venv"
40#: a list of analysis to be applied to the base directory
41__BASE_ANALYSES: Final[tuple[tuple[str, ...], ...]] = (
42 ("flake8", ".", "--exclude", __EXCLUDES,
43 "--ignore=B008,B009,B010,DUO102,TRY003,TRY101,W503"),
44 ("pyroma", "."),
45 ("pydocstyle", ".", "--convention=pep257"),
46 ("vulture", ".", "--exclude", __EXCLUDES, "--min-confidence", "61"),
47 ("dodgy", "."),
48)
50#: the rule sets we use for ruff
51__RUFF_RULES: Final[str] =\
52 ("--select=A,AIR,ANN,ARG,ASYNC,B,BLE,C,C4,COM,D,DJ,DOC,DTZ,E,ERA,EXE,F,"
53 "FA,FIX,FLY,FURB,G,I,ICN,INP,ISC,INT,LOG,N,NPY,PERF,PGH,PIE,PL,PLC,PLE,"
54 "PLR,PLW,PT,PYI,Q,RET,RSE,RUF,S,SIM,SLF,SLOT,T,T10,T20,TC,TD,TID,TRY,"
55 "UP,W,YTT")
57#: the ruff rules that we ignore
58__RUFF_IGNORE: Final[str] =\
59 ("--ignore=A005,ANN001,ANN002,ANN003,ANN204,ANN401,ARG002,B008,B009,B010,"
60 "C901,D203,D208,D212,D401,D407,D413,DOC201,DOC402,DOC501,FURB189,N801,"
61 "PGH003,PGH004,PLC2801,PLR0904,PLR0911,PLR0912,PLR0913,PLR0914,PLR0915,"
62 "PLR0916,PLR0917,PLR1702,PLR2004,PLR6301,PT011,PT012,PT013,"
63 "PYI041,RUF100,S,TC001,TC002,SLF001,TRY003,UP035,UP047,W")
65#: the pylint rules that we ignore
66__PYLINT_IGNORE: Final[str] =\
67 ("--disable=C0103,C0302,C0325,R0801,R0901,R0902,R0903,R0911,R0912,R0913,"
68 "R0914,R0915,R0916,R0917,R1702,R1728,W0212,W0238,W0703")
70#: the ruff target version
71__RUF_TARGET_VERSION: Final[str] = "py312"
73#: a list of analysis to be applied to the package directory
74__PACKAGE_ANALYSES: Final[tuple[tuple[str, ...], ...]] = (
75 ("pyflakes", "."),
76 ("pylint", ".", __PYLINT_IGNORE),
77 ("mypy", ".", "--no-strict-optional", "--check-untyped-defs",
78 "--explicit-package-bases"),
79 ("bandit", "-r", ".", "-s", "B311"),
80 ("tryceratops", ".", "-i", "TRY003", "-i", "TRY101"),
81 ("unimport", "."),
82 ("pycodestyle", "."),
83 ("ruff", "check", "--target-version", __RUF_TARGET_VERSION, __RUFF_RULES,
84 __RUFF_IGNORE, "--line-length", "79", "--preview", "."),
85)
87#: a list of analysis to be applied to the test directory
88__TESTS_ANALYSES: Final[tuple[tuple[str, ...], ...]] = (
89 ("pylint", ".", __PYLINT_IGNORE),
90 ("mypy", ".", "--no-strict-optional", "--check-untyped-defs"),
91 ("bandit", "-r", ".", "-s", "B311,B101"),
92 ("tryceratops", ".", "-i", "TRY003", "-i", "TRY101"),
93 ("unimport", "."),
94 ("pycodestyle", "."),
95 ("ruff", "check", "--target-version", __RUF_TARGET_VERSION,
96 __RUFF_RULES, f"{__RUFF_IGNORE},INP001", "--preview", "."),
97)
99#: a list of analysis to be applied to the examples directory
100__EXAMPLES_ANALYSES: Final[tuple[tuple[str, ...], ...]] = (
101 ("pylint", ".", __PYLINT_IGNORE),
102 ("bandit", "-r", ".", "-s", "B311"),
103 ("tryceratops", ".", "-i", "TRY003", "-i", "TRY101"),
104 ("unimport", "."),
105 ("pycodestyle", "--ignore=E731,W503", "."),
106 ("ruff", "check", "--target-version", __RUF_TARGET_VERSION,
107 __RUFF_RULES.replace(",T20", ""), f"{__RUFF_IGNORE},INP001,T201",
108 "--line-length", "79", "--preview", "."),
109)
111#: a list of analysis to be applied to the examples directory
112__DOC_SOURCE: Final[tuple[tuple[str, ...], ...]] = __EXAMPLES_ANALYSES
115def static_analysis(info: BuildInfo) -> None:
116 """
117 Perform the static code analysis for a Python project.
119 :param info: the build information
121 >>> from contextlib import redirect_stdout
122 >>> with redirect_stdout(None):
123 ... static_analysis(BuildInfo(
124 ... Path(__file__).up(4), "pycommons", "tests",
125 ... "examples", "docs/source"))
127 >>> try:
128 ... static_analysis(None)
129 ... except TypeError as te:
130 ... print(str(te)[:50])
131 info should be an instance of pycommons.dev.buildi
133 >>> try:
134 ... static_analysis(1)
135 ... except TypeError as te:
136 ... print(str(te)[:50])
137 info should be an instance of pycommons.dev.buildi
138 """
139 if not isinstance(info, BuildInfo):
140 raise type_error(info, "info", BuildInfo)
142 text: Final[str] = f"static analysis for {info}"
143 logger(f"Performing {text}.")
145 current: Final[Path] = directory_path(getcwd())
146 try:
147 errors: list[str] = []
148 for what, analysis, path in (
149 ("base", __BASE_ANALYSES, info.base_dir),
150 ("package", __PACKAGE_ANALYSES, info.sources_dir),
151 ("tests", __TESTS_ANALYSES, info.tests_dir),
152 ("examples", __EXAMPLES_ANALYSES, info.examples_dir),
153 ("doc", __DOC_SOURCE, info.doc_source_dir)):
154 if path is None:
155 continue
157 # If we only have a single Python file in the directory, then
158 # we will only check this single file.
159 use_path: Path = path
160 single_file: Path | None = None
161 for thepath in path.list_dir(True, True):
162 if thepath.is_file():
163 if thepath.endswith(".py"):
164 if single_file is None:
165 single_file = thepath
166 else:
167 single_file = None
168 break
169 else:
170 single_file = None
171 break
172 if single_file is not None:
173 use_path = single_file
175 for a in analysis:
176 logger(f"Applying {a[0]} to {what}.")
177 __exec(replace_in_cmd(a, use_path), info, errors.append)
178 finally:
179 chdir(current)
181 if list.__len__(errors) <= 0:
182 logger(f"Successfully completed {text}.")
183 return
185 logger(f"The {text} encountered the following errors:")
186 for error in errors:
187 logger(error + "\n")
189 raise ValueError(f"Failed to do {text}:\n{'\n'.join(errors)}")
192# Run static analysis program if executed as script
193if __name__ == "__main__":
194 parser: Final[ArgumentParser] = pycommons_argparser(
195 __file__,
196 "Apply Static Code Analysis Tools",
197 "This utility applies a big heap of static code analysis tools in "
198 "a unified way as I use it throughout my projects.")
199 static_analysis(parse_project_arguments(parser))