Coverage for pycommons / dev / building / static_analysis.py: 100%
60 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-17 00:00 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-17 00:00 +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=A005,B008,B009,B010,DUO102,PIE799,PT011,TRY003,TRY101,W503"),
44 ("pyroma", "."),
45 ("pydocstyle", ".", "--convention=pep257"),
46 ("vulture", ".", "--exclude", __EXCLUDES, "--min-confidence", "61"),
47 ("dodgy", "."),
48 ("pyrefly", "check", "."),
49)
51#: the rule sets we use for ruff
52__RUFF_RULES: Final[str] =\
53 ("--select=A,AIR,ANN,ARG,ASYNC,B,BLE,C,C4,COM,D,DJ,DOC,DTZ,E,ERA,EXE,F,"
54 "FA,FIX,FLY,FURB,G,I,ICN,INP,ISC,INT,LOG,N,NPY,PERF,PGH,PIE,PL,PLC,PLE,"
55 "PLR,PLW,PT,PYI,Q,RET,RSE,RUF,S,SIM,SLF,SLOT,T,T10,T20,TC,TD,TID,TRY,"
56 "UP,W,YTT")
58#: the ruff rules that we ignore
59__RUFF_IGNORE: Final[str] =\
60 ("--ignore=A005,ANN001,ANN002,ANN003,ANN204,ANN401,ARG002,B008,B009,B010,"
61 "C901,D203,D208,D212,D401,D407,D413,DOC201,DOC402,DOC501,FURB189,N801,"
62 "PGH003,PGH004,PLC2801,PLR0904,PLR0911,PLR0912,PLR0913,PLR0914,PLR0915,"
63 "PLR0916,PLR0917,PLR1702,PLR2004,PLR6301,PT011,PT012,PT013,"
64 "PYI041,RUF100,S,TC001,TC002,SLF001,TRY003,UP035,UP047,W")
66#: the pylint rules that we ignore
67__PYLINT_IGNORE: Final[str] =\
68 ("--disable=C0103,C0302,C0325,R0801,R0901,R0902,R0903,R0911,R0912,R0913,"
69 "R0914,R0915,R0916,R0917,R1702,R1728,W0212,W0238,W0703")
71#: the ruff target version
72__RUF_TARGET_VERSION: Final[str] = "py312"
74#: a list of analysis to be applied to the package directory
75__PACKAGE_ANALYSES: Final[tuple[tuple[str, ...], ...]] = (
76 ("pyflakes", "."),
77 ("pylint", ".", __PYLINT_IGNORE),
78 ("mypy", ".", "--no-strict-optional", "--check-untyped-defs",
79 "--explicit-package-bases"),
80 ("bandit", "-r", ".", "-s", "B311"),
81 ("tryceratops", ".", "-i", "TRY003", "-i", "TRY101"),
82 ("unimport", "."),
83 ("pycodestyle", "."),
84 ("ruff", "check", "--target-version", __RUF_TARGET_VERSION, __RUFF_RULES,
85 __RUFF_IGNORE, "--line-length", "79", "--preview", "."),
86)
88#: a list of analysis to be applied to the test directory
89__TESTS_ANALYSES: Final[tuple[tuple[str, ...], ...]] = (
90 ("pylint", ".", __PYLINT_IGNORE),
91 ("mypy", ".", "--no-strict-optional", "--check-untyped-defs"),
92 ("bandit", "-r", ".", "-s", "B311,B101"),
93 ("tryceratops", ".", "-i", "TRY003", "-i", "TRY101"),
94 ("unimport", "."),
95 ("pycodestyle", "."),
96 ("ruff", "check", "--target-version", __RUF_TARGET_VERSION,
97 __RUFF_RULES, f"{__RUFF_IGNORE},INP001", "--preview", "."),
98)
100#: a list of analysis to be applied to the examples directory
101__EXAMPLES_ANALYSES: Final[tuple[tuple[str, ...], ...]] = (
102 ("pylint", ".", __PYLINT_IGNORE),
103 ("bandit", "-r", ".", "-s", "B311"),
104 ("tryceratops", ".", "-i", "TRY003", "-i", "TRY101"),
105 ("unimport", "."),
106 ("pycodestyle", "--ignore=E731,W503", "."),
107 ("ruff", "check", "--target-version", __RUF_TARGET_VERSION,
108 __RUFF_RULES.replace(",T20", ""), f"{__RUFF_IGNORE},INP001,T201",
109 "--line-length", "79", "--preview", "."),
110)
112#: a list of analysis to be applied to the documentation source directory
113__DOC_SOURCE: Final[tuple[tuple[str, ...], ...]] = __EXAMPLES_ANALYSES
116def static_analysis(info: BuildInfo) -> None:
117 """
118 Perform the static code analysis for a Python project.
120 :param info: the build information
122 >>> from contextlib import redirect_stdout
123 >>> with redirect_stdout(None):
124 ... static_analysis(BuildInfo(
125 ... Path(__file__).up(4), "pycommons", "tests",
126 ... "examples", "docs/source"))
128 >>> try:
129 ... static_analysis(None)
130 ... except TypeError as te:
131 ... print(str(te)[:50])
132 info should be an instance of pycommons.dev.buildi
134 >>> try:
135 ... static_analysis(1)
136 ... except TypeError as te:
137 ... print(str(te)[:50])
138 info should be an instance of pycommons.dev.buildi
139 """
140 if not isinstance(info, BuildInfo):
141 raise type_error(info, "info", BuildInfo)
143 text: Final[str] = f"static analysis for {info}"
144 logger(f"Performing {text}.")
146 current: Final[Path] = directory_path(getcwd())
147 try:
148 errors: list[str] = []
149 for what, analysis, path in (
150 ("base", __BASE_ANALYSES, info.base_dir),
151 ("package", __PACKAGE_ANALYSES, info.sources_dir),
152 ("tests", __TESTS_ANALYSES, info.tests_dir),
153 ("examples", __EXAMPLES_ANALYSES, info.examples_dir),
154 ("doc", __DOC_SOURCE, info.doc_source_dir)):
155 if path is None:
156 continue
158 # If we only have a single Python file in the directory, then
159 # we will only check this single file.
160 use_path: Path = path
161 single_file: Path | None = None
162 for thepath in path.list_dir(True, True):
163 if thepath.is_file():
164 if thepath.endswith(".py"):
165 if single_file is None:
166 single_file = thepath
167 else:
168 single_file = None
169 break
170 else:
171 single_file = None
172 break
173 if single_file is not None:
174 use_path = single_file
176 for a in analysis:
177 logger(f"Applying {a[0]} to {what}.")
178 __exec(replace_in_cmd(a, use_path), info, errors.append)
179 finally:
180 chdir(current)
182 if list.__len__(errors) <= 0:
183 logger(f"Successfully completed {text}.")
184 return
186 logger(f"The {text} encountered the following errors:")
187 for error in errors:
188 logger(error + "\n")
190 raise ValueError(f"Failed to do {text}:\n{'\n'.join(errors)}")
193# Run static analysis program if executed as script.
194# This part cannot appear in unit test coverage, but we do test it.
195if __name__ == "__main__": # pragma: no cover
196 parser: Final[ArgumentParser] = pycommons_argparser(
197 __file__,
198 "Apply Static Code Analysis Tools",
199 "This utility applies a big heap of static code analysis tools in "
200 "a unified way as I use it throughout my projects.")
201 static_analysis(parse_project_arguments(parser))