Skip to content

Commit ce03c41

Browse files
authored
add: File IO timeout API (#133)
Born from the discussion in #129 and continued in #130. This adds timeout-oriented functions for reading and writing to the scanned source files. This supersedes #130 and resolves #129. * run clang-format before clang-tidy * prevent busy waits and log errors
1 parent 96835b7 commit ce03c41

File tree

6 files changed

+138
-37
lines changed

6 files changed

+138
-37
lines changed

cpp_linter/clang_tools/__init__.py

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Optional, List, Dict, Tuple, cast
77
import shutil
88

9-
from ..common_fs import FileObj
9+
from ..common_fs import FileObj, FileIOTimeout
1010
from ..common_fs.file_filter import TidyFileFilter, FormatFileFilter
1111
from ..loggers import start_log_group, end_log_group, worker_log_init, logger
1212
from .clang_tidy import run_clang_tidy, TidyAdvice
@@ -45,34 +45,52 @@ def _run_on_single_file(
4545
args: Args,
4646
) -> Tuple[str, str, Optional[TidyAdvice], Optional[FormatAdvice]]:
4747
log_stream = worker_log_init(log_lvl)
48-
49-
tidy_note = None
50-
if tidy_cmd is not None and (
51-
tidy_filter is None or tidy_filter.is_source_or_ignored(file.name)
52-
):
53-
tidy_note = run_clang_tidy(
54-
command=tidy_cmd,
55-
file_obj=file,
56-
checks=args.tidy_checks,
57-
lines_changed_only=args.lines_changed_only,
58-
database=args.database,
59-
extra_args=args.extra_arg,
60-
db_json=db_json,
61-
tidy_review=args.tidy_review,
62-
style=args.style,
63-
)
48+
filename = Path(file.name).as_posix()
6449

6550
format_advice = None
6651
if format_cmd is not None and (
6752
format_filter is None or format_filter.is_source_or_ignored(file.name)
6853
):
69-
format_advice = run_clang_format(
70-
command=format_cmd,
71-
file_obj=file,
72-
style=args.style,
73-
lines_changed_only=args.lines_changed_only,
74-
format_review=args.format_review,
75-
)
54+
try:
55+
format_advice = run_clang_format(
56+
command=format_cmd,
57+
file_obj=file,
58+
style=args.style,
59+
lines_changed_only=args.lines_changed_only,
60+
format_review=args.format_review,
61+
)
62+
except FileIOTimeout: # pragma: no cover
63+
logger.error(
64+
"Failed to read or write contents of %s when running clang-format",
65+
filename,
66+
)
67+
except OSError: # pragma: no cover
68+
logger.error(
69+
"Failed to open the file %s when running clang-format", filename
70+
)
71+
72+
tidy_note = None
73+
if tidy_cmd is not None and (
74+
tidy_filter is None or tidy_filter.is_source_or_ignored(file.name)
75+
):
76+
try:
77+
tidy_note = run_clang_tidy(
78+
command=tidy_cmd,
79+
file_obj=file,
80+
checks=args.tidy_checks,
81+
lines_changed_only=args.lines_changed_only,
82+
database=args.database,
83+
extra_args=args.extra_arg,
84+
db_json=db_json,
85+
tidy_review=args.tidy_review,
86+
style=args.style,
87+
)
88+
except FileIOTimeout: # pragma: no cover
89+
logger.error(
90+
"Failed to Read/Write contents of %s when running clang-tidy", filename
91+
)
92+
except OSError: # pragma: no cover
93+
logger.error("Failed to open the file %s when running clang-tidy", filename)
7694

7795
return file.name, log_stream.getvalue(), tidy_note, format_advice
7896

cpp_linter/clang_tools/clang_format.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,13 @@ def parse_format_replacements_xml(
130130
file_obj.range_of_changed_lines(lines_changed_only, get_ranges=True),
131131
)
132132
tree = ET.fromstring(xml_out)
133+
content = file_obj.read_with_timeout()
133134
for child in tree:
134135
if child.tag == "replacement":
135136
null_len = int(child.attrib["length"])
136137
text = "" if child.text is None else child.text
137138
offset = int(child.attrib["offset"])
138-
line, cols = get_line_cnt_from_cols(file_obj.name, offset)
139+
line, cols = get_line_cnt_from_cols(content, offset)
139140
is_line_in_ranges = False
140141
for r in ranges:
141142
if line in range(r[0], r[1]): # range is inclusive

cpp_linter/clang_tools/clang_tidy.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def run_clang_tidy(
246246
if tidy_review:
247247
# clang-tidy overwrites the file contents when applying fixes.
248248
# create a cache of original contents
249-
original_buf = Path(file_obj.name).read_bytes()
249+
original_buf = file_obj.read_with_timeout()
250250
cmds.append("--fix-errors") # include compiler-suggested fixes
251251
cmds.append(filename)
252252
logger.info('Running "%s"', " ".join(cmds))
@@ -260,10 +260,8 @@ def run_clang_tidy(
260260
advice = parse_tidy_output(results.stdout.decode(), database=db_json)
261261

262262
if tidy_review:
263-
# store the modified output from clang-tidy
264-
advice.patched = Path(file_obj.name).read_bytes()
265-
# re-write original file contents
266-
Path(file_obj.name).write_bytes(original_buf)
263+
# store the modified output from clang-tidy and re-write original file contents
264+
advice.patched = file_obj.read_write_with_timeout(original_buf)
267265

268266
return advice
269267

cpp_linter/clang_tools/patcher.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
by the clang tool's output."""
33

44
from abc import ABC
5-
from pathlib import Path
65
from typing import Optional, Dict, Any, List, Tuple
76
from pygit2 import Patch # type: ignore
87
from ..common_fs import FileObj
@@ -178,7 +177,7 @@ def get_suggestions_from_patch(
178177
self.patched
179178
), f"{self.__class__.__name__} has no suggestions for {file_obj.name}"
180179
patch = Patch.create_from(
181-
Path(file_obj.name).read_bytes(),
180+
file_obj.read_with_timeout(),
182181
self.patched,
183182
file_obj.name,
184183
file_obj.name,

cpp_linter/common_fs/__init__.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from os import environ
22
from pathlib import Path
3+
import time
34
from typing import List, Dict, Any, Union, Tuple, Optional, TYPE_CHECKING
45
from pygit2 import DiffHunk # type: ignore
56
from ..loggers import logger
@@ -158,6 +159,90 @@ def is_range_contained(self, start: int, end: int) -> Optional[Tuple[int, int]]:
158159
)
159160
return None
160161

162+
def read_with_timeout(self, timeout_ns: int = 1_000_000_000) -> bytes:
163+
"""Read the entire file's contents.
164+
165+
:param timeout_ns: The number of nanoseconds to wait till timeout occurs.
166+
Defaults to 1 second.
167+
168+
:returns: The bytes read from the file.
169+
170+
:raises FileIOTimeout: When the operation did not succeed due to a timeout.
171+
:raises OSError: When the file could not be opened due to an `OSError`.
172+
"""
173+
contents = b""
174+
success = False
175+
exception: Union[OSError, FileIOTimeout] = FileIOTimeout(
176+
f"Failed to read from file '{self.name}' within "
177+
+ f"{round(timeout_ns / 1_000_000_000, 2)} seconds"
178+
)
179+
timeout = time.monotonic_ns() + timeout_ns
180+
while not success and time.monotonic_ns() < timeout:
181+
try:
182+
with open(self.name, "rb") as f:
183+
while not success and time.monotonic_ns() < timeout:
184+
if f.readable():
185+
contents = f.read()
186+
success = True
187+
else: # pragma: no cover
188+
time.sleep(0.001) # Sleep to prevent busy-waiting
189+
except OSError as exc: # pragma: no cover
190+
exception = exc
191+
if not success and exception: # pragma: no cover
192+
raise exception
193+
return contents
194+
195+
def read_write_with_timeout(
196+
self,
197+
data: Union[bytes, bytearray],
198+
timeout_ns: int = 1_000_000_000,
199+
) -> bytes:
200+
"""Read then write the entire file's contents.
201+
202+
:param data: The bytes to write to the file. This will overwrite the contents
203+
being read beforehand.
204+
:param timeout_ns: The number of nanoseconds to wait till timeout occurs.
205+
Defaults to 1 second.
206+
207+
:returns: The bytes read from the file.
208+
209+
:raises FileIOTimeout: When the operation did not succeed due to a timeout.
210+
:raises OSError: When the file could not be opened due to an `OSError`.
211+
"""
212+
success = False
213+
exception: Union[OSError, FileIOTimeout] = FileIOTimeout(
214+
f"Failed to read then write file '{self.name}' within "
215+
+ f"{round(timeout_ns / 1_000_000_000, 2)} seconds"
216+
)
217+
original_data = b""
218+
timeout = time.monotonic_ns() + timeout_ns
219+
while not success and time.monotonic_ns() < timeout:
220+
try:
221+
with open(self.name, "r+b") as f:
222+
while not success and time.monotonic_ns() < timeout:
223+
if f.readable():
224+
original_data = f.read()
225+
f.seek(0)
226+
else: # pragma: no cover
227+
time.sleep(0.001) # Sleep to prevent busy-waiting
228+
continue
229+
while not success and time.monotonic_ns() < timeout:
230+
if f.writable():
231+
f.write(data)
232+
f.truncate()
233+
success = True
234+
else: # pragma: no cover
235+
time.sleep(0.001) # Sleep to prevent busy-waiting
236+
except OSError as exc: # pragma: no cover
237+
exception = exc
238+
if not success and exception: # pragma: no cover
239+
raise exception
240+
return original_data
241+
242+
243+
class FileIOTimeout(Exception):
244+
"""An exception thrown when a file operation timed out."""
245+
161246

162247
def has_line_changes(
163248
lines_changed_only: int, diff_chunks: List[List[int]], additions: List[int]
@@ -180,10 +265,10 @@ def has_line_changes(
180265
)
181266

182267

183-
def get_line_cnt_from_cols(file_path: str, offset: int) -> Tuple[int, int]:
268+
def get_line_cnt_from_cols(data: bytes, offset: int) -> Tuple[int, int]:
184269
"""Gets a line count and columns offset from a file's absolute offset.
185270
186-
:param file_path: Path to file.
271+
:param data: Bytes content to analyze.
187272
:param offset: The byte offset to translate
188273
189274
:returns:
@@ -193,5 +278,5 @@ def get_line_cnt_from_cols(file_path: str, offset: int) -> Tuple[int, int]:
193278
- Index 1 is the column number for the given offset on the line.
194279
"""
195280
# logger.debug("Getting line count from %s at offset %d", file_path, offset)
196-
contents = Path(file_path).read_bytes()[:offset]
281+
contents = data[:offset]
197282
return (contents.count(b"\n") + 1, offset - contents.rfind(b"\n"))

tests/test_misc.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ def test_list_src_files(
8484
@pytest.mark.parametrize("line,cols,offset", [(13, 5, 144), (19, 1, 189)])
8585
def test_file_offset_translation(line: int, cols: int, offset: int):
8686
"""Validate output from ``get_line_cnt_from_cols()``"""
87-
test_file = str(Path("tests/demo/demo.cpp").resolve())
88-
assert (line, cols) == get_line_cnt_from_cols(test_file, offset)
87+
contents = Path("tests/demo/demo.cpp").read_bytes()
88+
assert (line, cols) == get_line_cnt_from_cols(contents, offset)
8989

9090

9191
def test_serialize_file_obj():

0 commit comments

Comments
 (0)