Skip to content

Commit ecff34c

Browse files
authored
complete refactor (#49)
- Functions are properly organized in modules with appropriate names. - REST API usage is isolated in class derived from an abstract base class that will easily allow integrating other git servers' REST API. - clang-format output is filtered by lines (when applicable) as the XML is parsed. - The comment is made after all clang tool output is parsed. - All clang tool output is parsed from buffered strings (addresses #48). - There isn't any cached files when verbosity is not set to debug mode. - Tests can now use mocked HTTP requests (no more fear of hitting REST API rate limits). - Updated docs to reflect the new package structure. - CLI supports file paths/names given as positional args - Removed dead code about previous attempt to create review comments - Change acceptable verbosity input values to be "debug" or "10" - Updated help strings in CLI for narrow terminals - fix problem in "is ignored" logic Due to python's lazy logic evaluation, we need to check if a path is first explicitly not-ignored before checking if said source is ignored.
1 parent 5f67781 commit ecff34c

File tree

80 files changed

+34756
-2162
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

80 files changed

+34756
-2162
lines changed

.github/workflows/run-dev-tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ jobs:
6161
- name: Install workflow deps
6262
# using a wildcard as filename on Windows requires a bash shell
6363
shell: bash
64-
run: python3 -m pip install pytest coverage[toml] meson dist/*.whl
64+
run: python3 -m pip install pytest requests-mock coverage[toml] meson dist/*.whl
6565

6666
# https://github.com/ninja-build/ninja/wiki/Pre-built-Ninja-packages
6767
- name: Install ninja (Linux)

.pre-commit-config.yaml

+11-1
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,14 @@ repos:
3535
types: [python]
3636
entry: mypy
3737
exclude: "^(docs/|setup.py$)"
38-
additional_dependencies: [mypy, types-pyyaml, types-requests, rich, requests, pytest, pyyaml, meson, '.']
38+
additional_dependencies:
39+
- mypy
40+
- types-pyyaml
41+
- types-requests
42+
- rich
43+
- requests
44+
- pytest
45+
- pyyaml
46+
- meson
47+
- requests-mock
48+
- '.'

cpp_linter/__init__.py

+71-218
Original file line numberDiff line numberDiff line change
@@ -1,234 +1,87 @@
1-
"""The Base module of the :mod:`cpp_linter` package. This holds the objects shared by
2-
multiple modules."""
3-
import os
4-
from pathlib import Path
1+
"""Run clang-tidy and clang-format on a list of files.
2+
If executed from command-line, then `main()` is the entrypoint.
3+
"""
4+
import json
55
import logging
6-
import platform
7-
from typing import TYPE_CHECKING, List, Dict, Tuple, Any, Union, Optional
8-
import shutil
9-
from requests import Response
10-
11-
if TYPE_CHECKING: # Used to avoid circular imports
12-
from cpp_linter.clang_format_xml import XMLFixit # noqa: F401
13-
from cpp_linter.clang_tidy_yml import YMLFixit # noqa: F401
14-
from cpp_linter.clang_tidy import TidyNotification # noqa: F401
15-
16-
FOUND_RICH_LIB = False
17-
try:
18-
from rich.logging import RichHandler
19-
20-
FOUND_RICH_LIB = True
21-
22-
logging.basicConfig(
23-
format="%(name)s: %(message)s",
24-
handlers=[RichHandler(show_time=False)],
25-
)
6+
import os
7+
from .common_fs import list_source_files, CACHE_PATH
8+
from .loggers import start_log_group, end_log_group, logger
9+
from .clang_tools import capture_clang_tools_output
10+
from .cli import cli_arg_parser, parse_ignore_option
11+
from .rest_api.github_api import GithubApiClient
2612

27-
except ImportError: # pragma: no cover
28-
logging.basicConfig()
2913

30-
#: The :py:class:`logging.Logger` object used for outputting data.
31-
logger = logging.getLogger("CPP Linter")
32-
if not FOUND_RICH_LIB:
33-
logger.debug("rich module not found")
14+
def main():
15+
"""The main script."""
3416

35-
# global constant variables
36-
IS_ON_RUNNER = bool(os.getenv("CI"))
37-
GITHUB_SHA = os.getenv("GITHUB_SHA", "")
38-
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", os.getenv("GIT_REST_API", ""))
39-
IS_ON_WINDOWS = platform.system().lower() == "windows"
40-
CACHE_PATH = Path(os.getenv("CPP_LINTER_CACHE", ".cpp-linter_cache"))
41-
CLANG_FORMAT_XML = CACHE_PATH / "clang_format_output.xml"
42-
CLANG_TIDY_YML = CACHE_PATH / "clang_tidy_output.yml"
43-
CLANG_TIDY_STDOUT = CACHE_PATH / "clang_tidy_report.txt"
44-
CHANGED_FILES_JSON = CACHE_PATH / "changed_files.json"
17+
# The parsed CLI args
18+
args = cli_arg_parser.parse_args()
4519

20+
# force files-changed-only to reflect value of lines-changed-only
21+
if args.lines_changed_only:
22+
args.files_changed_only = True
4623

47-
def make_headers(use_diff: bool = False) -> Dict[str, str]:
48-
"""Create a `dict` for use in REST API headers.
24+
rest_api_client = GithubApiClient()
25+
logger.info("processing %s event", rest_api_client.event_name)
4926

50-
:param use_diff: A flag to indicate that the returned format should be in diff
51-
syntax.
52-
:returns: A `dict` to be used as headers in `requests` API calls.
53-
"""
54-
headers = {
55-
"Accept": "application/vnd.github." + ("diff" if use_diff else "text+json"),
56-
}
57-
if GITHUB_TOKEN:
58-
headers["Authorization"] = f"token {GITHUB_TOKEN}"
59-
return headers
27+
# set logging verbosity
28+
logger.setLevel(10 if args.verbosity or rest_api_client.debug_enabled else 20)
6029

30+
# prepare ignored paths list
31+
ignored, not_ignored = parse_ignore_option(args.ignore, args.files)
6132

62-
class FileObj:
63-
"""A class to represent a single file being analyzed.
33+
# change working directory
34+
os.chdir(args.repo_root)
35+
CACHE_PATH.mkdir(exist_ok=True)
6436

65-
:param name: The file name. This should use Unix style path delimiters (``/``),
66-
even on Windows.
67-
:param additions: A `list` of line numbers that have added changes in the diff.
68-
This value is used to populate the `lines_added` property.
69-
:param diff_chunks: The ranges that define the beginning and ending line numbers
70-
for all hunks in the diff.
71-
"""
37+
if logger.getEffectiveLevel() <= logging.DEBUG:
38+
start_log_group("Event json from the runner")
39+
logger.debug(json.dumps(rest_api_client.event_payload))
40+
end_log_group()
7241

73-
def __init__(self, name: str, additions: List[int], diff_chunks: List[List[int]]):
74-
self.name: str = name #: The file name
75-
self.additions: List[int] = additions
76-
"""A list of line numbers that contain added changes. This will be empty if
77-
not focusing on lines changed only."""
78-
self.diff_chunks: List[List[int]] = diff_chunks
79-
"""A list of line numbers that define the beginning and ending of hunks in the
80-
diff. This will be empty if not focusing on lines changed only."""
81-
self.lines_added: List[List[int]] = FileObj._consolidate_list_to_ranges(
82-
additions
42+
if args.files_changed_only:
43+
files = rest_api_client.get_list_of_changed_files(
44+
args.extensions,
45+
ignored,
46+
not_ignored,
47+
args.lines_changed_only,
8348
)
84-
"""A list of line numbers that define the beginning and ending of ranges that
85-
have added changes. This will be empty if not focusing on lines changed only.
86-
"""
87-
88-
@staticmethod
89-
def _consolidate_list_to_ranges(numbers: List[int]) -> List[List[int]]:
90-
"""A helper function that is only used after parsing the lines from a diff that
91-
contain additions.
92-
93-
:param numbers: A `list` of integers representing the lines' numbers that
94-
contain additions.
95-
:returns: A consolidated sequence of lists. Each list will have 2 items
96-
describing the starting and ending lines of all line ``numbers``.
97-
"""
98-
result: List[List[int]] = []
99-
for i, n in enumerate(numbers):
100-
if not i:
101-
result.append([n])
102-
elif n - 1 != numbers[i - 1]:
103-
result[-1].append(numbers[i - 1] + 1)
104-
result.append([n])
105-
if i == len(numbers) - 1:
106-
result[-1].append(n + 1)
107-
return result
108-
109-
def range_of_changed_lines(
110-
self, lines_changed_only: int, get_ranges: bool = False
111-
) -> Union[List[int], List[List[int]]]:
112-
"""Assemble a list of lines changed.
113-
114-
:param lines_changed_only: A flag to indicate the focus of certain lines.
115-
116-
- ``0``: focuses on all lines in a file(s).
117-
- ``1``: focuses on any lines shown in the event's diff (may include
118-
unchanged lines).
119-
- ``2``: focuses strictly on lines in the diff that contain additions.
120-
:param get_ranges: A flag to return a list of sequences representing
121-
:py:class:`range` parameters. Defaults to `False` since this is only
122-
required when constructing clang-tidy or clang-format CLI arguments.
123-
:returns:
124-
A list of line numbers for which to give attention. If ``get_ranges`` is
125-
asserted, then the returned list will be a list of ranges. If
126-
``lines_changed_only`` is ``0``, then an empty list is returned.
127-
"""
128-
if lines_changed_only:
129-
ranges = self.diff_chunks if lines_changed_only == 1 else self.lines_added
130-
if get_ranges:
131-
return ranges
132-
return self.additions
133-
# we return an empty list (instead of None) here so we can still iterate it
134-
return [] # type: ignore[return-value]
135-
136-
def serialize(self) -> Dict[str, Any]:
137-
"""For easy debugging, use this method to serialize the `FileObj` into a json
138-
compatible `dict`."""
139-
return {
140-
"filename": self.name,
141-
"line_filter": {
142-
"diff_chunks": self.diff_chunks,
143-
"lines_added": self.lines_added,
144-
},
145-
}
146-
147-
148-
class Globals:
149-
"""Global variables for re-use (non-constant)."""
150-
151-
TIDY_COMMENT: str = ""
152-
"""The accumulated output of clang-tidy (gets appended to OUTPUT)"""
153-
FORMAT_COMMENT: str = ""
154-
OUTPUT: str = "<!-- cpp linter action -->\n# Cpp-Linter Report "
155-
"""The accumulated body of the resulting comment that gets posted."""
156-
FILES: List[FileObj] = []
157-
"""The responding payload containing info about changed files."""
158-
EVENT_PAYLOAD: Dict[str, Any] = {}
159-
"""The parsed JSON of the event payload."""
160-
response_buffer: Response = Response()
161-
"""A shared response object for `requests` module."""
162-
format_failed_count: int = 0
163-
"""A total count of clang-format concerns"""
164-
tidy_failed_count: int = 0
165-
"""A total count of clang-tidy concerns"""
166-
167-
168-
class GlobalParser:
169-
"""Global variables specific to output parsers. Each element in each of the
170-
following attributes represents a clang-tool's output for 1 source file.
171-
"""
172-
173-
tidy_notes = [] # type: List[TidyNotification]
174-
"""This can only be a `list` of type
175-
:class:`~cpp_linter.clang_tidy.TidyNotification`."""
176-
tidy_advice = [] # type: List[YMLFixit]
177-
"""This can only be a `list` of type :class:`~cpp_linter.clang_tidy_yml.YMLFixit`.
178-
"""
179-
format_advice = [] # type: List[XMLFixit]
180-
"""This can only be a `list` of type :class:`~cpp_linter.clang_format_xml.XMLFixit`.
181-
"""
182-
183-
184-
def get_line_cnt_from_cols(file_path: str, offset: int) -> Tuple[int, int]:
185-
"""Gets a line count and columns offset from a file's absolute offset.
186-
187-
:param file_path: Path to file.
188-
:param offset: The byte offset to translate
189-
190-
:returns:
191-
A `tuple` of 2 `int` numbers:
192-
193-
- Index 0 is the line number for the given offset.
194-
- Index 1 is the column number for the given offset on the line.
195-
"""
196-
# logger.debug("Getting line count from %s at offset %d", file_path, offset)
197-
contents = Path(file_path).read_bytes()[:offset]
198-
return (contents.count(b"\n") + 1, offset - contents.rfind(b"\n"))
199-
200-
201-
def log_response_msg() -> bool:
202-
"""Output the response buffer's message on a failed request.
203-
204-
:returns:
205-
A bool describing if response's status code was less than 400.
206-
"""
207-
if Globals.response_buffer.status_code >= 400:
208-
logger.error(
209-
"response returned %d message: %s",
210-
Globals.response_buffer.status_code,
211-
Globals.response_buffer.text,
49+
if files:
50+
rest_api_client.verify_files_are_present(files)
51+
else:
52+
files = list_source_files(args.extensions, ignored, not_ignored)
53+
if not files:
54+
logger.info("No source files need checking!")
55+
else:
56+
logger.info(
57+
"Giving attention to the following files:\n\t%s",
58+
"\n\t".join([f.name for f in files]),
21259
)
213-
return False
214-
return True
60+
end_log_group()
61+
62+
(format_advice, tidy_advice) = capture_clang_tools_output(
63+
files,
64+
args.version,
65+
args.tidy_checks,
66+
args.style,
67+
args.lines_changed_only,
68+
args.database,
69+
args.extra_arg,
70+
)
21571

72+
start_log_group("Posting comment(s)")
73+
rest_api_client.post_feedback(
74+
files,
75+
format_advice,
76+
tidy_advice,
77+
args.thread_comments,
78+
args.no_lgtm,
79+
args.step_summary,
80+
args.file_annotations,
81+
args.style,
82+
)
83+
end_log_group()
21684

217-
def assemble_version_exec(tool_name: str, specified_version: str) -> Optional[str]:
218-
"""Assembles the command to the executable of the given clang tool based on given
219-
version information.
22085

221-
:param tool_name: The name of the clang tool to be executed.
222-
:param specified_version: The version number or the installed path to a version of
223-
the tool's executable.
224-
"""
225-
semver = specified_version.split(".")
226-
exe_path = None
227-
if semver and semver[0].isdigit(): # version info is not a path
228-
# let's assume the exe is in the PATH env var
229-
exe_path = shutil.which(f"{tool_name}-{specified_version}")
230-
elif specified_version: # treat value as a path to binary executable
231-
exe_path = shutil.which(tool_name, path=specified_version)
232-
if exe_path is not None:
233-
return exe_path
234-
return shutil.which(tool_name)
86+
if __name__ == "__main__":
87+
main()

0 commit comments

Comments
 (0)