suitable-loop 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.
@@ -0,0 +1,663 @@
1
+ """Log and error analysis engine for CodeZero."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from suitable_loop.config import SuitableLoopConfig
14
+ from suitable_loop.db import Database
15
+ from suitable_loop.models import ErrorCodeLink, ErrorGroup, LogEntry
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Regex patterns for stdlib logging formats
21
+ # ---------------------------------------------------------------------------
22
+
23
+ # Patterns ordered from most specific to least specific.
24
+ _STDLIB_PATTERNS: list[re.Pattern[str]] = [
25
+ # 2024-01-15 10:30:45,123 - module - ERROR - message
26
+ re.compile(
27
+ r"^(?P<timestamp>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[,\.]\d{1,6})"
28
+ r"\s+-\s+(?P<logger>\S+)"
29
+ r"\s+-\s+(?P<level>[A-Z]+)"
30
+ r"\s+-\s+(?P<message>.+)$"
31
+ ),
32
+ # 2024-01-15 10:30:45,123 module ERROR message
33
+ re.compile(
34
+ r"^(?P<timestamp>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[,\.]\d{1,6})"
35
+ r"\s+(?P<logger>\S+)"
36
+ r"\s+(?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL)"
37
+ r"\s+(?P<message>.+)$"
38
+ ),
39
+ # [2024-01-15 10:30:45] ERROR module: message
40
+ re.compile(
41
+ r"^\[(?P<timestamp>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[,\.]?\d{0,6})\]"
42
+ r"\s+(?P<level>[A-Z]+)"
43
+ r"\s+(?P<logger>\S+?):\s+(?P<message>.+)$"
44
+ ),
45
+ # ERROR 2024-01-15 10:30:45 module - message
46
+ re.compile(
47
+ r"^(?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL)"
48
+ r"\s+(?P<timestamp>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[,\.]?\d{0,6})"
49
+ r"\s+(?P<logger>\S+)"
50
+ r"\s+-\s+(?P<message>.+)$"
51
+ ),
52
+ ]
53
+
54
+ # Traceback frame line: File "path", line N, in func
55
+ _FRAME_RE = re.compile(
56
+ r'^\s+File "(?P<path>[^"]+)",\s+line\s+(?P<lineno>\d+),\s+in\s+(?P<func>\S+)'
57
+ )
58
+
59
+ # Timestamp parsing formats (tried in order).
60
+ _TIMESTAMP_FORMATS = [
61
+ "%Y-%m-%d %H:%M:%S,%f",
62
+ "%Y-%m-%d %H:%M:%S.%f",
63
+ "%Y-%m-%d %H:%M:%S",
64
+ "%Y-%m-%dT%H:%M:%S,%f",
65
+ "%Y-%m-%dT%H:%M:%S.%f",
66
+ "%Y-%m-%dT%H:%M:%S",
67
+ ]
68
+
69
+
70
+ def _parse_timestamp(raw: str) -> float | None:
71
+ """Try to convert a timestamp string to a Unix epoch float."""
72
+ raw = raw.strip()
73
+ for fmt in _TIMESTAMP_FORMATS:
74
+ try:
75
+ from datetime import datetime, timezone
76
+
77
+ dt = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc)
78
+ return dt.timestamp()
79
+ except ValueError:
80
+ continue
81
+ return None
82
+
83
+
84
+ class LogAnalyzer:
85
+ """Ingests log files, groups errors, and maps stack frames to code."""
86
+
87
+ def __init__(self, db: Database, config: SuitableLoopConfig) -> None:
88
+ self.db = db
89
+ self.config = config
90
+
91
+ # ------------------------------------------------------------------
92
+ # Public API
93
+ # ------------------------------------------------------------------
94
+
95
+ def ingest_logs(self, path: str) -> dict:
96
+ """Ingest log files from *path* (file or directory).
97
+
98
+ Returns a summary dict with keys: entries_parsed, errors_found,
99
+ error_groups_new, error_groups_updated.
100
+ """
101
+ target = Path(path)
102
+ if target.is_dir():
103
+ files = sorted(
104
+ p
105
+ for p in target.iterdir()
106
+ if p.is_file() and p.suffix in (".log", ".txt")
107
+ )
108
+ elif target.is_file():
109
+ files = [target]
110
+ else:
111
+ logger.warning("Path does not exist or is not readable: %s", path)
112
+ return {
113
+ "entries_parsed": 0,
114
+ "errors_found": 0,
115
+ "error_groups_new": 0,
116
+ "error_groups_updated": 0,
117
+ }
118
+
119
+ max_entries = self.config.logging.max_entries_per_ingest
120
+ entries_parsed = 0
121
+ errors_found = 0
122
+ error_groups_new = 0
123
+ error_groups_updated = 0
124
+
125
+ for file_path in files:
126
+ if entries_parsed >= max_entries:
127
+ logger.info(
128
+ "Reached max_entries_per_ingest limit (%d). Stopping.",
129
+ max_entries,
130
+ )
131
+ break
132
+
133
+ logger.info("Ingesting %s", file_path)
134
+ try:
135
+ lines = file_path.read_text(errors="replace").splitlines()
136
+ except OSError:
137
+ logger.exception("Failed to read %s", file_path)
138
+ continue
139
+
140
+ # 1. Extract tracebacks so we can create error groups.
141
+ tracebacks = self._extract_tracebacks(lines)
142
+
143
+ # Build a lookup: line index -> traceback dict (keyed on the
144
+ # exception line index, i.e., the last line of each traceback).
145
+ tb_by_exc_line: dict[int, dict] = {}
146
+ for tb in tracebacks:
147
+ tb_by_exc_line[tb["end_line"]] = tb
148
+
149
+ # 2. Persist error groups and remember mapping from signature
150
+ # to group_id so we can link log entries later.
151
+ sig_to_group: dict[str, int] = {}
152
+ existing_sigs: set[str] = set()
153
+
154
+ # Pre-check which signatures already exist.
155
+ for tb in tracebacks:
156
+ row = self.db.conn.execute(
157
+ "SELECT id FROM error_groups WHERE signature = ?",
158
+ (tb["signature"],),
159
+ ).fetchone()
160
+ if row:
161
+ existing_sigs.add(tb["signature"])
162
+
163
+ for tb in tracebacks:
164
+ now = time.time()
165
+ eg = ErrorGroup(
166
+ signature=tb["signature"],
167
+ exception_type=tb["exception_type"],
168
+ exception_message=tb["exception_message"],
169
+ traceback=tb["raw"],
170
+ first_seen=now,
171
+ last_seen=now,
172
+ occurrence_count=1,
173
+ )
174
+ group_id = self.db.upsert_error_group(eg)
175
+ sig_to_group[tb["signature"]] = group_id
176
+ errors_found += 1
177
+
178
+ if tb["signature"] in existing_sigs:
179
+ error_groups_updated += 1
180
+ else:
181
+ error_groups_new += 1
182
+ existing_sigs.add(tb["signature"])
183
+
184
+ # Map frames to indexed code.
185
+ self._map_frames_to_code(tb["frames"], group_id)
186
+
187
+ # 3. Detect format and parse individual lines.
188
+ auto_detect = self.config.logging.auto_detect_format
189
+ detected_format: str | None = None
190
+
191
+ if auto_detect:
192
+ detected_format = self._detect_format(lines)
193
+
194
+ for line_idx, line in enumerate(lines):
195
+ if entries_parsed >= max_entries:
196
+ break
197
+
198
+ parsed = self._parse_line(line, detected_format)
199
+ if parsed is None:
200
+ continue
201
+
202
+ # Check if this line is the exception line of a traceback.
203
+ error_group_id: int | None = None
204
+ if line_idx in tb_by_exc_line:
205
+ tb = tb_by_exc_line[line_idx]
206
+ error_group_id = sig_to_group.get(tb["signature"])
207
+
208
+ entry = LogEntry(
209
+ source_file=str(file_path),
210
+ timestamp=parsed.get("timestamp"),
211
+ level=parsed.get("level", ""),
212
+ logger_name=parsed.get("logger", ""),
213
+ message=parsed.get("message", ""),
214
+ raw_line=line,
215
+ error_group_id=error_group_id,
216
+ )
217
+ self.db.insert_log_entry(entry)
218
+ entries_parsed += 1
219
+
220
+ self.db.commit()
221
+
222
+ return {
223
+ "entries_parsed": entries_parsed,
224
+ "errors_found": errors_found,
225
+ "error_groups_new": error_groups_new,
226
+ "error_groups_updated": error_groups_updated,
227
+ }
228
+
229
+ def get_error_groups(self, limit: int = 20) -> list[dict]:
230
+ """Return error groups sorted by frequency, enriched with code links."""
231
+ groups = self.db.get_error_groups(limit=limit)
232
+ results: list[dict] = []
233
+ for eg in groups:
234
+ links = self.db.get_error_code_links(eg.id) # type: ignore[arg-type]
235
+ results.append(
236
+ {
237
+ "id": eg.id,
238
+ "signature": eg.signature,
239
+ "exception_type": eg.exception_type,
240
+ "exception_message": eg.exception_message,
241
+ "first_seen": eg.first_seen,
242
+ "last_seen": eg.last_seen,
243
+ "occurrence_count": eg.occurrence_count,
244
+ "code_links": links,
245
+ }
246
+ )
247
+ return results
248
+
249
+ def error_detail(self, error_group_id: int) -> dict | None:
250
+ """Full detail for a single error group."""
251
+ eg = self.db.get_error_group_by_id(error_group_id)
252
+ if eg is None:
253
+ return None
254
+
255
+ code_links = self.db.get_error_code_links(error_group_id)
256
+
257
+ # Affected functions (from code links).
258
+ affected_functions: list[dict] = []
259
+ for link in code_links:
260
+ func_id = link.get("function_id")
261
+ if func_id is not None:
262
+ func = self.db.get_function_by_id(func_id)
263
+ if func:
264
+ affected_functions.append(
265
+ {
266
+ "function_id": func.id,
267
+ "qualified_name": func.qualified_name,
268
+ "file_id": func.file_id,
269
+ "line_start": func.line_start,
270
+ "line_end": func.line_end,
271
+ "frame_position": link.get("frame_position"),
272
+ }
273
+ )
274
+
275
+ # Sample log entries.
276
+ rows = self.db.conn.execute(
277
+ "SELECT * FROM log_entries WHERE error_group_id = ? ORDER BY timestamp DESC LIMIT 10",
278
+ (error_group_id,),
279
+ ).fetchall()
280
+ sample_entries = [dict(r) for r in rows]
281
+
282
+ return {
283
+ "id": eg.id,
284
+ "signature": eg.signature,
285
+ "exception_type": eg.exception_type,
286
+ "exception_message": eg.exception_message,
287
+ "traceback": eg.traceback,
288
+ "first_seen": eg.first_seen,
289
+ "last_seen": eg.last_seen,
290
+ "occurrence_count": eg.occurrence_count,
291
+ "code_links": code_links,
292
+ "affected_functions": affected_functions,
293
+ "sample_entries": sample_entries,
294
+ }
295
+
296
+ def correlate_error(self, error_text: str) -> dict:
297
+ """Parse raw error text and find matching error groups / code paths."""
298
+ lines = error_text.splitlines()
299
+ tracebacks = self._extract_tracebacks(lines)
300
+
301
+ matched_groups: list[dict] = []
302
+ mapped_frames: list[dict] = []
303
+
304
+ for tb in tracebacks:
305
+ # Look for an existing error group with the same signature.
306
+ row = self.db.conn.execute(
307
+ "SELECT * FROM error_groups WHERE signature = ?",
308
+ (tb["signature"],),
309
+ ).fetchone()
310
+ if row:
311
+ eg = ErrorGroup(**dict(row))
312
+ links = self.db.get_error_code_links(eg.id) # type: ignore[arg-type]
313
+ matched_groups.append(
314
+ {
315
+ "id": eg.id,
316
+ "signature": eg.signature,
317
+ "exception_type": eg.exception_type,
318
+ "exception_message": eg.exception_message,
319
+ "occurrence_count": eg.occurrence_count,
320
+ "code_links": links,
321
+ }
322
+ )
323
+ else:
324
+ # No existing group -- try to map frames to code anyway.
325
+ for pos, (fpath, lineno, func_name) in enumerate(tb["frames"]):
326
+ match = self._find_function_for_frame(fpath, lineno)
327
+ if match:
328
+ file_entity, func_entity = match
329
+ mapped_frames.append(
330
+ {
331
+ "frame_position": pos,
332
+ "file_path": fpath,
333
+ "line_number": lineno,
334
+ "frame_function": func_name,
335
+ "matched_function": func_entity.qualified_name,
336
+ "function_id": func_entity.id,
337
+ "file_id": file_entity.id,
338
+ }
339
+ )
340
+
341
+ # If no tracebacks found, try a simple text search against existing
342
+ # error messages.
343
+ if not tracebacks:
344
+ search_text = error_text.strip()[:200]
345
+ rows = self.db.conn.execute(
346
+ "SELECT * FROM error_groups WHERE exception_message LIKE ? "
347
+ "ORDER BY occurrence_count DESC LIMIT 5",
348
+ (f"%{search_text}%",),
349
+ ).fetchall()
350
+ for row in rows:
351
+ eg = ErrorGroup(**dict(row))
352
+ links = self.db.get_error_code_links(eg.id) # type: ignore[arg-type]
353
+ matched_groups.append(
354
+ {
355
+ "id": eg.id,
356
+ "signature": eg.signature,
357
+ "exception_type": eg.exception_type,
358
+ "exception_message": eg.exception_message,
359
+ "occurrence_count": eg.occurrence_count,
360
+ "code_links": links,
361
+ }
362
+ )
363
+
364
+ return {
365
+ "tracebacks_parsed": len(tracebacks),
366
+ "matched_groups": matched_groups,
367
+ "unmapped_frame_matches": mapped_frames,
368
+ }
369
+
370
+ def error_timeline(self, days: int = 7) -> list[dict]:
371
+ """Return error counts grouped by day and error type for the last *days* days."""
372
+ cutoff = time.time() - days * 86400
373
+ rows = self.db.conn.execute(
374
+ """
375
+ SELECT
376
+ date(le.timestamp, 'unixepoch') AS day,
377
+ eg.exception_type,
378
+ eg.id AS error_group_id,
379
+ COUNT(*) AS count
380
+ FROM log_entries le
381
+ JOIN error_groups eg ON le.error_group_id = eg.id
382
+ WHERE le.timestamp >= ?
383
+ GROUP BY day, eg.id
384
+ ORDER BY day, count DESC
385
+ """,
386
+ (cutoff,),
387
+ ).fetchall()
388
+ return [dict(r) for r in rows]
389
+
390
+ # ------------------------------------------------------------------
391
+ # Private helpers
392
+ # ------------------------------------------------------------------
393
+
394
+ def _parse_stdlib_line(self, line: str) -> dict | None:
395
+ """Parse a stdlib-style log line and return a dict or *None*."""
396
+ for pattern in _STDLIB_PATTERNS:
397
+ m = pattern.match(line)
398
+ if m:
399
+ groups = m.groupdict()
400
+ ts = _parse_timestamp(groups["timestamp"])
401
+ return {
402
+ "timestamp": ts,
403
+ "level": groups["level"].upper(),
404
+ "logger": groups.get("logger", ""),
405
+ "message": groups["message"],
406
+ }
407
+ return None
408
+
409
+ def _parse_json_line(self, line: str) -> dict | None:
410
+ """Parse a JSON log line and return a normalised dict or *None*."""
411
+ stripped = line.strip()
412
+ if not stripped.startswith("{"):
413
+ return None
414
+ try:
415
+ obj = json.loads(stripped)
416
+ except (json.JSONDecodeError, ValueError):
417
+ return None
418
+
419
+ if not isinstance(obj, dict):
420
+ return None
421
+
422
+ # Normalise common key names.
423
+ level = (
424
+ obj.get("level")
425
+ or obj.get("levelname")
426
+ or obj.get("severity")
427
+ or ""
428
+ )
429
+ message = (
430
+ obj.get("message")
431
+ or obj.get("msg")
432
+ or obj.get("text")
433
+ or ""
434
+ )
435
+ logger_name = (
436
+ obj.get("logger")
437
+ or obj.get("name")
438
+ or obj.get("logger_name")
439
+ or ""
440
+ )
441
+ raw_ts = obj.get("timestamp") or obj.get("time") or obj.get("ts")
442
+ ts: float | None = None
443
+ if isinstance(raw_ts, (int, float)):
444
+ ts = float(raw_ts)
445
+ elif isinstance(raw_ts, str):
446
+ ts = _parse_timestamp(raw_ts)
447
+
448
+ return {
449
+ "timestamp": ts,
450
+ "level": str(level).upper(),
451
+ "logger": str(logger_name),
452
+ "message": str(message),
453
+ }
454
+
455
+ def _extract_tracebacks(self, lines: list[str]) -> list[dict]:
456
+ """Extract Python traceback blocks from *lines*.
457
+
458
+ Each returned dict contains:
459
+ - start_line / end_line: indices in *lines*
460
+ - raw: the full traceback text
461
+ - exception_type, exception_message: parsed from the last line
462
+ - frames: list of (file_path, line_number, function_name) tuples
463
+ - signature: dedup hash
464
+ """
465
+ tracebacks: list[dict] = []
466
+ i = 0
467
+ while i < len(lines):
468
+ if lines[i].strip() == "Traceback (most recent call last):":
469
+ start = i
470
+ frames: list[tuple[str, int, str]] = []
471
+ i += 1
472
+
473
+ # Consume frame lines and code lines.
474
+ while i < len(lines):
475
+ frame_match = _FRAME_RE.match(lines[i])
476
+ if frame_match:
477
+ frames.append(
478
+ (
479
+ frame_match.group("path"),
480
+ int(frame_match.group("lineno")),
481
+ frame_match.group("func"),
482
+ )
483
+ )
484
+ i += 1
485
+ # Skip the source-code line that follows the frame.
486
+ if i < len(lines) and lines[i].startswith(" "):
487
+ i += 1
488
+ continue
489
+
490
+ # A line starting with whitespace that is not a frame is
491
+ # still part of the traceback (e.g., chained exceptions or
492
+ # "During handling..." blocks). But a non-indented line is
493
+ # the exception line (or end of block).
494
+ if lines[i] and not lines[i][0].isspace():
495
+ break
496
+ i += 1
497
+
498
+ # The current line should be the exception line.
499
+ if i < len(lines):
500
+ exc_line = lines[i].strip()
501
+ end = i
502
+ else:
503
+ exc_line = ""
504
+ end = i - 1
505
+
506
+ exc_type, _, exc_msg = exc_line.partition(":")
507
+ exc_type = exc_type.strip()
508
+ exc_msg = exc_msg.strip()
509
+
510
+ raw = "\n".join(lines[start : end + 1])
511
+ signature = self._compute_error_signature(exc_type, frames)
512
+
513
+ tracebacks.append(
514
+ {
515
+ "start_line": start,
516
+ "end_line": end,
517
+ "raw": raw,
518
+ "exception_type": exc_type,
519
+ "exception_message": exc_msg,
520
+ "frames": frames,
521
+ "signature": signature,
522
+ }
523
+ )
524
+ i += 1
525
+
526
+ return tracebacks
527
+
528
+ def _compute_error_signature(
529
+ self, exception_type: str, frames: list[tuple[str, int, str]]
530
+ ) -> str:
531
+ """Return a hex-digest hash that uniquely identifies this error shape.
532
+
533
+ The signature is derived from the exception type and the top 3 stack
534
+ frames (file path, line number, function name) so that identical
535
+ tracebacks always map to the same error group.
536
+ """
537
+ top_frames = frames[-3:] if len(frames) > 3 else frames
538
+ key_parts = [exception_type]
539
+ for fpath, lineno, func_name in top_frames:
540
+ key_parts.append(f"{fpath}:{lineno}:{func_name}")
541
+ key = "|".join(key_parts)
542
+ return hashlib.sha256(key.encode()).hexdigest()
543
+
544
+ def _map_frames_to_code(
545
+ self, frames: list[tuple[str, int, str]], error_group_id: int
546
+ ) -> None:
547
+ """Try to match each stack frame to an indexed function and persist links."""
548
+ # Avoid creating duplicate links if we re-ingest.
549
+ existing = self.db.get_error_code_links(error_group_id)
550
+ existing_positions: set[int] = {
551
+ link.get("frame_position", -1) for link in existing
552
+ }
553
+
554
+ for pos, (fpath, lineno, _func_name) in enumerate(frames):
555
+ if pos in existing_positions:
556
+ continue
557
+
558
+ match = self._find_function_for_frame(fpath, lineno)
559
+ if match:
560
+ file_entity, func_entity = match
561
+ link = ErrorCodeLink(
562
+ error_group_id=error_group_id,
563
+ function_id=func_entity.id,
564
+ file_id=file_entity.id,
565
+ line_number=lineno,
566
+ frame_position=pos,
567
+ )
568
+ self.db.insert_error_code_link(link)
569
+ else:
570
+ # Even without a function match, try to link by file alone.
571
+ file_entity = self._find_file_for_frame(fpath)
572
+ if file_entity:
573
+ link = ErrorCodeLink(
574
+ error_group_id=error_group_id,
575
+ function_id=None,
576
+ file_id=file_entity.id,
577
+ line_number=lineno,
578
+ frame_position=pos,
579
+ )
580
+ self.db.insert_error_code_link(link)
581
+
582
+ self.db.commit()
583
+
584
+ # ------------------------------------------------------------------
585
+ # Internal utilities
586
+ # ------------------------------------------------------------------
587
+
588
+ def _detect_format(self, lines: list[str]) -> str | None:
589
+ """Sample the first non-empty lines to guess the log format.
590
+
591
+ Returns ``"json"``, ``"stdlib"``, or ``None``.
592
+ """
593
+ sample_count = 0
594
+ json_hits = 0
595
+ stdlib_hits = 0
596
+
597
+ for line in lines:
598
+ stripped = line.strip()
599
+ if not stripped:
600
+ continue
601
+ if self._parse_json_line(stripped) is not None:
602
+ json_hits += 1
603
+ elif self._parse_stdlib_line(stripped) is not None:
604
+ stdlib_hits += 1
605
+ sample_count += 1
606
+ if sample_count >= 20:
607
+ break
608
+
609
+ if json_hits > stdlib_hits and json_hits > 0:
610
+ return "json"
611
+ if stdlib_hits > 0:
612
+ return "stdlib"
613
+ return None
614
+
615
+ def _parse_line(self, line: str, detected_format: str | None) -> dict | None:
616
+ """Parse a single log line using the detected (or both) format(s)."""
617
+ stripped = line.strip()
618
+ if not stripped:
619
+ return None
620
+
621
+ if detected_format == "json":
622
+ return self._parse_json_line(stripped)
623
+ if detected_format == "stdlib":
624
+ return self._parse_stdlib_line(stripped)
625
+
626
+ # Fallback: try both.
627
+ result = self._parse_json_line(stripped)
628
+ if result is not None:
629
+ return result
630
+ return self._parse_stdlib_line(stripped)
631
+
632
+ def _find_function_for_frame(self, fpath: str, lineno: int):
633
+ """Return ``(FileEntity, FunctionEntity)`` or *None*."""
634
+ file_entity = self._find_file_for_frame(fpath)
635
+ if file_entity is None:
636
+ return None
637
+
638
+ # Find the function whose line range contains *lineno*.
639
+ funcs = self.db.get_functions_by_file(file_entity.id) # type: ignore[arg-type]
640
+ for func in funcs:
641
+ if func.line_start <= lineno <= func.line_end:
642
+ return file_entity, func
643
+ return None
644
+
645
+ def _find_file_for_frame(self, fpath: str):
646
+ """Try to find an indexed ``FileEntity`` matching *fpath*."""
647
+ # Try exact match first.
648
+ fe = self.db.get_file_by_path(fpath)
649
+ if fe:
650
+ return fe
651
+
652
+ # Try matching by filename suffix (the traceback path might be
653
+ # absolute while the indexed path is relative, or vice-versa).
654
+ basename = os.path.basename(fpath)
655
+ rows = self.db.conn.execute(
656
+ "SELECT * FROM files WHERE path LIKE ? LIMIT 1",
657
+ (f"%/{basename}",),
658
+ ).fetchall()
659
+ if rows:
660
+ from suitable_loop.models import FileEntity
661
+
662
+ return FileEntity(**dict(rows[0]))
663
+ return None