0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
521 lines
19 KiB
Python
521 lines
19 KiB
Python
"""Rust-backed log/build-output compressor.
|
|
|
|
Phase 3e.5 ported the implementation to
|
|
`crates/headroom-core/src/transforms/log_compressor.rs`. This module
|
|
is now a thin shim that:
|
|
|
|
1. Keeps the public dataclass and enum surface (`LogLevel`,
|
|
`LogFormat`, `LogLine`, `LogCompressorConfig`,
|
|
`LogCompressionResult`) so existing call sites (`ContentRouter`,
|
|
tests) don't change.
|
|
2. Routes `LogCompressor.compress()` entirely through the Rust
|
|
implementation, picking up the bug fixes (chained-exception trace
|
|
survival, conservative warning dedupe, loud CCR failures).
|
|
3. Implements legacy internal helpers (`_detect_format`, `_parse_lines`,
|
|
`_score_line`, `_select_lines`, `_select_with_first_last`,
|
|
`_dedupe_similar`, `_format_output`) on top of the Rust building
|
|
blocks where a Rust delegation makes sense; otherwise keeps Python
|
|
logic that mirrors Rust scoring.
|
|
|
|
# Bug fixes the Rust port carries (and this shim therefore inherits)
|
|
|
|
* **Stack-trace state machine.** Pre-3e.5 Python terminated on any
|
|
blank line, dropping mid-trace lines from chained-exception traces.
|
|
Rust dispatches per language flavor so blank lines stay inside
|
|
Python tracebacks.
|
|
* **Conservative dedupe.** Pre-3e.5 normalised digits/paths/hex
|
|
globally, collapsing distinct error categories that shared a
|
|
trailing variable shape. Rust splits on the first `:`/`=` and only
|
|
normalises the trailing region — message identifiers stay distinct.
|
|
* **Loud CCR failures.** Storage failures are logged at warning level
|
|
instead of being silently swallowed.
|
|
* **`LogLevel.FAIL` is documented as cosmetic-equivalent to
|
|
`LogLevel.ERROR`.** Both score 1.0 in Python and Rust.
|
|
|
|
# CCR plumbing note
|
|
|
|
Same pattern as search_compressor: Rust emits a `cache_key`, the
|
|
Python shim writes the original to the production
|
|
`CompressionStore`. The Rust crate's CCR store is in-memory and
|
|
exists only for unit testing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Any, cast
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LogFormat(Enum):
|
|
"""Detected log format."""
|
|
|
|
PYTEST = "pytest"
|
|
NPM = "npm"
|
|
CARGO = "cargo"
|
|
MAKE = "make"
|
|
JEST = "jest"
|
|
GENERIC = "generic"
|
|
|
|
|
|
class LogLevel(Enum):
|
|
"""Log level for categorization."""
|
|
|
|
ERROR = "error"
|
|
FAIL = "fail"
|
|
WARN = "warn"
|
|
INFO = "info"
|
|
DEBUG = "debug"
|
|
TRACE = "trace"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
@dataclass(eq=False)
|
|
class LogLine:
|
|
"""A single log line with metadata."""
|
|
|
|
line_number: int
|
|
content: str
|
|
level: LogLevel = LogLevel.UNKNOWN
|
|
is_stack_trace: bool = False
|
|
is_summary: bool = False
|
|
score: float = 0.0
|
|
|
|
def __eq__(self, other: object) -> bool:
|
|
if not isinstance(other, LogLine):
|
|
return NotImplemented
|
|
return self.line_number == other.line_number
|
|
|
|
def __hash__(self) -> int:
|
|
return hash(self.line_number)
|
|
|
|
|
|
@dataclass
|
|
class LogCompressorConfig:
|
|
"""Configuration for log compression."""
|
|
|
|
max_errors: int = 10
|
|
error_context_lines: int = 3
|
|
keep_first_error: bool = True
|
|
keep_last_error: bool = True
|
|
max_stack_traces: int = 3
|
|
stack_trace_max_lines: int = 20
|
|
max_warnings: int = 5
|
|
dedupe_warnings: bool = True
|
|
keep_summary_lines: bool = True
|
|
max_total_lines: int = 100
|
|
enable_ccr: bool = True
|
|
min_lines_for_ccr: int = 50
|
|
|
|
|
|
@dataclass
|
|
class LogCompressionResult:
|
|
"""Result of log compression."""
|
|
|
|
compressed: str
|
|
original: str
|
|
original_line_count: int
|
|
compressed_line_count: int
|
|
format_detected: LogFormat
|
|
compression_ratio: float
|
|
cache_key: str | None = None
|
|
stats: dict[str, int] = field(default_factory=dict)
|
|
|
|
@property
|
|
def tokens_saved_estimate(self) -> int:
|
|
chars_saved = len(self.original) - len(self.compressed)
|
|
return max(0, chars_saved // 4)
|
|
|
|
@property
|
|
def lines_omitted(self) -> int:
|
|
return self.original_line_count - self.compressed_line_count
|
|
|
|
|
|
# ─── LogCompressor (Rust-backed) ────────────────────────────────────────────
|
|
|
|
|
|
def _format_from_str(name: str) -> LogFormat:
|
|
return {
|
|
"pytest": LogFormat.PYTEST,
|
|
"npm": LogFormat.NPM,
|
|
"cargo": LogFormat.CARGO,
|
|
"make": LogFormat.MAKE,
|
|
"jest": LogFormat.JEST,
|
|
}.get(name, LogFormat.GENERIC)
|
|
|
|
|
|
class LogCompressor:
|
|
"""Rust-backed log compressor.
|
|
|
|
Drop-in replacement for the retired Python class. `compress()`
|
|
delegates to Rust end-to-end; internal helpers used by the
|
|
existing test surface keep working but route through the same
|
|
Rust building blocks where they exist.
|
|
"""
|
|
|
|
def __init__(self, config: LogCompressorConfig | None = None) -> None:
|
|
# Hard import — no fallback. If the wheel is missing, the user
|
|
# must build it. See feedback memory `feedback_no_silent_fallbacks.md`.
|
|
from headroom._core import (
|
|
LogCompressor as _RustLogCompressor,
|
|
)
|
|
from headroom._core import (
|
|
LogCompressorConfig as _RustLogCompressorConfig,
|
|
)
|
|
|
|
cfg = config or LogCompressorConfig()
|
|
self.config = cfg
|
|
# `min_compression_ratio_for_ccr` was inlined as 0.5 in Python;
|
|
# the Rust port promoted it to a config field but defaults
|
|
# match.
|
|
self._rust = _RustLogCompressor(
|
|
_RustLogCompressorConfig(
|
|
max_errors=cfg.max_errors,
|
|
error_context_lines=cfg.error_context_lines,
|
|
keep_first_error=cfg.keep_first_error,
|
|
keep_last_error=cfg.keep_last_error,
|
|
max_stack_traces=cfg.max_stack_traces,
|
|
stack_trace_max_lines=cfg.stack_trace_max_lines,
|
|
max_warnings=cfg.max_warnings,
|
|
dedupe_warnings=cfg.dedupe_warnings,
|
|
keep_summary_lines=cfg.keep_summary_lines,
|
|
max_total_lines=cfg.max_total_lines,
|
|
enable_ccr=cfg.enable_ccr,
|
|
min_lines_for_ccr=cfg.min_lines_for_ccr,
|
|
min_compression_ratio_for_ccr=0.5,
|
|
)
|
|
)
|
|
|
|
# ─── Public API ─────────────────────────────────────────────────────
|
|
|
|
def compress(self, content: str, context: str = "", bias: float = 1.0) -> LogCompressionResult:
|
|
# `context` is unused upstream and unused here (Python original
|
|
# also didn't use it). Kept in the signature for drop-in compat.
|
|
del context
|
|
rust_result = self._rust.compress(content, bias)
|
|
cache_key: str | None = rust_result.cache_key
|
|
if cache_key is not None:
|
|
self._persist_to_python_ccr(content, rust_result.compressed, cache_key)
|
|
|
|
stats_dict = {k: int(v) for k, v in cast("dict[str, int]", rust_result.stats).items()}
|
|
return LogCompressionResult(
|
|
compressed=rust_result.compressed,
|
|
original=content,
|
|
original_line_count=rust_result.original_line_count,
|
|
compressed_line_count=rust_result.compressed_line_count,
|
|
format_detected=_format_from_str(rust_result.format_detected),
|
|
compression_ratio=rust_result.compression_ratio,
|
|
cache_key=cache_key,
|
|
stats=stats_dict,
|
|
)
|
|
|
|
# ─── Legacy internal helpers (test surface compat) ──────────────────
|
|
|
|
def _detect_format(self, lines: list[str]) -> LogFormat:
|
|
"""Delegate to the Rust format detector."""
|
|
from headroom._core import detect_log_format
|
|
|
|
return _format_from_str(detect_log_format(list(lines)))
|
|
|
|
def _parse_lines(self, lines: list[str]) -> list[LogLine]:
|
|
"""Parse + categorize lines, mirroring Rust's classification.
|
|
|
|
Stays Python so the legacy direct-call test surface keeps
|
|
working without rebuilding through Rust on every test. Rust
|
|
unit tests pin Rust's behavior; this implementation must
|
|
mirror Rust's level/stack-trace/summary classification rules.
|
|
"""
|
|
import re
|
|
|
|
# Mirror of Rust's level classifier: aho-corasick with
|
|
# word-boundary post-filter. Python's `re` is fast enough for
|
|
# the test path; the Rust path uses aho-corasick. Both share
|
|
# the same keyword set.
|
|
level_patterns = [
|
|
(
|
|
LogLevel.ERROR,
|
|
re.compile(r"\b(?:ERROR|error|Error|FATAL|fatal|Fatal|CRITICAL|critical)\b"),
|
|
),
|
|
(LogLevel.FAIL, re.compile(r"\b(?:FAIL|FAILED|fail|failed|Fail|Failed)\b")),
|
|
(LogLevel.WARN, re.compile(r"\b(?:WARN|WARNING|warn|warning|Warn|Warning)\b")),
|
|
(LogLevel.INFO, re.compile(r"\b(?:INFO|info|Info)\b")),
|
|
(LogLevel.DEBUG, re.compile(r"\b(?:DEBUG|debug|Debug)\b")),
|
|
(LogLevel.TRACE, re.compile(r"\b(?:TRACE|trace|Trace)\b")),
|
|
]
|
|
stack_trace_patterns = [
|
|
re.compile(r"^\s*Traceback \(most recent call last\)"),
|
|
re.compile(r'^\s*File ".+", line \d+'),
|
|
re.compile(r"^\s*at .+\(.+:\d+:\d+\)"),
|
|
re.compile(r"^\s+at [\w.$]+\("),
|
|
re.compile(r"^\s*--> .+:\d+:\d+"),
|
|
re.compile(r"^\s*\d+:\s+0x[0-9a-f]+"),
|
|
]
|
|
summary_patterns = [
|
|
re.compile(r"^={3,}"),
|
|
re.compile(r"^-{3,}"),
|
|
re.compile(r"^\d+ (passed|failed|skipped|error|warning)"),
|
|
re.compile(r"^(?:Tests?|Suites?):?\s+\d+"),
|
|
re.compile(r"^(?:TOTAL|Total|Summary)"),
|
|
re.compile(r"^(?:Build|Compile|Test).*(?:succeeded|failed|complete)"),
|
|
]
|
|
|
|
log_lines: list[LogLine] = []
|
|
in_stack_trace = False
|
|
stack_trace_lines = 0
|
|
|
|
for i, line in enumerate(lines):
|
|
log_line = LogLine(line_number=i, content=line)
|
|
|
|
for level, pattern in level_patterns:
|
|
if pattern.search(line):
|
|
log_line.level = level
|
|
break
|
|
|
|
for pattern in stack_trace_patterns:
|
|
if pattern.search(line):
|
|
in_stack_trace = True
|
|
stack_trace_lines = 0
|
|
break
|
|
|
|
if in_stack_trace:
|
|
log_line.is_stack_trace = True
|
|
stack_trace_lines += 1
|
|
if stack_trace_lines > self.config.stack_trace_max_lines or not line.strip():
|
|
in_stack_trace = False
|
|
|
|
for pattern in summary_patterns:
|
|
if pattern.search(line):
|
|
log_line.is_summary = True
|
|
break
|
|
|
|
log_line.score = self._score_line(log_line)
|
|
log_lines.append(log_line)
|
|
|
|
return log_lines
|
|
|
|
def _score_line(self, log_line: LogLine) -> float:
|
|
"""Per-line importance scoring."""
|
|
level_scores = {
|
|
LogLevel.ERROR: 1.0,
|
|
LogLevel.FAIL: 1.0,
|
|
LogLevel.WARN: 0.5,
|
|
LogLevel.INFO: 0.1,
|
|
LogLevel.DEBUG: 0.05,
|
|
LogLevel.TRACE: 0.02,
|
|
LogLevel.UNKNOWN: 0.1,
|
|
}
|
|
score = level_scores.get(log_line.level, 0.1)
|
|
if log_line.is_stack_trace:
|
|
score += 0.3
|
|
if log_line.is_summary:
|
|
score += 0.4
|
|
return min(1.0, score)
|
|
|
|
def _select_lines(self, log_lines: list[LogLine], bias: float = 1.0) -> list[LogLine]:
|
|
"""Select important lines using the same algorithm Rust uses."""
|
|
from headroom.transforms.adaptive_sizer import compute_optimal_k
|
|
|
|
all_strings = [line.content for line in log_lines]
|
|
adaptive_max = compute_optimal_k(
|
|
all_strings, bias=bias, min_k=10, max_k=self.config.max_total_lines
|
|
)
|
|
|
|
errors: list[LogLine] = []
|
|
fails: list[LogLine] = []
|
|
warnings: list[LogLine] = []
|
|
stack_traces: list[list[LogLine]] = []
|
|
summaries: list[LogLine] = []
|
|
current_stack: list[LogLine] = []
|
|
|
|
for log_line in log_lines:
|
|
if log_line.level == LogLevel.ERROR:
|
|
errors.append(log_line)
|
|
elif log_line.level == LogLevel.FAIL:
|
|
fails.append(log_line)
|
|
elif log_line.level == LogLevel.WARN:
|
|
warnings.append(log_line)
|
|
if log_line.is_stack_trace:
|
|
current_stack.append(log_line)
|
|
elif current_stack:
|
|
stack_traces.append(current_stack)
|
|
current_stack = []
|
|
if log_line.is_summary:
|
|
summaries.append(log_line)
|
|
if current_stack:
|
|
stack_traces.append(current_stack)
|
|
|
|
selected: list[LogLine] = []
|
|
if errors:
|
|
selected.extend(self._select_with_first_last(errors, self.config.max_errors))
|
|
if fails:
|
|
selected.extend(self._select_with_first_last(fails, self.config.max_errors))
|
|
if warnings:
|
|
if self.config.dedupe_warnings:
|
|
warnings = self._dedupe_similar(warnings)
|
|
selected.extend(warnings[: self.config.max_warnings])
|
|
for stack in stack_traces[: self.config.max_stack_traces]:
|
|
selected.extend(stack[: self.config.stack_trace_max_lines])
|
|
if self.config.keep_summary_lines:
|
|
selected.extend(summaries)
|
|
|
|
selected = self._add_context(log_lines, selected)
|
|
selected = sorted(set(selected), key=lambda x: x.line_number)
|
|
|
|
if len(selected) > adaptive_max:
|
|
selected = sorted(selected, key=lambda x: x.score, reverse=True)
|
|
selected = selected[:adaptive_max]
|
|
selected = sorted(selected, key=lambda x: x.line_number)
|
|
|
|
return selected
|
|
|
|
def _select_with_first_last(self, lines: list[LogLine], max_count: int) -> list[LogLine]:
|
|
if len(lines) <= max_count:
|
|
return lines
|
|
|
|
selected: list[LogLine] = []
|
|
if self.config.keep_first_error and lines:
|
|
selected.append(lines[0])
|
|
if self.config.keep_last_error and lines and lines[-1] not in selected:
|
|
selected.append(lines[-1])
|
|
|
|
remaining = max_count - len(selected)
|
|
if remaining > 0:
|
|
candidates = sorted(
|
|
(line for line in lines if line not in selected),
|
|
key=lambda x: x.score,
|
|
reverse=True,
|
|
)
|
|
selected.extend(candidates[:remaining])
|
|
|
|
return selected
|
|
|
|
def _dedupe_similar(self, lines: list[LogLine]) -> list[LogLine]:
|
|
"""Conservative dedupe — preserves message prefix, only
|
|
normalises trailing variable region (digits, hex, paths).
|
|
Mirrors Rust `normalize_for_dedupe`."""
|
|
import re
|
|
|
|
seen: set[str] = set()
|
|
deduped: list[LogLine] = []
|
|
digit_re = re.compile(r"\d+")
|
|
hex_re = re.compile(r"0x[0-9a-fA-F]+")
|
|
path_re = re.compile(r"/[\w/]+/")
|
|
|
|
for line in lines:
|
|
content = line.content
|
|
split_at = next((i for i, c in enumerate(content) if c in (":", "=")), len(content))
|
|
prefix = content[:split_at]
|
|
suffix = content[split_at:]
|
|
suffix = digit_re.sub("N", suffix)
|
|
suffix = hex_re.sub("ADDR", suffix)
|
|
suffix = path_re.sub("/PATH/", suffix)
|
|
normalized = prefix + suffix
|
|
if normalized not in seen:
|
|
seen.add(normalized)
|
|
deduped.append(line)
|
|
return deduped
|
|
|
|
def _add_context(self, all_lines: list[LogLine], selected: list[LogLine]) -> list[LogLine]:
|
|
selected_indices = {line.line_number for line in selected}
|
|
context_indices: set[int] = set()
|
|
for idx in selected_indices:
|
|
for i in range(max(0, idx - self.config.error_context_lines), idx):
|
|
context_indices.add(i)
|
|
for i in range(
|
|
idx + 1,
|
|
min(len(all_lines), idx + self.config.error_context_lines + 1),
|
|
):
|
|
context_indices.add(i)
|
|
for idx in context_indices:
|
|
if idx not in selected_indices and idx < len(all_lines):
|
|
selected.append(all_lines[idx])
|
|
return selected
|
|
|
|
def _format_output(
|
|
self, selected: list[LogLine], all_lines: list[LogLine]
|
|
) -> tuple[str, dict[str, int]]:
|
|
stats: dict[str, int] = {
|
|
"errors": sum(1 for line in all_lines if line.level == LogLevel.ERROR),
|
|
"fails": sum(1 for line in all_lines if line.level == LogLevel.FAIL),
|
|
"warnings": sum(1 for line in all_lines if line.level == LogLevel.WARN),
|
|
"info": sum(1 for line in all_lines if line.level == LogLevel.INFO),
|
|
"total": len(all_lines),
|
|
"selected": len(selected),
|
|
}
|
|
output_lines = [line.content for line in selected]
|
|
omitted = len(all_lines) - len(selected)
|
|
if omitted > 0:
|
|
summary_parts: list[str] = []
|
|
for label, key in (
|
|
("ERROR", "errors"),
|
|
("FAIL", "fails"),
|
|
("WARN", "warnings"),
|
|
("INFO", "info"),
|
|
):
|
|
count = stats[key]
|
|
if count > 0:
|
|
summary_parts.append(f"{count} {label}")
|
|
if summary_parts:
|
|
output_lines.append(f"[{omitted} lines omitted: {', '.join(summary_parts)}]")
|
|
return "\n".join(output_lines), stats
|
|
|
|
def _store_in_ccr(self, original: str, compressed: str, original_count: int) -> str | None:
|
|
"""Backwards-compat shim — the legacy callsite name. Now
|
|
delegates to `_persist_to_python_ccr`. Returns the stored
|
|
cache_key if persistence succeeded, else None.
|
|
"""
|
|
# Compute the same cache key the Rust path would (MD5 of
|
|
# original truncated to 24 hex chars).
|
|
import hashlib
|
|
|
|
cache_key = hashlib.md5(original.encode()).hexdigest()[:24]
|
|
try:
|
|
from ..cache.compression_store import get_compression_store
|
|
except ImportError as e:
|
|
logger.warning("CCR store import failed; cache_key %s not persisted: %s", cache_key, e)
|
|
return None
|
|
try:
|
|
store: Any = get_compression_store()
|
|
return cast(
|
|
"str | None",
|
|
store.store(original, compressed, original_item_count=original_count),
|
|
)
|
|
except Exception as e:
|
|
logger.warning("CCR store write failed; cache_key %s not persisted: %s", cache_key, e)
|
|
return None
|
|
|
|
def _persist_to_python_ccr(self, original: str, compressed: str, cache_key: str) -> None:
|
|
"""Promote a Rust-emitted cache_key into the production Python
|
|
CompressionStore. Failures are logged at warning level."""
|
|
try:
|
|
from ..cache.compression_store import get_compression_store
|
|
except ImportError as e:
|
|
logger.warning("CCR store import failed; cache_key %s won't persist: %s", cache_key, e)
|
|
return
|
|
try:
|
|
store: Any = get_compression_store()
|
|
# The Rust-emitted marker embeds MD5(original)[:24], but
|
|
# store() has defaulted to SHA-256(original)[:24] since
|
|
# PR #395. Pass the marker's key explicitly so retrieving
|
|
# the marker hash actually finds the entry (issue #816).
|
|
store.store(original, compressed, explicit_hash=cache_key)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"CCR store write failed; cache_key %s remains in-marker only: %s",
|
|
cache_key,
|
|
e,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"LogCompressor",
|
|
"LogCompressorConfig",
|
|
"LogCompressionResult",
|
|
"LogFormat",
|
|
"LogLevel",
|
|
"LogLine",
|
|
]
|