hiac-utils 0.1.0__py3-none-any.whl

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.
hiac_utils/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ # Scripts for automated Nextcloud setup.
2
+ # Copyright (C) 2026 Guillermo López Alejos
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """hiac_utils — shared HIAC utilities."""
18
+
19
+ from __future__ import annotations
20
+
21
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
22
+
23
+ from hiac_utils.log_context import LogContext
24
+
25
+ try:
26
+ __version__ = _pkg_version("hiac_utils")
27
+ except PackageNotFoundError: # pragma: no cover - running from an uninstalled checkout
28
+ __version__ = "0+unknown"
29
+
30
+ __all__ = ["__version__", "LogContext"]
hiac_utils/__main__.py ADDED
@@ -0,0 +1,49 @@
1
+ # Scripts for automated Nextcloud setup.
2
+ # Copyright (C) 2026 Guillermo López Alejos
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Entry point: ``python -m hiac_utils``.
18
+
19
+ hiac_utils is primarily an Ansible filter-plugin library with no operational
20
+ CLI of its own; this entry point exists so the component can report its own
21
+ version, per AGENTS.md's "Version file" section.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+
28
+ from . import __version__
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(
33
+ prog="python -m hiac_utils",
34
+ description="HIAC shared utility library (Ansible filter plugins).",
35
+ )
36
+ parser.add_argument(
37
+ "--version",
38
+ action="version",
39
+ version=f"hiac_utils {__version__}",
40
+ )
41
+ return parser
42
+
43
+
44
+ def main() -> None:
45
+ build_parser().parse_args()
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -0,0 +1,81 @@
1
+ # Scripts for automated Nextcloud setup.
2
+ # Copyright (C) 2026 Guillermo López Alejos
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Per-thread logging context labels, stackable via context managers."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextvars
22
+ import logging
23
+ from types import TracebackType
24
+ from typing import TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ pass
28
+
29
+ __all__ = ["LogContext"]
30
+
31
+
32
+ class LogContext:
33
+ """Push a label onto the logging context stack (thread-safe via ContextVar).
34
+
35
+ Labels from nested contexts are joined with ".":
36
+
37
+ with LogContext("SCN0004"):
38
+ with LogContext("hiac_entrypoint"):
39
+ # log records carry ctx="SCN0004.hiac_entrypoint"
40
+
41
+ Use `install_filter` once per handler to inject ``record.ctx`` into every
42
+ log record that passes through it. Then include ``%(ctx)s`` in the format
43
+ string; the value is ``"[label] "`` when a context is active, ``""`` when
44
+ not, so existing format strings need only a ``%(ctx)s`` prefix added.
45
+ """
46
+
47
+ _var: contextvars.ContextVar[str] = contextvars.ContextVar(
48
+ "hiac_log_ctx", default=""
49
+ )
50
+
51
+ class _Filter(logging.Filter):
52
+ def filter(self, record: logging.LogRecord) -> bool:
53
+ ctx = LogContext._var.get()
54
+ record.ctx = f"[{ctx}] " if ctx else "" # type: ignore[attr-defined]
55
+ return True
56
+
57
+ def __init__(self, label: str) -> None:
58
+ current = self._var.get()
59
+ new = (f"{current}.{label}" if current else label) if label else current
60
+ self._token = self._var.set(new)
61
+
62
+ def __enter__(self) -> "LogContext":
63
+ return self
64
+
65
+ def __exit__(
66
+ self,
67
+ exc_type: type[BaseException] | None,
68
+ exc_val: BaseException | None,
69
+ exc_tb: TracebackType | None,
70
+ ) -> None:
71
+ self._var.reset(self._token)
72
+
73
+ @classmethod
74
+ def current(cls) -> str:
75
+ """Return the active context string for propagation to child threads."""
76
+ return cls._var.get()
77
+
78
+ @classmethod
79
+ def install_filter(cls, handler: logging.Handler) -> None:
80
+ """Attach the ctx-injecting filter to *handler*. Call once per handler."""
81
+ handler.addFilter(cls._Filter())
@@ -0,0 +1,27 @@
1
+ # Scripts for automated Nextcloud setup.
2
+ # Copyright (C) 2026 Guillermo López Alejos
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """hiac_utils.log_monitor — log monitoring and early-error-detection engine."""
18
+
19
+ from .profile import GroupRule, MessageRule, Profile, load_profile
20
+ from .engine import LogMonitorEngine
21
+ from .thread import LogMonitorThread
22
+
23
+ __all__ = [
24
+ "GroupRule", "MessageRule", "Profile", "load_profile",
25
+ "LogMonitorEngine",
26
+ "LogMonitorThread",
27
+ ]
@@ -0,0 +1,197 @@
1
+ # Scripts for automated Nextcloud setup.
2
+ # Copyright (C) 2026 Guillermo López Alejos
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """CLI entry point: python -m hiac_utils.log_monitor <subcommand>
18
+
19
+ Subcommands
20
+ -----------
21
+ process
22
+ Read log lines from stdin, evaluate profile rules, emit D-Bus signals.
23
+ Also writes human-friendly log output to --human-friendly-log-path.
24
+
25
+ Usage::
26
+
27
+ python -m hiac_utils.log_monitor process \\
28
+ --profile /path/to/log_monitor_profile.yml \\
29
+ --log-processor-id hiac_server_journalctl \\
30
+ --dbus-address unix:path=/run/user/1000/bus \\
31
+ --human-friendly-log-path /evidence/nodes/hiac_server_1/journalctl.log
32
+
33
+ listen
34
+ Subscribe to D-Bus, write all events to --events-log, and print
35
+ CRITICAL events to stdout (one line per event).
36
+
37
+ Usage::
38
+
39
+ python -m hiac_utils.log_monitor listen \\
40
+ --dbus-address unix:path=/run/user/1000/bus \\
41
+ --events-log /evidence/log/events.log
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import select
48
+ import signal
49
+ import sys
50
+ import threading
51
+ import time
52
+ from pathlib import Path
53
+
54
+
55
+ def _process_stdin(engine, human_fh) -> None:
56
+ """Read log lines from stdin until EOF, feeding each line to the engine."""
57
+ stdin = sys.stdin
58
+ # 1-second select timeout so tick() fires even during idle periods.
59
+ while True:
60
+ ready, _, _ = select.select([stdin], [], [], 1.0)
61
+ if ready:
62
+ line = stdin.readline()
63
+ if not line: # EOF — SSH process terminated
64
+ break
65
+ human_fh.write(line)
66
+ human_fh.flush()
67
+ engine.process_line(line)
68
+ else:
69
+ engine.tick()
70
+
71
+
72
+ def _process_file(path: str, retry_sleep: float, retry_max: int,
73
+ engine, human_fh) -> None:
74
+ """Wait for `path` to exist, then follow it line by line until SIGTERM/SIGINT.
75
+
76
+ retry_sleep: seconds to wait between existence checks.
77
+ retry_max: max number of sleep cycles before giving up (0 = wait forever).
78
+ """
79
+ _stop = threading.Event()
80
+
81
+ def _on_signal(signum, frame):
82
+ _stop.set()
83
+
84
+ signal.signal(signal.SIGTERM, _on_signal)
85
+ signal.signal(signal.SIGINT, _on_signal)
86
+
87
+ file_path = Path(path)
88
+ attempts = 0
89
+ while not file_path.exists() and not _stop.is_set():
90
+ if retry_max > 0 and attempts >= retry_max:
91
+ return # file never appeared within the retry budget
92
+ engine.tick()
93
+ _stop.wait(retry_sleep) # interruptible sleep — wakes immediately on SIGTERM
94
+ attempts += 1
95
+
96
+ if _stop.is_set() or not file_path.exists():
97
+ return
98
+
99
+ with file_path.open("r", encoding="utf-8", errors="replace") as fh:
100
+ while not _stop.is_set():
101
+ line = fh.readline()
102
+ if line:
103
+ human_fh.write(line)
104
+ human_fh.flush()
105
+ engine.process_line(line)
106
+ else:
107
+ engine.tick()
108
+ _stop.wait(0.5)
109
+
110
+
111
+ def _cmd_process(args: argparse.Namespace) -> int:
112
+ from .engine import LogMonitorEngine
113
+ from .profile import load_profile
114
+ from .signals import make_dbus_sender
115
+
116
+ profile = load_profile(Path(args.profile), timeout_factor=args.timeout_factor)
117
+ signal_fn = make_dbus_sender(args.dbus_address, args.log_processor_id)
118
+ engine = LogMonitorEngine(profile, signal_fn)
119
+
120
+ human_fh = open(args.human_friendly_log_path, "w", encoding="utf-8")
121
+ try:
122
+ if args.file:
123
+ _process_file(
124
+ args.file, args.retry_sleep, args.retry_max, engine, human_fh,
125
+ )
126
+ else:
127
+ _process_stdin(engine, human_fh)
128
+ finally:
129
+ human_fh.close()
130
+
131
+ return 0
132
+
133
+
134
+ def _cmd_listen(args: argparse.Namespace) -> int:
135
+ from .listener import run_listener
136
+
137
+ run_listener(
138
+ dbus_address=args.dbus_address,
139
+ events_log_path=Path(args.events_log),
140
+ )
141
+ return 0
142
+
143
+
144
+ def build_parser() -> argparse.ArgumentParser:
145
+ parser = argparse.ArgumentParser(
146
+ prog="python -m hiac_utils.log_monitor",
147
+ description="HIAC log monitor — process log streams or listen for D-Bus events.",
148
+ )
149
+ sub = parser.add_subparsers(dest="subcommand", required=True)
150
+
151
+ # process subcommand
152
+ p = sub.add_parser("process", help="Read stdin or a file, evaluate rules, emit D-Bus signals")
153
+ p.add_argument("--profile", required=True, help="Path to log_monitor_profile.yml")
154
+ p.add_argument("--log-processor-id", required=True, help="Identifier for this processor")
155
+ p.add_argument("--dbus-address", required=True, help="D-Bus session bus address")
156
+ p.add_argument(
157
+ "--human-friendly-log-path", required=True,
158
+ help="Path to write human-readable log output",
159
+ )
160
+ p.add_argument(
161
+ "--timeout-factor", type=float, default=1.0, metavar="F",
162
+ help="Multiply all non-zero timeouts by F (default: 1.0)",
163
+ )
164
+ p.add_argument(
165
+ "--file", default=None, metavar="PATH",
166
+ help="Follow a log file instead of stdin (waits for the file to appear)",
167
+ )
168
+ p.add_argument(
169
+ "--retry-sleep", type=float, default=15.0, metavar="S",
170
+ help="Seconds between file-existence checks when --file is used (default: 15)",
171
+ )
172
+ p.add_argument(
173
+ "--retry-max", type=int, default=3, metavar="N",
174
+ help="Max existence-check cycles before giving up; 0 = wait forever (default: 3)",
175
+ )
176
+
177
+ # listen subcommand
178
+ ls = sub.add_parser("listen", help="Subscribe to D-Bus and write events.log")
179
+ ls.add_argument("--dbus-address", required=True, help="D-Bus session bus address")
180
+ ls.add_argument("--events-log", required=True, help="Path to events log file")
181
+
182
+ return parser
183
+
184
+
185
+ def main(argv=None) -> int:
186
+ parser = build_parser()
187
+ args = parser.parse_args(argv)
188
+ if args.subcommand == "process":
189
+ return _cmd_process(args)
190
+ if args.subcommand == "listen":
191
+ return _cmd_listen(args)
192
+ parser.print_help()
193
+ return 1
194
+
195
+
196
+ if __name__ == "__main__":
197
+ sys.exit(main())
@@ -0,0 +1,252 @@
1
+ # Scripts for automated Nextcloud setup.
2
+ # Copyright (C) 2026 Guillermo López Alejos
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Stateful rule evaluator for the log monitor engine.
18
+
19
+ The engine processes log lines one at a time. On each line (or on a
20
+ periodic tick) it:
21
+
22
+ 1. Tries to match the line against currently-active rules.
23
+ 2. Checks whether any active rule has exceeded its timeout.
24
+ 3. Emits events via a signal_fn callback for completed or timed-out nodes.
25
+
26
+ signal_fn signature::
27
+
28
+ signal_fn(event_name: str, status: str, time_to_complete: float) -> None
29
+
30
+ ``time_to_complete`` is the wall-clock seconds from when the node was
31
+ entered until the event fired.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ import time as _time
38
+ from abc import ABC, abstractmethod
39
+ from dataclasses import dataclass, field
40
+ from typing import Callable
41
+
42
+ from .profile import GroupRule, MessageRule, Profile, RuleNode
43
+
44
+ SignalFn = Callable[[str, str, float], None]
45
+
46
+ _TIMEOUT_STATUS = "ERROR" # fallback; prefer rule.timeout_status when available
47
+
48
+
49
+ # ── Node states ───────────────────────────────────────────────────────────────
50
+
51
+ class _NodeState(ABC):
52
+ def __init__(self, rule: RuleNode, entered_at: float) -> None:
53
+ self._rule = rule
54
+ self._entered_at = entered_at
55
+ self._done = False
56
+
57
+ @property
58
+ def done(self) -> bool:
59
+ return self._done
60
+
61
+ @abstractmethod
62
+ def check(self, content: str | None, now: float, signal_fn: SignalFn) -> None:
63
+ """Process one log line (or a tick if content is None)."""
64
+
65
+
66
+ class _MessageState(_NodeState):
67
+ def __init__(self, rule: MessageRule, entered_at: float) -> None:
68
+ super().__init__(rule, entered_at)
69
+ self._rule: MessageRule
70
+
71
+ def check(self, content: str | None, now: float, signal_fn: SignalFn) -> None:
72
+ if self._done:
73
+ return
74
+ # Timeout check first.
75
+ if self._rule.timeout > 0 and (now - self._entered_at) >= self._rule.timeout:
76
+ self._done = True
77
+ if self._rule.notify:
78
+ signal_fn(
79
+ self._rule.name,
80
+ getattr(self._rule, "timeout_status", _TIMEOUT_STATUS),
81
+ now - self._entered_at,
82
+ )
83
+ return
84
+ if content is not None and self._rule.pattern.search(content):
85
+ self._done = True
86
+ if self._rule.notify:
87
+ signal_fn(
88
+ self._rule.name,
89
+ self._rule.status,
90
+ now - self._entered_at,
91
+ )
92
+
93
+
94
+ class _GroupState(_NodeState):
95
+ def __init__(self, rule: GroupRule, entered_at: float) -> None:
96
+ super().__init__(rule, entered_at)
97
+ self._rule: GroupRule
98
+ self._children: list[_NodeState] = [
99
+ _make_state(child, entered_at) for child in rule.children
100
+ ]
101
+ # sequence: index of the currently-active child
102
+ self._seq_index: int = 0
103
+ # all_any_order: set of child indices already matched
104
+ self._matched_indices: set[int] = set()
105
+
106
+ def check(self, content: str | None, now: float, signal_fn: SignalFn) -> None:
107
+ if self._done:
108
+ return
109
+
110
+ # Group-level timeout.
111
+ if self._rule.timeout > 0 and (now - self._entered_at) >= self._rule.timeout:
112
+ self._done = True
113
+ if self._rule.notify:
114
+ signal_fn(
115
+ self._rule.name,
116
+ getattr(self._rule, "timeout_status", _TIMEOUT_STATUS),
117
+ now - self._entered_at,
118
+ )
119
+ return
120
+
121
+ if self._rule.constraint == "sequence":
122
+ self._check_sequence(content, now, signal_fn)
123
+ elif self._rule.constraint == "at_least_one":
124
+ self._check_at_least_one(content, now, signal_fn)
125
+ else: # all_any_order
126
+ self._check_all_any_order(content, now, signal_fn)
127
+
128
+ def _check_sequence(
129
+ self, content: str | None, now: float, signal_fn: SignalFn
130
+ ) -> None:
131
+ """Advance through children in order; each must match before the next starts."""
132
+ if self._seq_index >= len(self._children):
133
+ return
134
+
135
+ active = self._children[self._seq_index]
136
+ was_done = active.done
137
+ active.check(content, now, signal_fn)
138
+
139
+ if active.done and not was_done:
140
+ self._seq_index += 1
141
+ if self._seq_index < len(self._children):
142
+ # Reset the next child's clock to now.
143
+ self._children[self._seq_index] = _make_state(
144
+ self._rule.children[self._seq_index], now
145
+ )
146
+ else:
147
+ # All children matched.
148
+ self._done = True
149
+ if self._rule.notify:
150
+ signal_fn(
151
+ self._rule.name,
152
+ self._rule.status,
153
+ now - self._entered_at,
154
+ )
155
+
156
+ def _check_at_least_one(
157
+ self, content: str | None, now: float, signal_fn: SignalFn
158
+ ) -> None:
159
+ """Done when any single child matches."""
160
+ for child in self._children:
161
+ if child.done:
162
+ continue
163
+ was_done = child.done
164
+ child.check(content, now, signal_fn)
165
+ if child.done and not was_done:
166
+ self._done = True
167
+ if self._rule.notify:
168
+ signal_fn(
169
+ self._rule.name,
170
+ self._rule.status,
171
+ now - self._entered_at,
172
+ )
173
+ return
174
+
175
+ def _check_all_any_order(
176
+ self, content: str | None, now: float, signal_fn: SignalFn
177
+ ) -> None:
178
+ """Done when every child has matched (in any order)."""
179
+ for i, child in enumerate(self._children):
180
+ if i in self._matched_indices:
181
+ continue
182
+ was_done = child.done
183
+ child.check(content, now, signal_fn)
184
+ if child.done and not was_done:
185
+ self._matched_indices.add(i)
186
+
187
+ if len(self._matched_indices) == len(self._children):
188
+ self._done = True
189
+ if self._rule.notify:
190
+ signal_fn(
191
+ self._rule.name,
192
+ self._rule.status,
193
+ now - self._entered_at,
194
+ )
195
+
196
+
197
+ def _make_state(rule: RuleNode, entered_at: float) -> _NodeState:
198
+ if isinstance(rule, GroupRule):
199
+ return _GroupState(rule, entered_at)
200
+ return _MessageState(rule, entered_at)
201
+
202
+
203
+ # ── Log line parsers ──────────────────────────────────────────────────────────
204
+
205
+ # dmesg --follow default format: "[timestamp] content" or "content"
206
+ _DMESG_RE = re.compile(r"^\[\s*\d+\.\d+\]\s*(.*)$")
207
+ # journalctl default format: "MMM DD HH:MM:SS hostname proc[pid]: content"
208
+ _JOURNALCTL_RE = re.compile(
209
+ r"^\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\S+\s+[^:]+:\s*(.*)$"
210
+ )
211
+ # docker service logs --follow --timestamps: "YYYY-MM-DDTHH:MM:SS.nZ svc.N.id | content"
212
+ _DOCKER_RE = re.compile(r"^\S+\s+\S+\s*\|\s*(.*)$")
213
+
214
+
215
+ def _extract_content(line: str, fmt: str) -> str:
216
+ """Strip format-specific prefix from a log line; return the raw message content."""
217
+ line = line.rstrip("\n\r")
218
+ if fmt == "dmesg":
219
+ m = _DMESG_RE.match(line)
220
+ return m.group(1) if m else line
221
+ if fmt == "journalctl":
222
+ m = _JOURNALCTL_RE.match(line)
223
+ return m.group(1) if m else line
224
+ if fmt == "docker":
225
+ m = _DOCKER_RE.match(line)
226
+ return m.group(1) if m else line
227
+ return line
228
+
229
+
230
+ # ── Public engine ─────────────────────────────────────────────────────────────
231
+
232
+ class LogMonitorEngine:
233
+ """Feed log lines into the rule tree and emit events via signal_fn."""
234
+
235
+ def __init__(self, profile: Profile, signal_fn: SignalFn) -> None:
236
+ self._format = profile.format
237
+ self._signal_fn = signal_fn
238
+ self._root = _make_state(profile.root_rule, _time.monotonic())
239
+
240
+ @property
241
+ def done(self) -> bool:
242
+ """True when the root rule has fully matched or timed out."""
243
+ return self._root.done
244
+
245
+ def process_line(self, line: str) -> None:
246
+ """Feed one log line to the engine."""
247
+ content = _extract_content(line, self._format)
248
+ self._root.check(content, _time.monotonic(), self._signal_fn)
249
+
250
+ def tick(self) -> None:
251
+ """Check timeouts without a new log line (called on read timeout)."""
252
+ self._root.check(None, _time.monotonic(), self._signal_fn)