Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/more debug logging to LogMatcher #1110

Merged
merged 4 commits into from
Jul 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/newsfragments/3025_new.logmatcher_debug_logging.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add more logging to :py:meth:`~testplan.common.utils.match.LogMatcher.match`, :py:meth:`~testplan.common.utils.match.LogMatcher.not_match` and :py:meth:`~testplan.common.utils.match.LogMatcher.match_all` for debugging purpose.
6 changes: 4 additions & 2 deletions examples/Assertions/Basic/test_plan_logfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ def logpath(self):
def starting(self):
super().starting()
self._log = open(self.logpath, "wb+")
self._log.write(b"pretending setup line\n")
self._log.flush()
self._sig = threading.Event()
self._thr = threading.Thread(target=self._start_loop)
self._thr.start()
Expand Down Expand Up @@ -93,9 +95,9 @@ def match_logfile(self, env, result):
def match_logfile_xfail(self, env, result):
lm = LogMatcher(env.server.logpath, binary=True)
with result.logfile.expect(
lm, r"pong", description="first assertion", timeout=0.2
lm, r"pong", description="first assertion", timeout=1
):
env.client.send(b"ping")
env.client.send(b"ping\n")


@test_plan(
Expand Down
161 changes: 125 additions & 36 deletions testplan/common/utils/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@
import time
import warnings
from contextlib import closing
from itertools import repeat
from typing import (
AnyStr,
Dict,
Iterator,
List,
Match,
Optional,
Expand Down Expand Up @@ -41,6 +39,14 @@
Regex: TypeAlias = Union[str, bytes, Pattern]


def _format_logline(s):
if not s:
return "<EOF>\n"
if len(s) <= 100:
return s
return f"{s[:100]} ... ({len(s) - 100} chars omitted)"


def match_regexps_in_file(
logpath: os.PathLike, log_extracts: List[Pattern]
) -> Tuple[bool, Dict[str, str], List[Pattern]]:
Expand Down Expand Up @@ -109,11 +115,11 @@ def __enter__(self):
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is not None:
return False
s_pos = self.log_matcher.position
m = self.log_matcher.match(
self.regex, self.timeout, raise_on_timeout=False
)
e_pos = self.log_matcher.position
s_pos = self.log_matcher._debug_info_s[0]
e_pos = self.log_matcher._debug_info_e[0]
if m is not None:
self.match_results.append((m, self.regex, s_pos, e_pos))
else:
Expand Down Expand Up @@ -142,6 +148,9 @@ def __init__(
self.position: Optional[LogPosition] = None
self.log_stream: FileLogStream = self._create_log_stream()

self._debug_info_s = ()
self._debug_info_e = ()

# deprecation helpers
self.had_transformed = False

Expand Down Expand Up @@ -241,41 +250,44 @@ def mark(self, name: str):
"""
self.marks[name] = self.position

def match(
def _match(
self,
regex: Regex,
timeout: float = LOG_MATCHER_DEFAULT_TIMEOUT,
raise_on_timeout: bool = True,
regex: Pattern[AnyStr],
timeout: float,
) -> Optional[Match]:
"""
Matches each line in the log file from the current line number to the
end of the file. If a match is found the line number is stored and the
match is returned. By default an exception is raised if no match is
found.
Base block for ``match``, ``not_match`` & ``match_all``,
as well as certain ``LogfileNamespace`` assertions.

:param regex: Regex string or compiled regular expression
(``re.compile``)
:param regex: Checked regular expression
:param timeout: Timeout in seconds to wait for matching process,
0 means matching till EOF and not waiting for new lines, any
value greater than 0 means doing matching up to such seconds,
defaults to 5 seconds
:param raise_on_timeout: To raise TimeoutException or not
:return: The regex match or None if no match is found
"""
match = None
start_time = time.time()
end_time = start_time + timeout

regex = self._prepare_regexp(regex)

with closing(self.log_stream) as log:
log.seek(self.position)

non_eof = ""
while True:
if timeout > 0 and time.time() > end_time:
break
line = log.readline()
if self._debug_info_s is None:
self._debug_info_s = (
str(self.position)
if self.position is not None
else "<BOF>",
start_time,
_format_logline(line),
)

if line:
non_eof = line
match = regex.match(line)
if match:
break
Expand All @@ -284,20 +296,62 @@ def match(
else:
break

if timeout > 0 and time.time() > end_time:
break

self.position = self.log_stream.position
if self._debug_info_e is None:
self._debug_info_e = (
str(self.position),
time.time(),
_format_logline(non_eof),
)

if match is not None:
return match

def match(
self,
regex: Regex,
timeout: float = LOG_MATCHER_DEFAULT_TIMEOUT,
raise_on_timeout: bool = True,
) -> Optional[Match]:
"""
Matches each line in the log file from the current line number to the
end of the file. If a match is found the line number is stored and the
match is returned. By default an exception is raised if no match is
found.

:param regex: Regex string or compiled regular expression
(``re.compile``)
:param timeout: Timeout in seconds to wait for matching process,
0 means matching till EOF and not waiting for new lines, any
value greater than 0 means doing matching up to such seconds,
defaults to 5 seconds
:param raise_on_timeout: To raise TimeoutException or not
:return: The regex match or None if no match is found
"""
self._debug_info_s = None
self._debug_info_e = None
regex = self._prepare_regexp(regex)

m = self._match(regex, timeout=timeout)

if m is None:
self.logger.debug(
"Match[%s] found in %.2fs",
"%s: no expected match[%s] found,\nsearch starting from %s (around %s), "
"where first line seen as:\n%s"
"and ending at %s (around %s), where last line seen as:\n%s",
self,
regex.pattern,
time.time() - start_time,
)
elif timeout and raise_on_timeout:
raise timing.TimeoutException(
"No match[{}] found in {}s".format(regex.pattern, timeout)
*self._debug_info_s,
*self._debug_info_e,
)
if timeout and raise_on_timeout:
raise timing.TimeoutException(
"No match[%s] found in %.2fs.", regex.pattern, timeout
)

return match
return m

def not_match(
self,
Expand All @@ -316,9 +370,22 @@ def not_match(
0 means should not wait and return whatever matched on initial
scan, defaults to 5 seconds
"""
self._debug_info_s = None
self._debug_info_e = None
regex = self._prepare_regexp(regex)

m = self._match(regex, timeout)

match = self.match(regex, timeout, raise_on_timeout=False)
if match is not None:
if m is not None:
self.logger.debug(
"%s: unexpected match[%s] found,\nsearch starting from %s (around %s), "
"where first line seen as:\n%s"
"and ending at %s (around %s), where last line seen as:\n%s",
self,
regex.pattern,
*self._debug_info_s,
*self._debug_info_e,
)
raise Exception(
f"Unexpected match[{regex.pattern}] found in {timeout}s"
)
Expand All @@ -343,15 +410,37 @@ def match_all(
matches = []
end_time = time.time() + timeout

try:
while timeout >= 0:
matches.append(
self.match(regex, timeout, raise_on_timeout=True)
self._debug_info_s = None
regex = self._prepare_regexp(regex)

while True:
if timeout == 0:
t = 0
else:
t = end_time - time.time()
if t <= 0:
break
self._debug_info_e = None
m = self._match(regex, t)
if m is not None:
matches.append(m)
else:
break

if not matches:
self.logger.debug(
"%s: no expected match[%s] found,\nsearch starting from %s (around %s), "
"where first line seen as:\n%s"
"and ending at %s (around %s), where last line seen as:\n%s",
self,
regex.pattern,
*self._debug_info_s,
*self._debug_info_e,
)
if timeout and raise_on_timeout:
raise timing.TimeoutException(
"No match[%s] found in %.2fs.", regex.pattern, timeout
)
timeout = end_time - time.time()
except timing.TimeoutException:
if not matches and raise_on_timeout:
raise

return matches

Expand Down
10 changes: 4 additions & 6 deletions testplan/testing/multitest/entries/assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1486,7 +1486,7 @@ class LogfileMatch(Assertion):
"""

@dataclass
class Result:
class ResultRep:
matched: Optional[str]
pattern: str
start_pos: str
Expand Down Expand Up @@ -1519,14 +1519,12 @@ def _truncate_str(s):
@classmethod
def _handle_quadruple(cls, tup):
m, r, s, e = tup
if s is None:
s = "<BOF>"
r = re.compile(r).pattern
return cls.Result(
return cls.ResultRep(
cls._truncate_str(m.group()) if m is not None else None,
cls._truncate_str(r),
str(s),
str(e),
s,
e,
)

def evaluate(self):
Expand Down
4 changes: 2 additions & 2 deletions testplan/testing/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -1450,9 +1450,9 @@ def match(
"""
results = []
failure = None
s_pos = log_matcher.position
m = log_matcher.match(regex, timeout, raise_on_timeout=False)
e_pos = log_matcher.position
s_pos = log_matcher._debug_info_s[0]
e_pos = log_matcher._debug_info_e[0]
if m is not None:
results.append((m, regex, s_pos, e_pos))
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const LogfileMatchAssertion = ({ assertion }) => {
const { timeout, results, failure } = assertion;

const timeoutMsg =
(assertion.passed ? "Passed" : "Failed") + ` in ${timeout} seconds.`;
(assertion.passed ? "Passed" : "Failed") + (timeout === 0 ? " when scanning till <EOF>." : ` in ${timeout} seconds.`);
const entries = [...results, ...failure].map((entry, index) => {
const { matched, pattern, start_pos: startPos, end_pos: endPos } = entry;
return (
Expand Down
Loading