From 9778068253e9147f7b33d47c7f0ef221cee6b9f9 Mon Sep 17 00:00:00 2001 From: Colin Vidal Date: Wed, 11 Jun 2025 15:45:52 +0200 Subject: [PATCH 01/12] fix watchlog.py doctest Fix some broken doctest in watchlog.py (no semantic error, but API slightly changed and broke some output messags). Also add a test for a missing failure case. --- bin/tests/system/isctest/log/watchlog.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/bin/tests/system/isctest/log/watchlog.py b/bin/tests/system/isctest/log/watchlog.py index ffa75e156d..3d35df09e9 100644 --- a/bin/tests/system/isctest/log/watchlog.py +++ b/bin/tests/system/isctest/log/watchlog.py @@ -76,20 +76,26 @@ class WatchLog(abc.ABC): ... print("Just print something without waiting for a log line") Traceback (most recent call last): ... - Exception: wait_for_*() was not called + isctest.log.watchlog.WatchLogException: wait_for_*() was not called >>> with WatchLogFromHere("/dev/null") as watcher: ... try: - ... watcher.wait_for_line("foo", timeout=0) + ... watcher.wait_for_line("foo", timeout=0.1) ... except TimeoutError: ... pass ... try: - ... watcher.wait_for_lines({"bar": 42}, timeout=0) + ... watcher.wait_for_lines({"bar": 42}, timeout=0.1) ... except TimeoutError: ... pass Traceback (most recent call last): ... - Exception: wait_for_*() was already called + isctest.log.watchlog.WatchLogException: wait_for_*() was already called + + >>> with WatchLogFromHere("/dev/null") as watcher: + ... watcher.wait_for_line("foo", timeout=0) + Traceback (most recent call last): + ... + AssertionError: Do not use this class unless you want to WAIT for something. """ self._fd = None # type: Optional[TextIO] self._path = path From 67896ddde2cc9058fc965eb50b263ca7909d24d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Mon, 16 Jun 2025 15:35:43 +0200 Subject: [PATCH 02/12] Abstract WatchLog line buffering to a separate function Move the line buffering functionality into _readline() to improve the readability of code. This also allows reading the file contents from other functions, since the line buffer is now an attribute of the class. --- bin/tests/system/isctest/log/watchlog.py | 51 +++++++++++++++++------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/bin/tests/system/isctest/log/watchlog.py b/bin/tests/system/isctest/log/watchlog.py index 3d35df09e9..0ac18477ac 100644 --- a/bin/tests/system/isctest/log/watchlog.py +++ b/bin/tests/system/isctest/log/watchlog.py @@ -100,6 +100,36 @@ class WatchLog(abc.ABC): self._fd = None # type: Optional[TextIO] self._path = path self._wait_function_called = False + self._linebuf = "" + + def _readline(self) -> Optional[str]: + """ + Wrapper around io.readline() function to handle unfinished lines. + + If a line ends with newline character, it's returned immediately. + If a line doesn't end with a newline character, the read contents are + buffered until the next call of this function and None is returned + instead. + """ + if not self._fd: + raise WatchLogException("file to watch isn't open") + read = self._fd.readline() + if not read.endswith("\n"): + self._linebuf += read + return None + read = self._linebuf + read + self._linebuf = "" + return read + + def _readlines(self) -> Iterator[str]: + """ + Wrapper around io.readline() which only returns finished lines. + """ + while True: + line = self._readline() + if line is None: + return + yield line def wait_for_line(self, string: str, timeout: int = 10) -> None: """ @@ -231,24 +261,15 @@ class WatchLog(abc.ABC): if self._wait_function_called: raise WatchLogException("wait_for_*() was already called") self._wait_function_called = True - if not self._fd: - raise WatchLogException("No file to watch") - leftover = "" assert timeout, "Do not use this class unless you want to WAIT for something." deadline = time.monotonic() + timeout while time.monotonic() < deadline: - for line in self._fd.readlines(): - if line[-1] != "\n": - # Line is not completely written yet, buffer and keep on waiting - leftover += line - else: - line = leftover + line - leftover = "" - for string, retval in patterns.items(): - if isinstance(string, Pattern) and string.search(line): - return retval - if isinstance(string, str) and string in line: - return retval + for line in self._readlines(): + for string, retval in patterns.items(): + if isinstance(string, Pattern) and string.search(line): + return retval + if isinstance(string, str) and string in line: + return retval time.sleep(0.1) raise TimeoutError( "Timeout reached watching {} for {}".format( From f2679bff194e1bb00b3b0f25264f056e5ba60af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Mon, 16 Jun 2025 17:12:47 +0200 Subject: [PATCH 03/12] Set timeout for WatchLog per-instance rather than per-call To simplify usage of multiple wait_for_*() calls, configure the timeout value for the WatchLog instance, rather than specifying it for each call. This is a preparation/cleanup for implementing multiple wait_for_*() calls in subsequent commits. --- bin/tests/system/isctest/instance.py | 12 ++-- bin/tests/system/isctest/log/watchlog.py | 67 ++++++++++--------- bin/tests/system/xferquota/tests_xferquota.py | 7 +- 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/bin/tests/system/isctest/instance.py b/bin/tests/system/isctest/instance.py index adbe2f8fb4..ee2916b9e6 100644 --- a/bin/tests/system/isctest/instance.py +++ b/bin/tests/system/isctest/instance.py @@ -183,19 +183,23 @@ class NamedInstance: debug(f"update of zone {zone} to server {self.ip} successful") return response - def watch_log_from_start(self) -> WatchLogFromStart: + def watch_log_from_start( + self, timeout: float = WatchLogFromStart.DEFAULT_TIMEOUT + ) -> WatchLogFromStart: """ Return an instance of the `WatchLogFromStart` context manager for this `named` instance's log file. """ - return WatchLogFromStart(self.log.path) + return WatchLogFromStart(self.log.path, timeout) - def watch_log_from_here(self) -> WatchLogFromHere: + def watch_log_from_here( + self, timeout: float = WatchLogFromHere.DEFAULT_TIMEOUT + ) -> WatchLogFromHere: """ Return an instance of the `WatchLogFromHere` context manager for this `named` instance's log file. """ - return WatchLogFromHere(self.log.path) + return WatchLogFromHere(self.log.path, timeout) def reconfigure(self) -> None: """ diff --git a/bin/tests/system/isctest/log/watchlog.py b/bin/tests/system/isctest/log/watchlog.py index 0ac18477ac..8173c92c56 100644 --- a/bin/tests/system/isctest/log/watchlog.py +++ b/bin/tests/system/isctest/log/watchlog.py @@ -65,9 +65,12 @@ class WatchLog(abc.ABC): by the `NamedInstance` class (see below for recommended usage patterns). """ - def __init__(self, path: str) -> None: + DEFAULT_TIMEOUT = 10.0 + + def __init__(self, path: str, timeout: float = DEFAULT_TIMEOUT) -> None: """ `path` is the path to the log file to watch. + `timeout` is the number of seconds (float) to wait for each wait call. Every instance of this class must call one of the `wait_for_*()` methods exactly once or else an `Exception` is thrown. @@ -78,29 +81,32 @@ class WatchLog(abc.ABC): ... isctest.log.watchlog.WatchLogException: wait_for_*() was not called - >>> with WatchLogFromHere("/dev/null") as watcher: + >>> with WatchLogFromHere("/dev/null", timeout=0.1) as watcher: ... try: - ... watcher.wait_for_line("foo", timeout=0.1) + ... watcher.wait_for_line("foo") ... except TimeoutError: ... pass ... try: - ... watcher.wait_for_lines({"bar": 42}, timeout=0.1) + ... watcher.wait_for_lines({"bar": 42}) ... except TimeoutError: ... pass Traceback (most recent call last): ... isctest.log.watchlog.WatchLogException: wait_for_*() was already called - >>> with WatchLogFromHere("/dev/null") as watcher: - ... watcher.wait_for_line("foo", timeout=0) + >>> with WatchLogFromHere("/dev/null", timeout=0.0) as watcher: + ... watcher.wait_for_line("foo") Traceback (most recent call last): ... - AssertionError: Do not use this class unless you want to WAIT for something. + isctest.log.watchlog.WatchLogException: timeout must be greater than 0 """ self._fd = None # type: Optional[TextIO] self._path = path self._wait_function_called = False self._linebuf = "" + if timeout <= 0.0: + raise WatchLogException("timeout must be greater than 0") + self._timeout = timeout def _readline(self) -> Optional[str]: """ @@ -131,14 +137,14 @@ class WatchLog(abc.ABC): return yield line - def wait_for_line(self, string: str, timeout: int = 10) -> None: + def wait_for_line(self, string: str) -> None: """ Block execution until a line containing the provided `string` appears in the log file. Return `None` once the line is found or raise a - `TimeoutError` after `timeout` seconds (default: 10) if `string` does - not appear in the log file (strings and regular expressions are - supported). (Catching this exception is discouraged as it indicates - that the test code did not behave as expected.) + `TimeoutError` after timeout if `string` does not appear in the log + file (strings and regular expressions are supported). (Catching this + exception is discouraged as it indicates that the test code did not + behave as expected.) Recommended use: @@ -162,13 +168,13 @@ class WatchLog(abc.ABC): >>> with tempfile.NamedTemporaryFile("w") as file: ... print("foo", file=file, flush=True) ... with WatchLogFromStart(file.name) as watcher: - ... retval = watcher.wait_for_line("foo", timeout=1) + ... retval = watcher.wait_for_line("foo") >>> print(retval) None >>> with tempfile.NamedTemporaryFile("w") as file: ... with WatchLogFromStart(file.name) as watcher: ... print("foo", file=file, flush=True) - ... retval = watcher.wait_for_line("foo", timeout=1) + ... retval = watcher.wait_for_line("foo") >>> print(retval) None @@ -178,8 +184,8 @@ class WatchLog(abc.ABC): >>> import tempfile >>> with tempfile.NamedTemporaryFile("w") as file: ... print("foo", file=file, flush=True) - ... with WatchLogFromHere(file.name) as watcher: - ... watcher.wait_for_line("foo", timeout=1) #doctest: +ELLIPSIS + ... with WatchLogFromHere(file.name, timeout=0.1) as watcher: + ... watcher.wait_for_line("foo") #doctest: +ELLIPSIS Traceback (most recent call last): ... TimeoutError: Timeout reached watching ... @@ -187,15 +193,13 @@ class WatchLog(abc.ABC): ... print("foo", file=file, flush=True) ... with WatchLogFromHere(file.name) as watcher: ... print("foo", file=file, flush=True) - ... retval = watcher.wait_for_line("foo", timeout=1) + ... retval = watcher.wait_for_line("foo") >>> print(retval) None """ - return self._wait_for({string: None}, timeout) + return self._wait_for({string: None}) - def wait_for_lines( - self, strings: Dict[Union[str, Pattern], Any], timeout: int = 10 - ) -> None: + def wait_for_lines(self, strings: Dict[Union[str, Pattern], Any]) -> None: """ Block execution until a line of interest appears in the log file. This function is a "multi-match" variant of `wait_for_line()` which is @@ -205,10 +209,9 @@ class WatchLog(abc.ABC): `strings` is a `dict` associating each string to look for with the value this function should return when that string is found in the log file (strings and regular expressions are supported). If none of the - `strings` being looked for appear in the log file after `timeout` - seconds, a `TimeoutError` is raised. (Catching this exception is - discouraged as it indicates that the test code did not behave as - expected.) + `strings` being looked for appear in the log file after timeout, a + `TimeoutError` is raised. (Catching this exception is discouraged as + it indicates that the test code did not behave as expected.) Since `strings` is a `dict` and preserves key order (in CPython 3.6 as implementation detail, since 3.7 by language design), each line is @@ -241,28 +244,28 @@ class WatchLog(abc.ABC): >>> with tempfile.NamedTemporaryFile("w") as file: ... print("foo", file=file, flush=True) ... with WatchLogFromStart(file.name) as watcher: - ... retval1 = watcher.wait_for_lines(triggers, timeout=1) + ... retval1 = watcher.wait_for_lines(triggers) ... with WatchLogFromHere(file.name) as watcher: ... print("bar", file=file, flush=True) - ... retval2 = watcher.wait_for_lines(triggers, timeout=1) + ... retval2 = watcher.wait_for_lines(triggers) >>> print(retval1) 42 >>> print(retval2) 1337 """ - return self._wait_for(strings, timeout) + return self._wait_for(strings) - def _wait_for(self, patterns: Dict[Union[str, Pattern], Any], timeout: int) -> Any: + def _wait_for(self, patterns: Dict[Union[str, Pattern], Any]) -> Any: """ Block execution until one of the `strings` being looked for appears in the log file. Raise a `TimeoutError` if none of the `strings` being - looked for are found in the log file for `timeout` seconds. + looked for are found in the log file after timeout. """ if self._wait_function_called: raise WatchLogException("wait_for_*() was already called") self._wait_function_called = True - assert timeout, "Do not use this class unless you want to WAIT for something." - deadline = time.monotonic() + timeout + + deadline = time.monotonic() + self._timeout while time.monotonic() < deadline: for line in self._readlines(): for string, retval in patterns.items(): diff --git a/bin/tests/system/xferquota/tests_xferquota.py b/bin/tests/system/xferquota/tests_xferquota.py index 3ca538193a..c31700e4a2 100644 --- a/bin/tests/system/xferquota/tests_xferquota.py +++ b/bin/tests/system/xferquota/tests_xferquota.py @@ -75,9 +75,6 @@ def test_xferquota(named_port, servers): f"transfer of 'changing/IN' from 10.53.0.1#{named_port}: " f"Transfer completed: .*\\(serial 2\\)" ) - with servers["ns2"].watch_log_from_start() as watcher: - watcher.wait_for_line( - pattern, - timeout=30, - ) + with servers["ns2"].watch_log_from_start(timeout=30) as watcher: + watcher.wait_for_line(pattern) query_and_compare(a_msg) From 5840908ead3a1f1775bda6690c820bbcbfd9d849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Mon, 16 Jun 2025 18:39:56 +0200 Subject: [PATCH 04/12] Unify the WatchLog.wait_for_line/s() API Rather than using two distinct functions for matching either one pattern (wait_for_line()), or any of multiple patterns (wait_for_lines()), use a single function that handles both in the same way. Extend the wait_for_line() API: 1. To allow for usage of one or more FlexPatterns, i.e. either plain strings to be matched verbatim, or regular expressions. Both can be used interchangeably to provide the caller to write simple and readable test code, while allowing for increased complexity to allow special cases. 2. Always return the regex match, which allows the caller to identify which line was matched, as well as to extract any additional information, such as individual regex groups. --- bin/tests/system/isctest/log/watchlog.py | 181 +++++++++++------------ 1 file changed, 89 insertions(+), 92 deletions(-) diff --git a/bin/tests/system/isctest/log/watchlog.py b/bin/tests/system/isctest/log/watchlog.py index 8173c92c56..9bdcecd8ab 100644 --- a/bin/tests/system/isctest/log/watchlog.py +++ b/bin/tests/system/isctest/log/watchlog.py @@ -9,13 +9,17 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. -from typing import Iterator, Optional, TextIO, Dict, Any, Union, Pattern +from typing import Iterator, Optional, TextIO, Any, List, Union, Pattern, Match import abc import os +import re import time +FlexPattern = Union[str, Pattern] + + class WatchLogException(Exception): pass @@ -137,14 +141,44 @@ class WatchLog(abc.ABC): return yield line - def wait_for_line(self, string: str) -> None: + def _prepare_patterns( + self, strings: Union[FlexPattern, List[FlexPattern]] + ) -> List[Pattern]: """ - Block execution until a line containing the provided `string` appears - in the log file. Return `None` once the line is found or raise a - `TimeoutError` after timeout if `string` does not appear in the log - file (strings and regular expressions are supported). (Catching this - exception is discouraged as it indicates that the test code did not - behave as expected.) + Convert a mix of string(s) and/or pattern(s) into a list of patterns. + + Any strings are converted into regular expression patterns that match + the string verbatim. + """ + patterns = [] + if not isinstance(strings, list): + strings = [strings] + for string in strings: + if isinstance(string, Pattern): + patterns.append(string) + elif isinstance(string, str): + pattern = re.compile(re.escape(string)) + patterns.append(pattern) + else: + raise WatchLogException( + "only string and re.Pattern allowed for matching" + ) + return patterns + + def wait_for_line(self, patterns: Union[FlexPattern, List[FlexPattern]]) -> Match: + """ + Block execution until any line of interest appears in the log file. + + `patterns` accepts one value or a list of values, with each value being + either a regular expression pattern, or a string which should be + matched verbatim (without interpreting it as a regular expression). + + If any of the patterns is found anywhere within a line in the log file, + return the match, allowing access to the matched line, the regex + groups, and the regex which matched. See re.Match for more. + + A `TimeoutError` is raised if the function fails to find any of the + `patterns` in the allotted time. Recommended use: @@ -152,13 +186,27 @@ class WatchLog(abc.ABC): import isctest def test_foo(servers): + with servers["ns1"].watch_log_from_start() as watcher: + watcher.wait_for_line("all zones loaded") + + pattern = re.compile(r"next key event in ([0-9]+) seconds") with servers["ns1"].watch_log_from_here() as watcher: # ... do stuff here ... - watcher.wait_for_line("foo bar") + match = watcher.wait_for_line(pattern) + seconds = int(match.groups(1)) + + strings = [ + "freezing zone", + "thawing zone", + ] + with servers["ns1"].watch_log_from_here() as watcher: + # ... do stuff here ... + match = watcher.wait_for_line(strings) + line = match.string ``` - One of `wait_for_line()` or `wait_for_lines()` must be called exactly - once for every `WatchLogFrom*` instance. + `wait_for_line()` must be called exactly once for every `WatchLog` + instance. >>> # For `WatchLogFromStart`, `wait_for_line()` returns without >>> # raising an exception as soon as the line being looked for appears @@ -166,101 +214,55 @@ class WatchLog(abc.ABC): >>> # after the `with` statement is reached. >>> import tempfile >>> with tempfile.NamedTemporaryFile("w") as file: - ... print("foo", file=file, flush=True) + ... print("foo bar baz", file=file, flush=True) ... with WatchLogFromStart(file.name) as watcher: - ... retval = watcher.wait_for_line("foo") - >>> print(retval) - None + ... match = watcher.wait_for_line("bar") + >>> print(match.string.strip()) + foo bar baz >>> with tempfile.NamedTemporaryFile("w") as file: ... with WatchLogFromStart(file.name) as watcher: - ... print("foo", file=file, flush=True) - ... retval = watcher.wait_for_line("foo") - >>> print(retval) - None + ... print("foo bar baz", file=file, flush=True) + ... match = watcher.wait_for_line("bar") + >>> print(match.group(0)) + bar >>> # For `WatchLogFromHere`, `wait_for_line()` only returns without >>> # raising an exception if the string being looked for appears in >>> # the log file after the `with` statement is reached. >>> import tempfile >>> with tempfile.NamedTemporaryFile("w") as file: - ... print("foo", file=file, flush=True) + ... print("foo bar baz", file=file, flush=True) ... with WatchLogFromHere(file.name, timeout=0.1) as watcher: - ... watcher.wait_for_line("foo") #doctest: +ELLIPSIS + ... watcher.wait_for_line("bar") #doctest: +ELLIPSIS Traceback (most recent call last): ... TimeoutError: Timeout reached watching ... >>> with tempfile.NamedTemporaryFile("w") as file: - ... print("foo", file=file, flush=True) + ... print("foo bar baz", file=file, flush=True) ... with WatchLogFromHere(file.name) as watcher: - ... print("foo", file=file, flush=True) - ... retval = watcher.wait_for_line("foo") - >>> print(retval) - None - """ - return self._wait_for({string: None}) - - def wait_for_lines(self, strings: Dict[Union[str, Pattern], Any]) -> None: - """ - Block execution until a line of interest appears in the log file. This - function is a "multi-match" variant of `wait_for_line()` which is - useful when some action may cause several different (mutually - exclusive) messages to appear in the log file. - - `strings` is a `dict` associating each string to look for with the - value this function should return when that string is found in the log - file (strings and regular expressions are supported). If none of the - `strings` being looked for appear in the log file after timeout, a - `TimeoutError` is raised. (Catching this exception is discouraged as - it indicates that the test code did not behave as expected.) - - Since `strings` is a `dict` and preserves key order (in CPython 3.6 as - implementation detail, since 3.7 by language design), each line is - checked against each key in order until the first match. Values provided - in the `strings` dictionary (i.e. values which this function is expected - to return upon a successful match) can be of any type. - - Recommended use: - - ```python - import isctest - - def test_foo(servers): - triggers = { - "message A": "value returned when message A is found", - "message B": "value returned when message B is found", - } - with servers["ns1"].watch_log_from_here() as watcher: - # ... do stuff here ... - retval = watcher.wait_for_lines(triggers) - ``` - - One of `wait_for_line()` or `wait_for_lines()` must be called exactly - once for every `WatchLogFromHere` instance. + ... print("bar qux", file=file, flush=True) + ... match = watcher.wait_for_line("bar") + >>> print(match.string.strip()) + bar qux >>> # Different values must be returned depending on which line is >>> # found in the log file. >>> import tempfile - >>> triggers = {"foo": 42, "bar": 1337} + >>> patterns = [re.compile(r"bar ([0-9])"), "qux"] >>> with tempfile.NamedTemporaryFile("w") as file: - ... print("foo", file=file, flush=True) + ... print("foo bar 3", file=file, flush=True) ... with WatchLogFromStart(file.name) as watcher: - ... retval1 = watcher.wait_for_lines(triggers) + ... match1 = watcher.wait_for_line(patterns) ... with WatchLogFromHere(file.name) as watcher: - ... print("bar", file=file, flush=True) - ... retval2 = watcher.wait_for_lines(triggers) - >>> print(retval1) - 42 - >>> print(retval2) - 1337 + ... print("baz qux", file=file, flush=True) + ... match2 = watcher.wait_for_line(patterns) + >>> print(match1.group(1)) + 3 + >>> print(match2.group(0)) + qux """ - return self._wait_for(strings) + regexes = self._prepare_patterns(patterns) - def _wait_for(self, patterns: Dict[Union[str, Pattern], Any]) -> Any: - """ - Block execution until one of the `strings` being looked for appears in - the log file. Raise a `TimeoutError` if none of the `strings` being - looked for are found in the log file after timeout. - """ if self._wait_function_called: raise WatchLogException("wait_for_*() was already called") self._wait_function_called = True @@ -268,17 +270,12 @@ class WatchLog(abc.ABC): deadline = time.monotonic() + self._timeout while time.monotonic() < deadline: for line in self._readlines(): - for string, retval in patterns.items(): - if isinstance(string, Pattern) and string.search(line): - return retval - if isinstance(string, str) and string in line: - return retval + for regex in regexes: + match = regex.search(line) + if match: + return match time.sleep(0.1) - raise TimeoutError( - "Timeout reached watching {} for {}".format( - self._path, list(patterns.keys()) - ) - ) + raise TimeoutError(f"Timeout reached watching {self._path} for {patterns}") def __enter__(self) -> Any: self._fd = open(self._path, encoding="utf-8") From 2afb3755b2ceb7066ce112e8f01c39cee27c02d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Mon, 23 Jun 2025 14:24:34 +0200 Subject: [PATCH 05/12] Allow WatchLog.wait_for_line() to be called more than once In some cases, it can be useful to be able to re-use the same WatchLog to wait for another line. --- bin/tests/system/isctest/log/watchlog.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/bin/tests/system/isctest/log/watchlog.py b/bin/tests/system/isctest/log/watchlog.py index 9bdcecd8ab..467a935ebc 100644 --- a/bin/tests/system/isctest/log/watchlog.py +++ b/bin/tests/system/isctest/log/watchlog.py @@ -77,7 +77,7 @@ class WatchLog(abc.ABC): `timeout` is the number of seconds (float) to wait for each wait call. Every instance of this class must call one of the `wait_for_*()` - methods exactly once or else an `Exception` is thrown. + methods at least once or else an `Exception` is thrown. >>> with WatchLogFromStart("/dev/null") as watcher: ... print("Just print something without waiting for a log line") @@ -85,19 +85,6 @@ class WatchLog(abc.ABC): ... isctest.log.watchlog.WatchLogException: wait_for_*() was not called - >>> with WatchLogFromHere("/dev/null", timeout=0.1) as watcher: - ... try: - ... watcher.wait_for_line("foo") - ... except TimeoutError: - ... pass - ... try: - ... watcher.wait_for_lines({"bar": 42}) - ... except TimeoutError: - ... pass - Traceback (most recent call last): - ... - isctest.log.watchlog.WatchLogException: wait_for_*() was already called - >>> with WatchLogFromHere("/dev/null", timeout=0.0) as watcher: ... watcher.wait_for_line("foo") Traceback (most recent call last): @@ -262,9 +249,6 @@ class WatchLog(abc.ABC): qux """ regexes = self._prepare_patterns(patterns) - - if self._wait_function_called: - raise WatchLogException("wait_for_*() was already called") self._wait_function_called = True deadline = time.monotonic() + self._timeout From 365f8b6af6e213398ad7d9084b0ebf9140b0933b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Mon, 23 Jun 2025 14:37:09 +0200 Subject: [PATCH 06/12] Split up waiting for match to a separate WatchLog method To allow re-use in upcoming functions, isolate the line matching logic into a separate function. Use an instance-wide deadline attribute, which is set by the calling function. --- bin/tests/system/isctest/log/watchlog.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/bin/tests/system/isctest/log/watchlog.py b/bin/tests/system/isctest/log/watchlog.py index 467a935ebc..e1fcaf739d 100644 --- a/bin/tests/system/isctest/log/watchlog.py +++ b/bin/tests/system/isctest/log/watchlog.py @@ -98,6 +98,7 @@ class WatchLog(abc.ABC): if timeout <= 0.0: raise WatchLogException("timeout must be greater than 0") self._timeout = timeout + self._deadline = 0.0 def _readline(self) -> Optional[str]: """ @@ -250,16 +251,22 @@ class WatchLog(abc.ABC): """ regexes = self._prepare_patterns(patterns) self._wait_function_called = True + self._deadline = time.monotonic() + self._timeout - deadline = time.monotonic() + self._timeout - while time.monotonic() < deadline: + return self._wait_for_match(regexes) + + def _wait_for_match(self, regexes: List[Pattern]) -> Match: + while time.monotonic() < self._deadline: for line in self._readlines(): for regex in regexes: match = regex.search(line) if match: return match time.sleep(0.1) - raise TimeoutError(f"Timeout reached watching {self._path} for {patterns}") + raise TimeoutError( + f"Timeout reached watching {self._path} for " + f"{' | '.join([regex.pattern for regex in regexes])}" + ) def __enter__(self) -> Any: self._fd = open(self._path, encoding="utf-8") From 0a839cd0bdd0256db68cbe85508a17bde6cb4595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Mon, 23 Jun 2025 15:50:29 +0200 Subject: [PATCH 07/12] Add wait_for_all() and wait_for_sequence() to WatchLog Extend the WatchLog API with a couple of new matching options. wait_for_sequence() can be used to check a specific sequence of lines appears in the log file in the given order. wait_for_all() ensure that all the provided patterns appear in the log at least once. Co-authored-by: Colin Vidal --- bin/tests/system/isctest/log/watchlog.py | 148 +++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/bin/tests/system/isctest/log/watchlog.py b/bin/tests/system/isctest/log/watchlog.py index e1fcaf739d..9791285e6c 100644 --- a/bin/tests/system/isctest/log/watchlog.py +++ b/bin/tests/system/isctest/log/watchlog.py @@ -255,6 +255,154 @@ class WatchLog(abc.ABC): return self._wait_for_match(regexes) + def wait_for_sequence(self, patterns: List[FlexPattern]) -> List[Match]: + """ + Block execution until the specified pattern sequence is found in the + log file. + + `patterns` is a list of values, with each value being either a regular + expression pattern, or a string which should be matched verbatim + (without interpreting it as a regular expression). Order of patterns is + important, as each pattern is looked for only after all the previous + patterns have matched. + + All the matches are returned as a list. + + A `TimeoutError` is raised if the function fails to find all of the + `patterns` in the given order in the allotted time. + + >>> import tempfile + >>> seq = ['a', 'b', 'c'] + >>> with tempfile.NamedTemporaryFile("w") as file: + ... print("b", file=file, flush=True) + ... print("a", file=file, flush=True) + ... print("b", file=file, flush=True) + ... print("z", file=file, flush=True) + ... print("c", file=file, flush=True) + ... with WatchLogFromStart(file.name) as watcher: + ... ret = watcher.wait_for_sequence(seq) + >>> assert ret[0].group(0) == "a" + >>> assert ret[1].group(0) == "b" + >>> assert ret[2].group(0) == "c" + + >>> import tempfile + >>> seq = ['a', 'b', 'c'] + >>> with tempfile.NamedTemporaryFile("w") as file: + ... print("b", file=file, flush=True) + ... print("a", file=file, flush=True) + ... print("c", file=file, flush=True) + ... with WatchLogFromStart(file.name, timeout=0.1) as watcher: + ... ret = watcher.wait_for_sequence(seq) #doctest: +ELLIPSIS + Traceback (most recent call last): + ... + TimeoutError: Timeout reached watching ... + + >>> import tempfile + >>> seq = ['a', 'b', 'c'] + >>> with tempfile.NamedTemporaryFile("w") as file: + ... print("b", file=file, flush=True) + ... print("a", file=file, flush=True) + ... print("b", file=file, flush=True) + ... with WatchLogFromStart(file.name, timeout=0.1) as watcher: + ... ret = watcher.wait_for_sequence(seq) #doctest: +ELLIPSIS + Traceback (most recent call last): + ... + TimeoutError: Timeout reached watching ... + + >>> import tempfile + >>> seq = ['a', 'b', 'c'] + >>> with tempfile.NamedTemporaryFile("w") as file: + ... print("b", file=file, flush=True) + ... print("a", file=file, flush=True) + ... print("c", file=file, flush=True) + ... print("b", file=file, flush=True) + ... with WatchLogFromStart(file.name, timeout=0.1) as watcher: + ... ret = watcher.wait_for_sequence(seq) #doctest: +ELLIPSIS + Traceback (most recent call last): + ... + TimeoutError: Timeout reached watching ... + """ + regexes = self._prepare_patterns(patterns) + self._wait_function_called = True + self._deadline = time.monotonic() + self._timeout + matches = [] + + for regex in regexes: + match = self._wait_for_match([regex]) + matches.append(match) + + return matches + + def wait_for_all(self, patterns: List[FlexPattern]) -> List[Match]: + """ + Block execution until all the specified patterns are found in the + log file in any order. + + `patterns` is a list of values, with each value being either a regular + expression pattern, or a string which should be matched verbatim + (without interpreting it as a regular expression). Order of patterns is + irrelevant and they may appear in any order. + + All the matches are returned as a list. The matches are listed in the + order of appearance. Pattern may match more than once, and all the + matches are included. To pair matches with the patterns, re.Match.re + may be used. + + A `TimeoutError` is raised if the function fails to find all of the + `patterns` in the allotted time. + + >>> import tempfile + >>> patterns = ['foo', 'bar'] + >>> with tempfile.NamedTemporaryFile("w") as file: + ... print("bar", file=file, flush=True) + ... print("foo", file=file, flush=True) + ... with WatchLogFromStart(file.name) as watcher: + ... ret = watcher.wait_for_all(patterns) + >>> assert ret[0].group(0) == "bar" + >>> assert ret[1].group(0) == "foo" + + >>> import tempfile + >>> bar_pattern = re.compile('bar') + >>> patterns = ['foo', bar_pattern] + >>> with tempfile.NamedTemporaryFile("w") as file: + ... print("bar", file=file, flush=True) + ... print("baz", file=file, flush=True) + ... print("bar", file=file, flush=True) + ... print("foo", file=file, flush=True) + ... with WatchLogFromStart(file.name) as watcher: + ... ret = watcher.wait_for_all(patterns) + >>> assert len(ret) == 3 + >>> assert ret[0].group(0) == "bar" + >>> assert ret[1].group(0) == "bar" + >>> assert ret[2].group(0) == "foo" + >>> assert ret[0].re == bar_pattern + >>> assert ret[1].re == bar_pattern + >>> assert ret[2].re.pattern == "foo" + + >>> import tempfile + >>> patterns = ['foo', 'bar'] + >>> with tempfile.NamedTemporaryFile("w") as file: + ... print("foo", file=file, flush=True) + ... print("quux", file=file, flush=True) + ... with WatchLogFromStart(file.name, timeout=0.1) as watcher: + ... ret = watcher.wait_for_all(patterns) #doctest: +ELLIPSIS + Traceback (most recent call last): + ... + TimeoutError: Timeout reached watching ... + """ + regexes = self._prepare_patterns(patterns) + self._wait_function_called = True + self._deadline = time.monotonic() + self._timeout + unmatched_regexes = set(regexes) + matches = [] + + while unmatched_regexes: + match = self._wait_for_match(regexes) + matches.append(match) + unmatched_regexes.discard(match.re) + + return matches + def _wait_for_match(self, regexes: List[Pattern]) -> Match: while time.monotonic() < self._deadline: for line in self._readlines(): From 628b47dd308a99fe1ea5bde5f1f4c1d88e0dadcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Mon, 23 Jun 2025 17:23:26 +0200 Subject: [PATCH 08/12] Use custom WatchLog timeout exception The TimeoutError is raised when system functions time out. Define a custom WatchLogTimeout to improve clarity. --- bin/tests/system/isctest/log/watchlog.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/bin/tests/system/isctest/log/watchlog.py b/bin/tests/system/isctest/log/watchlog.py index 9791285e6c..ccffa70481 100644 --- a/bin/tests/system/isctest/log/watchlog.py +++ b/bin/tests/system/isctest/log/watchlog.py @@ -24,6 +24,10 @@ class WatchLogException(Exception): pass +class WatchLogTimeout(WatchLogException): + pass + + class LogFile: """ Log file wrapper with a path and means to find a string in its contents. @@ -165,7 +169,7 @@ class WatchLog(abc.ABC): return the match, allowing access to the matched line, the regex groups, and the regex which matched. See re.Match for more. - A `TimeoutError` is raised if the function fails to find any of the + A `WatchLogTimeout` is raised if the function fails to find any of the `patterns` in the allotted time. Recommended use: @@ -224,7 +228,7 @@ class WatchLog(abc.ABC): ... watcher.wait_for_line("bar") #doctest: +ELLIPSIS Traceback (most recent call last): ... - TimeoutError: Timeout reached watching ... + isctest.log.watchlog.WatchLogTimeout: ... >>> with tempfile.NamedTemporaryFile("w") as file: ... print("foo bar baz", file=file, flush=True) ... with WatchLogFromHere(file.name) as watcher: @@ -268,7 +272,7 @@ class WatchLog(abc.ABC): All the matches are returned as a list. - A `TimeoutError` is raised if the function fails to find all of the + A `WatchLogTimeout` is raised if the function fails to find all of the `patterns` in the given order in the allotted time. >>> import tempfile @@ -295,7 +299,7 @@ class WatchLog(abc.ABC): ... ret = watcher.wait_for_sequence(seq) #doctest: +ELLIPSIS Traceback (most recent call last): ... - TimeoutError: Timeout reached watching ... + isctest.log.watchlog.WatchLogTimeout: ... >>> import tempfile >>> seq = ['a', 'b', 'c'] @@ -307,7 +311,7 @@ class WatchLog(abc.ABC): ... ret = watcher.wait_for_sequence(seq) #doctest: +ELLIPSIS Traceback (most recent call last): ... - TimeoutError: Timeout reached watching ... + isctest.log.watchlog.WatchLogTimeout: ... >>> import tempfile >>> seq = ['a', 'b', 'c'] @@ -320,7 +324,7 @@ class WatchLog(abc.ABC): ... ret = watcher.wait_for_sequence(seq) #doctest: +ELLIPSIS Traceback (most recent call last): ... - TimeoutError: Timeout reached watching ... + isctest.log.watchlog.WatchLogTimeout: ... """ regexes = self._prepare_patterns(patterns) self._wait_function_called = True @@ -348,7 +352,7 @@ class WatchLog(abc.ABC): matches are included. To pair matches with the patterns, re.Match.re may be used. - A `TimeoutError` is raised if the function fails to find all of the + A `WatchLogTimeout` is raised if the function fails to find all of the `patterns` in the allotted time. >>> import tempfile @@ -388,7 +392,7 @@ class WatchLog(abc.ABC): ... ret = watcher.wait_for_all(patterns) #doctest: +ELLIPSIS Traceback (most recent call last): ... - TimeoutError: Timeout reached watching ... + isctest.log.watchlog.WatchLogTimeout: ... """ regexes = self._prepare_patterns(patterns) self._wait_function_called = True @@ -411,7 +415,7 @@ class WatchLog(abc.ABC): if match: return match time.sleep(0.1) - raise TimeoutError( + raise WatchLogTimeout( f"Timeout reached watching {self._path} for " f"{' | '.join([regex.pattern for regex in regexes])}" ) From 3c8432d19645d4cf1549f9ec5a3a776ab4213e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Fri, 4 Jul 2025 16:21:30 +0200 Subject: [PATCH 09/12] Refactor WatchLog for better readability Various improvements for typing, naming, code deduplication and better code organization to make the code easier to read. --- bin/tests/system/isctest/log/watchlog.py | 53 ++++++++++++------------ 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/bin/tests/system/isctest/log/watchlog.py b/bin/tests/system/isctest/log/watchlog.py index ccffa70481..1ae56adef2 100644 --- a/bin/tests/system/isctest/log/watchlog.py +++ b/bin/tests/system/isctest/log/watchlog.py @@ -9,7 +9,7 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. -from typing import Iterator, Optional, TextIO, Any, List, Union, Pattern, Match +from typing import Any, Iterator, List, Match, Optional, Pattern, TextIO, TypeVar, Union import abc import os @@ -18,6 +18,8 @@ import time FlexPattern = Union[str, Pattern] +T = TypeVar("T") +OneOrMore = Union[T, List[T]] class WatchLogException(Exception): @@ -133,9 +135,12 @@ class WatchLog(abc.ABC): return yield line - def _prepare_patterns( - self, strings: Union[FlexPattern, List[FlexPattern]] - ) -> List[Pattern]: + def _setup_wait(self, patterns: OneOrMore[FlexPattern]) -> List[Pattern]: + self._wait_function_called = True + self._deadline = time.monotonic() + self._timeout + return self._prepare_patterns(patterns) + + def _prepare_patterns(self, strings: OneOrMore[FlexPattern]) -> List[Pattern]: """ Convert a mix of string(s) and/or pattern(s) into a list of patterns. @@ -157,7 +162,20 @@ class WatchLog(abc.ABC): ) return patterns - def wait_for_line(self, patterns: Union[FlexPattern, List[FlexPattern]]) -> Match: + def _wait_for_match(self, regexes: List[Pattern]) -> Match: + while time.monotonic() < self._deadline: + for line in self._readlines(): + for regex in regexes: + match = regex.search(line) + if match: + return match + time.sleep(0.1) + raise WatchLogTimeout( + f"Timeout reached watching {self._path} for " + f"{' | '.join([regex.pattern for regex in regexes])}" + ) + + def wait_for_line(self, patterns: OneOrMore[FlexPattern]) -> Match: """ Block execution until any line of interest appears in the log file. @@ -253,9 +271,7 @@ class WatchLog(abc.ABC): >>> print(match2.group(0)) qux """ - regexes = self._prepare_patterns(patterns) - self._wait_function_called = True - self._deadline = time.monotonic() + self._timeout + regexes = self._setup_wait(patterns) return self._wait_for_match(regexes) @@ -326,9 +342,7 @@ class WatchLog(abc.ABC): ... isctest.log.watchlog.WatchLogTimeout: ... """ - regexes = self._prepare_patterns(patterns) - self._wait_function_called = True - self._deadline = time.monotonic() + self._timeout + regexes = self._setup_wait(patterns) matches = [] for regex in regexes: @@ -394,9 +408,7 @@ class WatchLog(abc.ABC): ... isctest.log.watchlog.WatchLogTimeout: ... """ - regexes = self._prepare_patterns(patterns) - self._wait_function_called = True - self._deadline = time.monotonic() + self._timeout + regexes = self._setup_wait(patterns) unmatched_regexes = set(regexes) matches = [] @@ -407,19 +419,6 @@ class WatchLog(abc.ABC): return matches - def _wait_for_match(self, regexes: List[Pattern]) -> Match: - while time.monotonic() < self._deadline: - for line in self._readlines(): - for regex in regexes: - match = regex.search(line) - if match: - return match - time.sleep(0.1) - raise WatchLogTimeout( - f"Timeout reached watching {self._path} for " - f"{' | '.join([regex.pattern for regex in regexes])}" - ) - def __enter__(self) -> Any: self._fd = open(self._path, encoding="utf-8") self._seek_on_enter() From ee782fb4b1ea17f3b01dc6d549481116409141bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Fri, 4 Jul 2025 17:28:06 +0200 Subject: [PATCH 10/12] Separate LineReader functionality from WatchLog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buffered reading of finished lines deserves its own class to make its function clearer, rather than bundling it within the WatchLog class. Co-Authored-By: Michał Kępień --- bin/tests/system/isctest/log/watchlog.py | 137 +++++++++++++++++------ 1 file changed, 101 insertions(+), 36 deletions(-) diff --git a/bin/tests/system/isctest/log/watchlog.py b/bin/tests/system/isctest/log/watchlog.py index 1ae56adef2..6d85e9817d 100644 --- a/bin/tests/system/isctest/log/watchlog.py +++ b/bin/tests/system/isctest/log/watchlog.py @@ -64,6 +64,94 @@ class LogFile: assert False, f"forbidden message appeared in log {self.path}: {msg}" +class LineReader: + """ + >>> import io + + >>> file = io.StringIO("complete line\\n") + >>> line_reader = LineReader(file) + >>> for line in line_reader.readlines(): + ... print(line.strip()) + complete line + + >>> file = io.StringIO("complete line\\nand then incomplete line") + >>> line_reader = LineReader(file) + >>> for line in line_reader.readlines(): + ... print(line.strip()) + complete line + + >>> file = io.StringIO("complete line\\nand then another complete line\\n") + >>> line_reader = LineReader(file) + >>> for line in line_reader.readlines(): + ... print(line.strip()) + complete line + and then another complete line + + >>> file = io.StringIO() + >>> line_reader = LineReader(file) + >>> for chunk in ( + ... "first line\\nsecond line\\nthi", + ... "rd ", + ... "line\\nfour", + ... "th line\\n\\nfifth line\\n" + ... ): + ... print("=== OUTER ITERATION ===") + ... pos = file.tell() + ... print(chunk, end="", file=file) + ... _ = file.seek(pos) + ... for line in line_reader.readlines(): + ... print("--- inner iteration ---") + ... print(line.strip() or "") + === OUTER ITERATION === + --- inner iteration --- + first line + --- inner iteration --- + second line + === OUTER ITERATION === + === OUTER ITERATION === + --- inner iteration --- + third line + === OUTER ITERATION === + --- inner iteration --- + fourth line + --- inner iteration --- + + --- inner iteration --- + fifth line + """ + + def __init__(self, stream: TextIO): + self._stream = stream + self._linebuf = "" + + def readline(self) -> Optional[str]: + """ + Wrapper around io.readline() function to handle unfinished lines. + + If a line ends with newline character, it's returned immediately. + If a line doesn't end with a newline character, the read contents are + buffered until the next call of this function and None is returned + instead. + """ + read = self._stream.readline() + if not read.endswith("\n"): + self._linebuf += read + return None + read = self._linebuf + read + self._linebuf = "" + return read + + def readlines(self) -> Iterator[str]: + """ + Wrapper around io.readline() which only returns finished lines. + """ + while True: + line = self.readline() + if line is None: + return + yield line + + class WatchLog(abc.ABC): """ Wait for a log message to appear in a text file. @@ -97,44 +185,15 @@ class WatchLog(abc.ABC): ... isctest.log.watchlog.WatchLogException: timeout must be greater than 0 """ - self._fd = None # type: Optional[TextIO] + self._fd: Optional[TextIO] = None + self._reader: Optional[LineReader] = None self._path = path self._wait_function_called = False - self._linebuf = "" if timeout <= 0.0: raise WatchLogException("timeout must be greater than 0") self._timeout = timeout self._deadline = 0.0 - def _readline(self) -> Optional[str]: - """ - Wrapper around io.readline() function to handle unfinished lines. - - If a line ends with newline character, it's returned immediately. - If a line doesn't end with a newline character, the read contents are - buffered until the next call of this function and None is returned - instead. - """ - if not self._fd: - raise WatchLogException("file to watch isn't open") - read = self._fd.readline() - if not read.endswith("\n"): - self._linebuf += read - return None - read = self._linebuf + read - self._linebuf = "" - return read - - def _readlines(self) -> Iterator[str]: - """ - Wrapper around io.readline() which only returns finished lines. - """ - while True: - line = self._readline() - if line is None: - return - yield line - def _setup_wait(self, patterns: OneOrMore[FlexPattern]) -> List[Pattern]: self._wait_function_called = True self._deadline = time.monotonic() + self._timeout @@ -163,8 +222,12 @@ class WatchLog(abc.ABC): return patterns def _wait_for_match(self, regexes: List[Pattern]) -> Match: + if not self._reader: + raise WatchLogException( + "use WatchLog as context manager before calling wait_for_*() functions" + ) while time.monotonic() < self._deadline: - for line in self._readlines(): + for line in self._reader.readlines(): for regex in regexes: match = regex.search(line) if match: @@ -422,6 +485,7 @@ class WatchLog(abc.ABC): def __enter__(self) -> Any: self._fd = open(self._path, encoding="utf-8") self._seek_on_enter() + self._reader = LineReader(self._fd) return self @abc.abstractmethod @@ -438,8 +502,9 @@ class WatchLog(abc.ABC): def __exit__(self, *_: Any) -> None: if not self._wait_function_called: raise WatchLogException("wait_for_*() was not called") - if self._fd: - self._fd.close() + self._reader = None + assert self._fd + self._fd.close() class WatchLogFromStart(WatchLog): @@ -460,5 +525,5 @@ class WatchLogFromHere(WatchLog): """ def _seek_on_enter(self) -> None: - if self._fd: - self._fd.seek(0, os.SEEK_END) + assert self._fd + self._fd.seek(0, os.SEEK_END) From dcfb6c23da975427e73a21a77636b6f5afcfe5ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Mon, 23 Jun 2025 17:36:08 +0200 Subject: [PATCH 11/12] Change NamedInstance.rndc() doctest into doc example The test is troublesome, because NamedInstance(identifier) expects that a directory with such a name exists. While it'd be possible to mock those directories as well, it'd make the doctest overly long and complex, which isn't justified, given that it's only testing a couple of options. Turn it into regular documentation instead. --- bin/tests/system/isctest/instance.py | 45 ++++++++++++---------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/bin/tests/system/isctest/instance.py b/bin/tests/system/isctest/instance.py index ee2916b9e6..83d396d1cd 100644 --- a/bin/tests/system/isctest/instance.py +++ b/bin/tests/system/isctest/instance.py @@ -118,35 +118,28 @@ class NamedInstance: The RNDC command will be logged to `rndc.log` (along with the server's response) unless `log` is set to `False`. - >>> # Instances of the `NamedInstance` class are expected to be passed - >>> # to pytest tests as fixtures; here, some instances are created - >>> # directly (with a fake RNDC executor) so that doctest can work. - >>> import unittest.mock - >>> mock_rndc_executor = unittest.mock.Mock() - >>> ns1 = NamedInstance("ns1", rndc_executor=mock_rndc_executor) - >>> ns2 = NamedInstance("ns2", rndc_executor=mock_rndc_executor) - >>> ns3 = NamedInstance("ns3", rndc_executor=mock_rndc_executor) - >>> ns4 = NamedInstance("ns4", rndc_executor=mock_rndc_executor) + ```python + def test_foo(servers): + # Send the "status" command to ns1. An `RNDCException` will be + # raised if the RNDC command fails. This command will be logged. + response = servers["ns1"].rndc("status") - >>> # Send the "status" command to ns1. An `RNDCException` will be - >>> # raised if the RNDC command fails. This command will be logged. - >>> response = ns1.rndc("status") + # Send the "thaw foo" command to ns2. No exception will be raised + # in case the RNDC command fails. This command will be logged + # (even if it fails). + response = servers["ns2"].rndc("thaw foo", ignore_errors=True) - >>> # Send the "thaw foo" command to ns2. No exception will be raised - >>> # in case the RNDC command fails. This command will be logged - >>> # (even if it fails). - >>> response = ns2.rndc("thaw foo", ignore_errors=True) + # Send the "stop" command to ns3. An `RNDCException` will be + # raised if the RNDC command fails, but this command will not be + # logged (the server's response will still be returned to the + # caller, though). + response = servers["ns3"].rndc("stop", log=False) - >>> # Send the "stop" command to ns3. An `RNDCException` will be - >>> # raised if the RNDC command fails, but this command will not be - >>> # logged (the server's response will still be returned to the - >>> # caller, though). - >>> response = ns3.rndc("stop", log=False) - - >>> # Send the "halt" command to ns4 in "fire & forget mode": no - >>> # exceptions will be raised and no logging will take place (the - >>> # server's response will still be returned to the caller, though). - >>> response = ns4.rndc("stop", ignore_errors=True, log=False) + # Send the "halt" command to ns4 in "fire & forget mode": no + # exceptions will be raised and no logging will take place (the + # server's response will still be returned to the caller, though). + response = servers["ns4"].rndc("stop", ignore_errors=True, log=False) + ``` """ try: response = self._rndc_executor.call(self.ip, self.ports.rndc, command) From d737986ea24bdcb344a91a491816766b80120c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Mon, 16 Jun 2025 15:51:51 +0200 Subject: [PATCH 12/12] Turn on doctest in CI Run doctests for the isctest module in a dedicated CI job. --- .gitlab-ci.yml | 11 +++++++++++ bin/tests/system/isctest/mark.py | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 41768897a0..761abb7747 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -599,6 +599,17 @@ coccinelle: - util/check-cocci - if test "$(git status --porcelain | grep -Ev '\?\?' | wc -l)" -gt "0"; then git status --short; exit 1; fi +doctest: + <<: *precheck_job + needs: [] + script: + - *configure + - meson compile -C build system-test-init + - *find_pytest + - cd bin/tests/system/isctest + - > + "$PYTEST" --noconftest --doctest-modules + pylint: <<: *default_triggering_rules <<: *debian_sid_amd64_image diff --git a/bin/tests/system/isctest/mark.py b/bin/tests/system/isctest/mark.py index 7fdb964dc6..0961d9bb14 100644 --- a/bin/tests/system/isctest/mark.py +++ b/bin/tests/system/isctest/mark.py @@ -30,7 +30,9 @@ live_internet_test = pytest.mark.skipif( def feature_test(feature): - feature_test_bin = os.environ["FEATURETEST"] + feature_test_bin = os.environ.get("FEATURETEST") + if not feature_test_bin: # this can be the case when running doctest + return False try: subprocess.run([feature_test_bin, feature], check=True) except subprocess.CalledProcessError as exc: