firewatcher 1.58__tar.gz → 1.59__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {firewatcher-1.58/firewatcher.egg-info → firewatcher-1.59}/PKG-INFO +3 -3
  2. {firewatcher-1.58 → firewatcher-1.59}/README.md +2 -2
  3. {firewatcher-1.58 → firewatcher-1.59/firewatcher.egg-info}/PKG-INFO +3 -3
  4. {firewatcher-1.58 → firewatcher-1.59}/firewatcher.py +180 -94
  5. {firewatcher-1.58 → firewatcher-1.59}/tests/test_capture.py +122 -1
  6. {firewatcher-1.58 → firewatcher-1.59}/tests/test_cli.py +11 -1
  7. {firewatcher-1.58 → firewatcher-1.59}/tests/test_matching.py +33 -0
  8. {firewatcher-1.58 → firewatcher-1.59}/tests/test_notify.py +13 -12
  9. {firewatcher-1.58 → firewatcher-1.59}/MANIFEST.in +0 -0
  10. {firewatcher-1.58 → firewatcher-1.59}/examples/firewatcher.conf +0 -0
  11. {firewatcher-1.58 → firewatcher-1.59}/examples/nvme_failure.regex +0 -0
  12. {firewatcher-1.58 → firewatcher-1.59}/examples/sys_msg.txt +0 -0
  13. {firewatcher-1.58 → firewatcher-1.59}/firewatcher.egg-info/SOURCES.txt +0 -0
  14. {firewatcher-1.58 → firewatcher-1.59}/firewatcher.egg-info/dependency_links.txt +0 -0
  15. {firewatcher-1.58 → firewatcher-1.59}/firewatcher.egg-info/entry_points.txt +0 -0
  16. {firewatcher-1.58 → firewatcher-1.59}/firewatcher.egg-info/top_level.txt +0 -0
  17. {firewatcher-1.58 → firewatcher-1.59}/setup.cfg +0 -0
  18. {firewatcher-1.58 → firewatcher-1.59}/setup.py +0 -0
  19. {firewatcher-1.58 → firewatcher-1.59}/tests/conftest.py +0 -0
  20. {firewatcher-1.58 → firewatcher-1.59}/tests/test_compressor.py +0 -0
  21. {firewatcher-1.58 → firewatcher-1.59}/tests/test_config.py +0 -0
  22. {firewatcher-1.58 → firewatcher-1.59}/tests/test_log_source.py +0 -0
  23. {firewatcher-1.58 → firewatcher-1.59}/tests/test_patterns.py +0 -0
  24. {firewatcher-1.58 → firewatcher-1.59}/tests/test_slugify.py +0 -0
  25. {firewatcher-1.58 → firewatcher-1.59}/tests/test_systemd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firewatcher
3
- Version: 1.58
3
+ Version: 1.59
4
4
  Summary: Watch logs for incident patterns and persist surrounding context off-box
5
5
  Home-page: https://github.com/yufei-pan/firewatcher
6
6
  Author: Yufei Pan
@@ -59,7 +59,7 @@ On a match, `firewatcher` writes:
59
59
  - a per-incident capture under `{output-folder}/{YYYY-MM}/…`
60
60
  - a line in `{output-folder}/journal.log`
61
61
 
62
- Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears.
62
+ Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears. Each captured line is flushed as it is written, and the capture file is fsynced at most once a second and when the capture closes, so a host crash loses at most about the last second of it.
63
63
 
64
64
  ## systemd service
65
65
 
@@ -99,7 +99,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
99
99
  | `--filter_only` | Filter existing logs only (`cat` instead of `tail -F`, or `journalctl` without `--follow`) |
100
100
  | `--compress-after-months` | Tar.xz monthly dirs after N months (default: 3) |
101
101
  | `--delete-after-months` | Delete `YYYY-MM` dirs after N months (default: 0 = never) |
102
- | `--capture_line_count_max` | Max lines before/after a match (default: 10000) |
102
+ | `--capture_line_count_max` | Max lines before/after a match (default: 10000). Also caps how many recent lines are held in memory |
103
103
  | `--config PATH` | INI config file. Default: `/etc/firewatcher/firewatcher.conf` when that file exists. An explicit path must exist |
104
104
  | `--llm` / `--no-llm` | Enable or disable LLM analysis of each finished capture |
105
105
  | `--llm-base-url URL` | OpenAI-compatible API root, such as `http://127.0.0.1:11434/v1` |
@@ -34,7 +34,7 @@ On a match, `firewatcher` writes:
34
34
  - a per-incident capture under `{output-folder}/{YYYY-MM}/…`
35
35
  - a line in `{output-folder}/journal.log`
36
36
 
37
- Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears.
37
+ Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears. Each captured line is flushed as it is written, and the capture file is fsynced at most once a second and when the capture closes, so a host crash loses at most about the last second of it.
38
38
 
39
39
  ## systemd service
40
40
 
@@ -74,7 +74,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
74
74
  | `--filter_only` | Filter existing logs only (`cat` instead of `tail -F`, or `journalctl` without `--follow`) |
75
75
  | `--compress-after-months` | Tar.xz monthly dirs after N months (default: 3) |
76
76
  | `--delete-after-months` | Delete `YYYY-MM` dirs after N months (default: 0 = never) |
77
- | `--capture_line_count_max` | Max lines before/after a match (default: 10000) |
77
+ | `--capture_line_count_max` | Max lines before/after a match (default: 10000). Also caps how many recent lines are held in memory |
78
78
  | `--config PATH` | INI config file. Default: `/etc/firewatcher/firewatcher.conf` when that file exists. An explicit path must exist |
79
79
  | `--llm` / `--no-llm` | Enable or disable LLM analysis of each finished capture |
80
80
  | `--llm-base-url URL` | OpenAI-compatible API root, such as `http://127.0.0.1:11434/v1` |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firewatcher
3
- Version: 1.58
3
+ Version: 1.59
4
4
  Summary: Watch logs for incident patterns and persist surrounding context off-box
5
5
  Home-page: https://github.com/yufei-pan/firewatcher
6
6
  Author: Yufei Pan
@@ -59,7 +59,7 @@ On a match, `firewatcher` writes:
59
59
  - a per-incident capture under `{output-folder}/{YYYY-MM}/…`
60
60
  - a line in `{output-folder}/journal.log`
61
61
 
62
- Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears.
62
+ Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears. Each captured line is flushed as it is written, and the capture file is fsynced at most once a second and when the capture closes, so a host crash loses at most about the last second of it.
63
63
 
64
64
  ## systemd service
65
65
 
@@ -99,7 +99,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
99
99
  | `--filter_only` | Filter existing logs only (`cat` instead of `tail -F`, or `journalctl` without `--follow`) |
100
100
  | `--compress-after-months` | Tar.xz monthly dirs after N months (default: 3) |
101
101
  | `--delete-after-months` | Delete `YYYY-MM` dirs after N months (default: 0 = never) |
102
- | `--capture_line_count_max` | Max lines before/after a match (default: 10000) |
102
+ | `--capture_line_count_max` | Max lines before/after a match (default: 10000). Also caps how many recent lines are held in memory |
103
103
  | `--config PATH` | INI config file. Default: `/etc/firewatcher/firewatcher.conf` when that file exists. An explicit path must exist |
104
104
  | `--llm` / `--no-llm` | Enable or disable LLM analysis of each finished capture |
105
105
  | `--llm-base-url URL` | OpenAI-compatible API root, such as `http://127.0.0.1:11434/v1` |
@@ -16,16 +16,12 @@ import datetime
16
16
  import os
17
17
  import unicodedata
18
18
  import configparser
19
- import json
20
19
  import queue
21
- import secrets
22
20
  import socket
23
21
  import syslog
24
22
  import threading
25
- import urllib.error
26
- import urllib.request
27
23
 
28
- version = '1.58'
24
+ version = '1.59'
29
25
  __version__ = version
30
26
 
31
27
  DEFAULT_OUTPUT_FOLDER = '/var/log/captured_messages/'
@@ -35,10 +31,13 @@ SYSTEMD_RUNTIME_DIR = '/run/systemd/system'
35
31
  SYSTEMD_UNIT_DIR = '/etc/systemd/system'
36
32
  SLUG_MAX_LEN = 80
37
33
  _OWN_SYSLOG_IDENT_RE = re.compile(r'(?:^|\s)firewatcher\[\d+\]:')
34
+ _OWN_FILE_NEEDLE = '/' + os.path.basename(__file__)
35
+ _GLOB_CHARS = frozenset('*?[')
38
36
 
39
37
  DEFAULT_CONFIG_PATH = '/etc/firewatcher/firewatcher.conf'
40
38
  SELF_MARKER = 'firewatcher-self'
41
39
  ANALYSIS_QUEUE_MAX = 4
40
+ CAPTURE_SYNC_INTERVAL = 1.0
42
41
  OVERFLOW_NOTIFY_TIMEOUT = 5
43
42
  PROGRAMMATIC_TITLE_MAX = 120
44
43
  NTFY_TITLE_MAX = 200
@@ -168,41 +167,60 @@ def load_patterns(pattern_files):
168
167
 
169
168
  def _is_own_log_line(line):
170
169
  """True for this process's own syslog/journal lines, not incidental matching text."""
171
- if SELF_MARKER in line:
172
- return True
173
- if _OWN_SYSLOG_IDENT_RE.search(line):
174
- return True
175
- base = os.path.basename(__file__)
176
- if base and ('/' + base) in line:
177
- return True
178
- return False
170
+ return SELF_MARKER in line or _OWN_FILE_NEEDLE in line or _OWN_SYSLOG_IDENT_RE.search(line) is not None
179
171
 
180
- def match_patterns(line, patterns):
181
- """Check if the line matches any of the patterns based on the matching mode."""
182
- if _is_own_log_line(line):
183
- return False
184
- if 'regex' in patterns:
185
- if any(regex.search(line) for regex in patterns['regex']):
186
- return True
187
- if 'fnmatch' in patterns:
188
- if any(fnmatch.fnmatch(line, pattern) for pattern in patterns['fnmatch']):
189
- return True
190
- return False
191
-
192
- def get_matched_pattern(line, patterns):
193
- '''Get the first matched pattern for the line'''
194
- if 'regex' in patterns:
195
- for regex in patterns['regex']:
196
- # if regex.search(line):
197
- # return regex.pattern
172
+ class PatternMatcher(object):
173
+ """load_patterns() output compiled once for the per-line hot path.
174
+
175
+ A `*text*` fnmatch pattern without wildcards inside is a plain substring test,
176
+ which is far cheaper than fnmatch's backtracking regex. Patterns are checked in
177
+ sorted order (regex, then substrings, then wildcard globs) so the label does not
178
+ depend on set order.
179
+ """
180
+
181
+ def __init__(self, patterns):
182
+ self.regexes = sorted(patterns.get('regex', ()), key=lambda regex: (regex.pattern, regex.flags))
183
+ self.substrings = []
184
+ self.globs = []
185
+ for pattern in sorted(patterns.get('fnmatch', ())):
186
+ inner = pattern[1:-1]
187
+ if len(pattern) > 2 and pattern[0] == '*' and pattern[-1] == '*' and not _GLOB_CHARS.intersection(inner):
188
+ self.substrings.append((inner, pattern))
189
+ else:
190
+ # fnmatch.fnmatch is fnmatchcase on POSIX; this is its compiled form.
191
+ self.globs.append((re.compile(fnmatch.translate(pattern)).match, pattern))
192
+
193
+ def first_match(self, line):
194
+ """Label of the first matching pattern: the regex match text or the fnmatch pattern."""
195
+ for regex in self.regexes:
198
196
  matched = regex.search(line)
199
197
  if matched:
200
198
  return matched[0]
201
- if 'fnmatch' in patterns:
202
- for pattern in patterns['fnmatch']:
203
- if fnmatch.fnmatch(line, pattern):
199
+ for needle, pattern in self.substrings:
200
+ if needle in line:
204
201
  return pattern
205
- return None
202
+ for match, pattern in self.globs:
203
+ if match(line):
204
+ return pattern
205
+ return None
206
+
207
+ def find(self, line):
208
+ """Like first_match(), but this process's own log lines never match."""
209
+ label = self.first_match(line)
210
+ if label is None or _is_own_log_line(line):
211
+ return None
212
+ return label
213
+
214
+ def _as_matcher(patterns):
215
+ return patterns if isinstance(patterns, PatternMatcher) else PatternMatcher(patterns)
216
+
217
+ def match_patterns(line, patterns):
218
+ """Check if the line matches any of the patterns based on the matching mode."""
219
+ return _as_matcher(patterns).find(line) is not None
220
+
221
+ def get_matched_pattern(line, patterns):
222
+ '''Get the first matched pattern for the line'''
223
+ return _as_matcher(patterns).first_match(line)
206
224
 
207
225
  class ConfigError(Exception):
208
226
  """Raised when the config file or merged settings are unusable."""
@@ -440,6 +458,7 @@ def _ini_text(value):
440
458
 
441
459
  def _generate_ntfy_url():
442
460
  """A hostname plus 72 random bits in ntfy's allowed URL-safe alphabet."""
461
+ import secrets
443
462
  prefix = 'firewatcher-'
444
463
  suffix = secrets.token_urlsafe(9)
445
464
  hostname = re.sub(r'[^A-Za-z0-9_-]+', '-', _hostname()).strip('-_') or 'localhost'
@@ -617,6 +636,10 @@ def _notify_body(text, path):
617
636
 
618
637
 
619
638
  def _urlopen_bytes(url, data, headers, timeout):
639
+ # Imported on first use: urllib.request pulls in ssl and http.client (about 9 MB RSS),
640
+ # and only LLM analysis and ntfy pushes need them.
641
+ import urllib.error
642
+ import urllib.request
620
643
  request = urllib.request.Request(url, data=data, headers=headers, method='POST')
621
644
  try:
622
645
  with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -631,6 +654,7 @@ def _urlopen_bytes(url, data, headers, timeout):
631
654
 
632
655
 
633
656
  def _parse_llm_object(content):
657
+ import json
634
658
  text = str(content).strip()
635
659
  if text.startswith('```'):
636
660
  rows = text.splitlines()
@@ -690,6 +714,7 @@ def _capture_excerpt(path, max_bytes):
690
714
 
691
715
 
692
716
  def call_llm(settings, incident):
717
+ import json
693
718
  excerpt = _capture_excerpt(incident['path'], settings.llm_max_bytes)
694
719
  user = (
695
720
  'Host: %s\nMatched pattern: %s\nMatched line: %s\nCapture path: %s\n\nExcerpt:\n%s'
@@ -857,30 +882,81 @@ def _start_dispatcher(settings):
857
882
  return None
858
883
  return IncidentDispatcher(settings)
859
884
 
860
- def _end_capture(log_file_name, captured_line_counter, capture_line_count_max, incident=None, dispatcher=None):
861
- if not log_file_name:
885
+ class CaptureFile(object):
886
+ """Append handle kept open for one capture.
887
+
888
+ commit() flushes to the OS after each captured line, so losing this process
889
+ loses nothing. fsync runs at most once per sync_interval and on close, which
890
+ bounds what a host crash (or an NFS client cache) can lose to about that long.
891
+ """
892
+
893
+ def __init__(self, path, sync_interval):
894
+ self.path = path
895
+ self.sync_interval = sync_interval
896
+ self.handle = open(path, 'a')
897
+ self.last_sync = None
898
+ self.dirty = False
899
+
900
+ def write(self, text):
901
+ self.handle.write(text)
902
+
903
+ def commit(self):
904
+ self.handle.flush()
905
+ self.dirty = True
906
+ now = time.time()
907
+ if self.last_sync is None or now - self.last_sync >= self.sync_interval:
908
+ self.sync(now)
909
+
910
+ def sync_due(self):
911
+ """When committed data must be fsynced, or None when nothing is pending."""
912
+ return self.last_sync + self.sync_interval if self.dirty else None
913
+
914
+ def sync(self, now=None):
915
+ self.handle.flush()
916
+ try:
917
+ os.fsync(self.handle.fileno())
918
+ except OSError:
919
+ # Some filesystems cannot fsync; the data has still been flushed.
920
+ pass
921
+ self.dirty = False
922
+ self.last_sync = time.time() if now is None else now
923
+
924
+ def close(self):
925
+ try:
926
+ self.sync()
927
+ finally:
928
+ self.handle.close()
929
+
930
+ def _end_capture(capture, captured_line_counter, capture_line_count_max, incident=None, dispatcher=None):
931
+ if not capture:
862
932
  return
863
- with open(log_file_name, "a") as log_file:
933
+ try:
864
934
  if captured_line_counter > capture_line_count_max:
865
- log_file.write(f"{bcolors.warning}Captured line count exceeded capture_line_count_max {capture_line_count_max}. Truncated to {capture_line_count_max} lines.{bcolors.ENDC}\n")
866
- log_file.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
867
- log_file.write(f"{bcolors.warning}End of capture at {datetime.datetime.now().isoformat()}{bcolors.ENDC}\n")
868
- log_file.write(f"{bcolors.warning}Captured {captured_line_counter} lines after first match.{bcolors.ENDC}\n")
869
- log_file.write(bcolors.warning+'-'*80 + bcolors.ENDC +'\n')
935
+ capture.write(f"{bcolors.warning}Captured line count exceeded capture_line_count_max {capture_line_count_max}. Truncated to {capture_line_count_max} lines.{bcolors.ENDC}\n")
936
+ capture.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
937
+ capture.write(f"{bcolors.warning}End of capture at {datetime.datetime.now().isoformat()}{bcolors.ENDC}\n")
938
+ capture.write(f"{bcolors.warning}Captured {captured_line_counter} lines after first match.{bcolors.ENDC}\n")
939
+ capture.write(bcolors.warning+'-'*80 + bcolors.ENDC +'\n')
870
940
  print(f" End of capture at {datetime.datetime.now().isoformat()}")
871
- log_file.write('-'*80 +'\n')
941
+ capture.write('-'*80 +'\n')
942
+ finally:
943
+ capture.close()
872
944
  if dispatcher is not None and incident is not None:
873
945
  job = dict(incident)
874
- job['path'] = log_file_name
946
+ job['path'] = capture.path
875
947
  dispatcher.submit(job)
876
948
 
877
949
 
878
950
  def capture_messages(command_to_run, patterns, capture_time=300, output_folder=DEFAULT_OUTPUT_FOLDER, compress_after_months=3, delete_after_months=0,capture_line_count_max=10000, notify_settings=None):
879
951
  """Tail the log file and process lines with buffer management."""
880
- log_buffer = deque() # Stores tuples of (read_time, line)
952
+ matcher = _as_matcher(patterns)
953
+ # Only the newest capture_line_count_max buffered lines are ever written, so hold no more.
954
+ buffer_cap = max(0, capture_line_count_max)
955
+ log_buffer = deque(maxlen=buffer_cap) # Stores tuples of (read_time, line)
956
+ dropped_until = None # read_time of the newest line the cap pushed out of log_buffer
881
957
  log_compressor = Log_Compressor(output_folder, compress_after_months, delete_after_months)
882
958
  capture_until = None
883
- log_file_name = None
959
+ capture = None
884
960
  captured_line_counter = 0
885
961
  processed_line_counter = 0
886
962
  incident = None
@@ -896,17 +972,28 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
896
972
  pending_read = None
897
973
  while True:
898
974
  try:
899
- if pending_read is None:
900
- pending_read = reader.submit(proc.stdout.readline)
901
- remaining = None if capture_until is None else max(0, capture_until - time.time())
902
- line = pending_read.result(timeout=remaining)
903
- pending_read = None
975
+ if pending_read is None and capture_until is None:
976
+ # Nothing can time out, so read inline instead of waking the reader thread per line.
977
+ line = proc.stdout.readline()
978
+ else:
979
+ if pending_read is None:
980
+ pending_read = reader.submit(proc.stdout.readline)
981
+ deadline = capture_until
982
+ sync_due = capture.sync_due() if capture else None
983
+ if sync_due is not None and (deadline is None or sync_due < deadline):
984
+ deadline = sync_due
985
+ remaining = None if deadline is None else max(0, deadline - time.time())
986
+ line = pending_read.result(timeout=remaining)
987
+ pending_read = None
904
988
  except ReadTimeout:
905
989
  # Keep the same pending read while the source is quiet.
906
- _end_capture(log_file_name, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
990
+ if capture and capture_until is not None and time.time() < capture_until:
991
+ capture.sync()
992
+ continue
993
+ _end_capture(capture, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
907
994
  capture_until = None
908
995
  captured_line_counter = 0
909
- log_file_name = None
996
+ capture = None
910
997
  continue
911
998
  except KeyboardInterrupt:
912
999
  print("Exiting.")
@@ -926,15 +1013,17 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
926
1013
  read_time = time.time()
927
1014
  processed_line_counter += 1
928
1015
 
929
- # Maintain a 10-minute buffer
1016
+ # Keep capture_time seconds of context, at most buffer_cap lines
930
1017
  while log_buffer and read_time - log_buffer[0][0] > capture_time:
931
1018
  log_buffer.popleft()
1019
+ if len(log_buffer) == buffer_cap:
1020
+ dropped_until = log_buffer[0][0] if log_buffer else read_time
932
1021
  log_buffer.append((read_time, line))
933
1022
 
934
1023
  # if the current line matches the pattern, capture the logs for the next capture_time seconds
935
- if match_patterns(line, patterns):
1024
+ matched_pattern = matcher.find(line)
1025
+ if matched_pattern is not None:
936
1026
  if not capture_until:
937
- matched_pattern = get_matched_pattern(line, patterns)
938
1027
  # Dump the buffer to file at output_folder/{Year-Month}/{Day-Hour-Minute-Second}.log
939
1028
  log_folder = f"{output_folder}/{datetime.datetime.fromtimestamp(read_time).strftime('%Y-%m')}"
940
1029
  os.makedirs(log_folder, exist_ok=True)
@@ -945,59 +1034,56 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
945
1034
  journal_file.write(f"{datetime.datetime.fromtimestamp(read_time).isoformat()} Captured logs for {matched_pattern} to {log_file_name}\n")
946
1035
  print(f"Capturing logs to {log_file_name}")
947
1036
  incident = {
948
- 'pattern': matched_pattern if matched_pattern is not None else '',
1037
+ 'pattern': matched_pattern,
949
1038
  'matched_line': line.strip(),
950
1039
  'hostname': _hostname(),
951
1040
  }
952
- with open(log_file_name, "a") as log_file:
953
- log_file.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
954
- # Write the matched pattern and the line that matched
955
- log_file.write(f"{bcolors.warning}Matched pattern: {bcolors.critical}{matched_pattern}{bcolors.ENDC}\n")
956
- print(f" Matched pattern: {matched_pattern}")
957
- log_file.write(f"{bcolors.warning}Matched line: {line}{bcolors.ENDC}")
958
- print(f" Matched line: {line.strip()}")
959
- log_file.write(f"{bcolors.warning}Captured at: {datetime.datetime.now().isoformat()}{bcolors.ENDC}\n")
960
- print(f" Captured at: {datetime.datetime.now().isoformat()}")
961
- log_file.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
962
- log_file.write(f"{bcolors.warning}Logs since {datetime.datetime.fromtimestamp(read_time - capture_time).isoformat()}:{bcolors.ENDC}\n")
963
- skip_lines = 0
964
- if len(log_buffer) > capture_line_count_max:
965
- skip_lines = len(log_buffer) - capture_line_count_max
966
- log_file.write(f"{bcolors.warning}Captured line count exceeded capture_line_count_max {capture_line_count_max}. Skipping cached {skip_lines} in-memory lines ...{bcolors.ENDC}\n")
967
- for i in range(skip_lines, len(log_buffer)):
968
- buffered_line = log_buffer[i][1]
969
- if match_patterns(buffered_line, patterns):
970
- log_file.write(f'{bcolors.critical}-> {buffered_line.strip()}{bcolors.ENDC}\n')
971
- else:
972
- log_file.write(buffered_line)
1041
+ capture = CaptureFile(log_file_name, CAPTURE_SYNC_INTERVAL)
1042
+ capture.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
1043
+ # Write the matched pattern and the line that matched
1044
+ capture.write(f"{bcolors.warning}Matched pattern: {bcolors.critical}{matched_pattern}{bcolors.ENDC}\n")
1045
+ print(f" Matched pattern: {matched_pattern}")
1046
+ capture.write(f"{bcolors.warning}Matched line: {line}{bcolors.ENDC}")
1047
+ print(f" Matched line: {line.strip()}")
1048
+ capture.write(f"{bcolors.warning}Captured at: {datetime.datetime.now().isoformat()}{bcolors.ENDC}\n")
1049
+ print(f" Captured at: {datetime.datetime.now().isoformat()}")
1050
+ capture.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
1051
+ capture.write(f"{bcolors.warning}Logs since {datetime.datetime.fromtimestamp(read_time - capture_time).isoformat()}:{bcolors.ENDC}\n")
1052
+ if dropped_until is not None and read_time - dropped_until <= capture_time:
1053
+ capture.write(f"{bcolors.warning}Captured line count exceeded capture_line_count_max {capture_line_count_max}. Older cached in-memory lines were dropped ...{bcolors.ENDC}\n")
1054
+ for _buffered_time, buffered_line in log_buffer:
1055
+ if matcher.find(buffered_line) is not None:
1056
+ capture.write(f'{bcolors.critical}-> {buffered_line.strip()}{bcolors.ENDC}\n')
1057
+ else:
1058
+ capture.write(buffered_line)
973
1059
  else:
974
1060
  # Append the line to the current log file
975
- with open(log_file_name, "a") as log_file:
976
- log_file.write(f'{bcolors.critical}-> {line.strip()}{bcolors.ENDC}\n')
1061
+ capture.write(f'{bcolors.critical}-> {line.strip()}{bcolors.ENDC}\n')
977
1062
  captured_line_counter += 1
1063
+ capture.commit()
978
1064
  # If a match is found, set to capture the next capture_time seconds
979
1065
  capture_until = read_time + capture_time
980
- if log_file_name and captured_line_counter > capture_line_count_max:
981
- _end_capture(log_file_name, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
1066
+ if captured_line_counter > capture_line_count_max:
1067
+ _end_capture(capture, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
982
1068
  capture_until = None
983
1069
  captured_line_counter = 0
984
- log_file_name = None
1070
+ capture = None
985
1071
  # if the current line doesn't match the pattern, and capture_until is set and expired, reset capture_until and close the current log file
986
- elif log_file_name and ((capture_until and read_time > capture_until) or captured_line_counter > capture_line_count_max):
987
- _end_capture(log_file_name, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
1072
+ elif capture and ((capture_until and read_time > capture_until) or captured_line_counter > capture_line_count_max):
1073
+ _end_capture(capture, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
988
1074
  capture_until = None
989
1075
  captured_line_counter = 0
990
- log_file_name = None
1076
+ capture = None
991
1077
  # if the current line doesn't match the pattern, continue to write the logs to the current log file if capture_until is set and not expired
992
1078
  elif capture_until and read_time <= capture_until:
993
- with open(log_file_name, "a") as log_file:
994
- log_file.write(line)
1079
+ capture.write(line)
1080
+ capture.commit()
995
1081
  captured_line_counter += 1
996
1082
  if captured_line_counter > capture_line_count_max:
997
- _end_capture(log_file_name, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
1083
+ _end_capture(capture, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
998
1084
  capture_until = None
999
1085
  captured_line_counter = 0
1000
- log_file_name = None
1086
+ capture = None
1001
1087
  # if the current line doesn't match the pattern, and capture_until is not set, check for log compression
1002
1088
  else:
1003
1089
  log_compressor.compressLogs()
@@ -1005,8 +1091,8 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
1005
1091
  print(f"Processed {processed_line_counter} lines.",flush=True)
1006
1092
  finally:
1007
1093
  try:
1008
- if log_file_name:
1009
- _end_capture(log_file_name, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
1094
+ if capture:
1095
+ _end_capture(capture, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
1010
1096
  finally:
1011
1097
  if dispatcher is not None:
1012
1098
  dispatcher.close()
@@ -1280,7 +1366,7 @@ def build_parser():
1280
1366
  filt.add_argument('--filter_only', dest='filter_only', action='store_true', help='Only filter logs and do not wait for new lines. Uses cat instead of tail -F, or journalctl without --follow.')
1281
1367
  filt.add_argument('--no-filter_only', dest='filter_only', action='store_false', help='Follow new log lines. Overrides filter_only from the config file.')
1282
1368
  parser.add_argument('--compress-after-months', type=int, default=None, help='Compress logs after this many months. Default: 3 months.')
1283
- parser.add_argument('--capture_line_count_max', type=int, default=None, help='Maximum number of lines to capture before and after in a single log file. Default: 10000 lines. Note: The final file maybe 2 * this number.')
1369
+ parser.add_argument('--capture_line_count_max', type=int, default=None, help='Maximum number of lines to capture before and after in a single log file. Default: 10000 lines. Note: The final file maybe 2 * this number. Also caps how many recent lines are held in memory.')
1284
1370
  parser.add_argument('--delete-after-months', type=int, default=None, help='Delete logs after this many months. (will only delete files in YYYY-MM format) Default: 0 (never).')
1285
1371
  svc = parser.add_mutually_exclusive_group()
1286
1372
  svc.add_argument('--install-service', action='store_true', help='Install and enable a systemd unit for this command line. Warns and exits if systemd is not available.')
@@ -1,4 +1,7 @@
1
+ import collections
1
2
  import datetime
3
+ import threading
4
+ import time
2
5
 
3
6
  import firewatcher as fw
4
7
  from conftest import Clock, capture, incident_logs, ScriptedPopen, patch_popen
@@ -157,7 +160,7 @@ def test_capture_skips_excess_pre_match_buffer(tmp_path):
157
160
  body = ''.join(f'pre-{i}\n' for i in range(10)) + 'HIT\n'
158
161
  out = capture(tmp_path, body, capture_line_count_max=3)
159
162
  text = incident_logs(out)[0].read_text()
160
- assert 'Skipping cached' in text
163
+ assert 'Older cached in-memory lines were dropped' in text
161
164
  assert 'pre-0' not in text
162
165
  assert 'pre-8' in text
163
166
  assert 'pre-9' in text
@@ -224,3 +227,121 @@ def test_capture_rematch_extends_window(tmp_path, monkeypatch):
224
227
  text = incident_logs(out)[0].read_text()
225
228
  assert '-> HIT 2' in text
226
229
  assert 'late' in text
230
+
231
+
232
+ def run_scripted(tmp_path, monkeypatch, popen, **kw):
233
+ pat = tmp_path / 'p.txt'
234
+ pat.write_text('HIT\n')
235
+ opts = dict(capture_time=60, output_folder=str(tmp_path / 'out'), compress_after_months=0, delete_after_months=0)
236
+ opts.update(kw)
237
+ patch_popen(monkeypatch, popen)
238
+ fw.capture_messages(['unused'], fw.load_patterns([str(pat)]), **opts)
239
+ return tmp_path / 'out'
240
+
241
+
242
+ def test_pre_match_buffer_is_capped_at_capture_line_count_max(tmp_path, monkeypatch):
243
+ class RecordingDeque(collections.deque):
244
+ peak = 0
245
+
246
+ def append(self, item):
247
+ super().append(item)
248
+ RecordingDeque.peak = max(RecordingDeque.peak, len(self))
249
+
250
+ monkeypatch.setattr(fw, 'deque', RecordingDeque)
251
+ capture(tmp_path, ''.join(f'noise-{i}\n' for i in range(50)), capture_line_count_max=5)
252
+ assert 0 < RecordingDeque.peak <= 5
253
+
254
+
255
+ def test_capture_does_not_report_dropped_lines_that_aged_out(tmp_path, monkeypatch):
256
+ clock = Clock(1_000_000.0)
257
+ monkeypatch.setattr(fw.time, 'time', clock.time)
258
+ popen = ScriptedPopen([
259
+ ('old-1\n', 0), ('old-2\n', 0), ('old-3\n', 0),
260
+ ('mid\n', 70), ('HIT now\n', 0),
261
+ ], clock=clock)
262
+ out = run_scripted(tmp_path, monkeypatch, popen, capture_line_count_max=2)
263
+ text = incident_logs(out)[0].read_text()
264
+ assert 'mid' in text
265
+ assert 'old-3' not in text
266
+ assert 'Older cached in-memory lines were dropped' not in text
267
+
268
+
269
+ def test_idle_reads_stay_on_the_calling_thread(tmp_path, monkeypatch):
270
+ threads = []
271
+
272
+ class RecordingPopen(ScriptedPopen):
273
+ def __init__(self, events):
274
+ super().__init__(events)
275
+ readline = self.stdout.readline
276
+
277
+ def recording_readline():
278
+ threads.append(threading.current_thread())
279
+ return readline()
280
+
281
+ self.stdout.readline = recording_readline
282
+
283
+ run_scripted(tmp_path, monkeypatch, RecordingPopen(['noise\n', 'HIT now\n', 'after\n']))
284
+ main = threading.current_thread()
285
+ assert threads[0] is main and threads[1] is main
286
+ assert threads[2] is not main
287
+
288
+
289
+ def test_capture_opens_the_capture_file_once(tmp_path, monkeypatch):
290
+ opened = []
291
+ real_open = open
292
+
293
+ def counting_open(path, *args, **kwargs):
294
+ opened.append(str(path))
295
+ return real_open(path, *args, **kwargs)
296
+
297
+ monkeypatch.setattr(fw, 'open', counting_open, raising=False)
298
+ out = capture(tmp_path, 'HIT now\n' + ''.join(f'after-{i}\n' for i in range(20)))
299
+ cap = str(incident_logs(out)[0])
300
+ assert opened.count(cap) == 1
301
+ assert 'after-19' in incident_logs(out)[0].read_text()
302
+
303
+
304
+ def test_capture_fsyncs_at_most_once_per_interval(tmp_path, monkeypatch):
305
+ clock = Clock(1_000_000.0)
306
+ monkeypatch.setattr(fw.time, 'time', clock.time)
307
+ synced_at = []
308
+ monkeypatch.setattr(fw.os, 'fsync', lambda fd: synced_at.append(clock.t))
309
+ popen = ScriptedPopen([
310
+ ('HIT now\n', 0), ('a\n', 0.125), ('b\n', 0.25), ('c\n', 0.875),
311
+ ], clock=clock)
312
+ run_scripted(tmp_path, monkeypatch, popen)
313
+ start = 1_000_000.0
314
+ assert synced_at == [start, start + 1.25, start + 1.25]
315
+
316
+
317
+ def test_capture_fsyncs_pending_lines_when_source_goes_quiet(tmp_path, monkeypatch):
318
+ monkeypatch.setattr(fw, 'CAPTURE_SYNC_INTERVAL', 0.05)
319
+ events = []
320
+ monkeypatch.setattr(fw.os, 'fsync', lambda fd: events.append('fsync'))
321
+
322
+ class QuietStdout:
323
+ def __init__(self):
324
+ self.lines = ['HIT now\n', 'more\n']
325
+
326
+ def readline(self):
327
+ if self.lines:
328
+ return self.lines.pop(0)
329
+ time.sleep(0.5)
330
+ events.append('eof')
331
+ return ''
332
+
333
+ popen = ScriptedPopen([])
334
+ popen.stdout = QuietStdout()
335
+ run_scripted(tmp_path, monkeypatch, popen)
336
+ assert events == ['fsync', 'fsync', 'eof', 'fsync']
337
+
338
+
339
+ def test_capture_survives_filesystems_without_fsync(tmp_path, monkeypatch):
340
+ def no_fsync(fd):
341
+ raise OSError(22, 'Invalid argument')
342
+
343
+ monkeypatch.setattr(fw.os, 'fsync', no_fsync)
344
+ out = capture(tmp_path, 'HIT now\nafter\n')
345
+ text = incident_logs(out)[0].read_text()
346
+ assert 'after' in text
347
+ assert 'End of capture' in text
@@ -102,7 +102,7 @@ def test_parser_rejects_conflicting_service_flags():
102
102
  def test_module_has_no_nebula_product_name():
103
103
  src = open(fw.__file__, encoding='utf-8').read().lower()
104
104
  assert 'nebula' not in src
105
- assert fw.version == '1.58'
105
+ assert fw.version == '1.59'
106
106
  assert fw.__version__ == fw.version
107
107
 
108
108
 
@@ -142,3 +142,13 @@ def test_main_install_defaults_pattern_dir(monkeypatch):
142
142
  def test_print_honors_explicit_flush_false(capsys):
143
143
  fw.print('no-force', flush=False)
144
144
  assert 'no-force' in capsys.readouterr().out
145
+
146
+
147
+ def test_import_leaves_http_and_secret_modules_unloaded():
148
+ heavy = ('urllib.request', 'http.client', 'ssl', 'json', 'secrets')
149
+ code = (
150
+ 'import sys; sys.path.insert(0, %r); import firewatcher; '
151
+ 'print(" ".join(m for m in %r if m in sys.modules))'
152
+ ) % (os.path.dirname(os.path.abspath(fw.__file__)), heavy)
153
+ loaded = subprocess.run([sys.executable, '-I', '-S', '-c', code], capture_output=True, text=True, check=True).stdout.split()
154
+ assert loaded == []
@@ -94,3 +94,36 @@ def test_match_regex_miss_falls_through_to_fnmatch():
94
94
  assert fw.get_matched_pattern('xx HIT yy', patterns) == '*HIT*'
95
95
  assert fw.match_patterns('nope', patterns) is False
96
96
  assert fw.get_matched_pattern('nope', patterns) is None
97
+
98
+
99
+ def test_pattern_matcher_agrees_with_fnmatch_for_wildcards_and_literals():
100
+ import fnmatch
101
+ pats = ['*I/O error*', '*a?c*', '*[0-9]x*', '*back\\slash*', '*100%*', 'exact', '*[unclosed*', '***']
102
+ lines = ['sd I/O error', 'zzabczz', 'a?c', 'id 7x', 'back\\slash', 'at 100%', 'exact', 'exactly',
103
+ '[unclosed', 'nothing', '', 'multi\nline a1c\n']
104
+ for pat in pats:
105
+ matcher = fw.PatternMatcher({'fnmatch': {pat}})
106
+ for line in lines:
107
+ expected = pat if fnmatch.fnmatch(line, pat) else None
108
+ assert matcher.first_match(line) == expected, (pat, line)
109
+
110
+
111
+ def test_matched_pattern_label_does_not_depend_on_pattern_order():
112
+ for order in (['*error*', '*disk*'], ['*disk*', '*error*']):
113
+ assert fw.get_matched_pattern('disk error', {'fnmatch': order}) == '*disk*'
114
+ regexes = [re.compile(r'sd[a-z]'), re.compile(r'ata\d')]
115
+ for order in (regexes, regexes[::-1]):
116
+ assert fw.get_matched_pattern('ata3 sdb fail', {'regex': order}) == 'ata3'
117
+
118
+
119
+ def test_own_line_check_runs_only_for_candidate_matches(monkeypatch):
120
+ calls = []
121
+ real = fw._is_own_log_line
122
+ monkeypatch.setattr(fw, '_is_own_log_line', lambda line: calls.append(line) or real(line))
123
+ matcher = fw.PatternMatcher({'fnmatch': {'*panic*'}, 'regex': {re.compile(r'nvme\d')}})
124
+ for line in ('quiet line', 'another quiet line', 'host firewatcher[12]: nothing'):
125
+ assert matcher.find(line) is None
126
+ assert calls == []
127
+ assert matcher.find('host firewatcher[12]: panic') is None
128
+ assert matcher.find('kernel panic') == '*panic*'
129
+ assert len(calls) == 2
@@ -2,6 +2,7 @@ import io
2
2
  import json
3
3
  import sys
4
4
  import urllib.error
5
+ import urllib.request
5
6
 
6
7
  import pytest
7
8
 
@@ -93,7 +94,7 @@ def test_capture_notifies_without_llm(tmp_path, monkeypatch):
93
94
  calls.append((req, timeout))
94
95
  return Body(b'ok')
95
96
 
96
- monkeypatch.setattr(fw.urllib.request, 'urlopen', urlopen)
97
+ monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
97
98
  resolved = settings_from(tmp_path, '[notify]\nenabled = true\nntfy_url = https://ntfy.example/alerts\npriority = 4\ntoken = secret\n')
98
99
  pat = tmp_path / 'p.txt'
99
100
  pat.write_text('HIT\n')
@@ -164,7 +165,7 @@ def test_llm_notify_true_appends_report_and_pushes(tmp_path, monkeypatch):
164
165
  return chat(payload)
165
166
  return Body(b'ok')
166
167
 
167
- monkeypatch.setattr(fw.urllib.request, 'urlopen', urlopen)
168
+ monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
168
169
  resolved = settings_from(
169
170
  tmp_path,
170
171
  '[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\napi_key = key\n'
@@ -202,7 +203,7 @@ def test_llm_decline_does_not_push(tmp_path, monkeypatch):
202
203
  calls.append(req.full_url)
203
204
  return chat(payload)
204
205
 
205
- monkeypatch.setattr(fw.urllib.request, 'urlopen', urlopen)
206
+ monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
206
207
  resolved = settings_from(
207
208
  tmp_path,
208
209
  '[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\n'
@@ -228,7 +229,7 @@ def test_llm_bad_json_and_http_error_fail_open(tmp_path, monkeypatch):
228
229
  return chat('not json')
229
230
  return Body(b'ok')
230
231
 
231
- monkeypatch.setattr(fw.urllib.request, 'urlopen', bad_json)
232
+ monkeypatch.setattr(urllib.request, 'urlopen', bad_json)
232
233
  resolved = settings_from(
233
234
  tmp_path,
234
235
  '[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\ntimeout = 3\n'
@@ -251,7 +252,7 @@ def test_llm_bad_json_and_http_error_fail_open(tmp_path, monkeypatch):
251
252
  raise urllib.error.HTTPError(req.full_url, 503, 'down', {}, io.BytesIO(b'busy'))
252
253
  return Body(b'ok')
253
254
 
254
- monkeypatch.setattr(fw.urllib.request, 'urlopen', http_down)
255
+ monkeypatch.setattr(urllib.request, 'urlopen', http_down)
255
256
  path2 = tmp_path / 'cap2.log'
256
257
  path2.write_text('HIT again\n')
257
258
  dispatcher = fw.IncidentDispatcher(resolved, start_thread=True)
@@ -267,7 +268,7 @@ def test_ntfy_failure_is_journaled(tmp_path, monkeypatch):
267
268
  def urlopen(req, timeout=None):
268
269
  raise urllib.error.URLError('unreachable')
269
270
 
270
- monkeypatch.setattr(fw.urllib.request, 'urlopen', urlopen)
271
+ monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
271
272
  resolved = settings_from(tmp_path, '[notify]\nenabled = true\nntfy_url = https://ntfy.example/alerts\n')
272
273
  dispatcher = fw.IncidentDispatcher(resolved, start_thread=True)
273
274
  dispatcher.submit(incident(tmp_path / 'cap.log'))
@@ -283,7 +284,7 @@ def test_queue_full_skips_llm_and_notifies_immediately(tmp_path, monkeypatch):
283
284
  calls.append((req.full_url, timeout))
284
285
  return Body(b'ok')
285
286
 
286
- monkeypatch.setattr(fw.urllib.request, 'urlopen', urlopen)
287
+ monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
287
288
  resolved = settings_from(
288
289
  tmp_path,
289
290
  '[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\n'
@@ -302,7 +303,7 @@ def test_queue_full_skips_llm_and_notifies_immediately(tmp_path, monkeypatch):
302
303
 
303
304
  def test_notify_only_jobs_use_higher_priority(tmp_path, monkeypatch):
304
305
  silence_syslog(monkeypatch)
305
- monkeypatch.setattr(fw.urllib.request, 'urlopen', lambda *args, **kwargs: Body(b'ok'))
306
+ monkeypatch.setattr(urllib.request, 'urlopen', lambda *args, **kwargs: Body(b'ok'))
306
307
  resolved = settings_from(tmp_path, '[notify]\nenabled = true\nntfy_url = https://ntfy.example/alerts\n')
307
308
  dispatcher = fw.IncidentDispatcher(resolved, start_thread=False)
308
309
  dispatcher.submit(incident(tmp_path / 'cap.log'))
@@ -351,10 +352,10 @@ def test_helper_edges(tmp_path, monkeypatch, capsys):
351
352
  def urlopen(req, timeout=None):
352
353
  raise urllib.error.HTTPError(req.full_url, 500, 'err', {}, Boom())
353
354
 
354
- monkeypatch.setattr(fw.urllib.request, 'urlopen', urlopen)
355
+ monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
355
356
  with pytest.raises(RuntimeError, match='HTTP 500'):
356
357
  fw._urlopen_bytes('http://llm.example/v1', b'{}', {}, 1)
357
- monkeypatch.setattr(fw.urllib.request, 'urlopen', lambda req, timeout=None: Body(b'not-json'))
358
+ monkeypatch.setattr(urllib.request, 'urlopen', lambda req, timeout=None: Body(b'not-json'))
358
359
  settings = settings_from(tmp_path, '[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\n')
359
360
  with pytest.raises(ValueError, match='unreadable'):
360
361
  fw.call_llm(settings, incident(path))
@@ -362,7 +363,7 @@ def test_helper_edges(tmp_path, monkeypatch, capsys):
362
363
 
363
364
  def test_overflow_without_notify_and_unwritable_failure_note(tmp_path, monkeypatch):
364
365
  records = silence_syslog(monkeypatch)
365
- monkeypatch.setattr(fw.urllib.request, 'urlopen', lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('no http')))
366
+ monkeypatch.setattr(urllib.request, 'urlopen', lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('no http')))
366
367
  resolved = settings_from(tmp_path, '[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\n')
367
368
  dispatcher = fw.IncidentDispatcher(resolved, start_thread=False)
368
369
  for index in range(fw.ANALYSIS_QUEUE_MAX):
@@ -385,7 +386,7 @@ def test_llm_without_notify_writes_report_only(tmp_path, monkeypatch):
385
386
  calls.append(req.full_url)
386
387
  return chat(payload)
387
388
 
388
- monkeypatch.setattr(fw.urllib.request, 'urlopen', urlopen)
389
+ monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
389
390
  resolved = settings_from(
390
391
  tmp_path,
391
392
  '[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\n',
File without changes
File without changes
File without changes
File without changes