kctl-lib 0.8.0__tar.gz → 0.8.2__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.
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/PKG-INFO +1 -1
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/pyproject.toml +1 -1
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/__init__.py +1 -1
- kctl_lib-0.8.2/src/kctl_lib/logging_utils.py +141 -0
- kctl_lib-0.8.2/src/kctl_lib/py.typed +1 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/conftest.py +8 -0
- kctl_lib-0.8.2/tests/test_logging_utils.py +70 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/.gitignore +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/README.md +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/_migration.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/api_client.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/async_api_client.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/callbacks.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/__init__.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/__init__.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/current.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/list_cmd.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/migrate.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/show.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/validate.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/main.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/completions.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/config.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/docker.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/doctor_base.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/exceptions.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/git_ops.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/history.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/monitor_base.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/output.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/plugins.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/progress.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/runner.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/schemas.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/secrets.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/self_update.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/skill_generator.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/ssh.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/ssh_tunnel.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/testing.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/tui.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/tui.tcss +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/validate.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/fixtures/clean_config.yaml +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/fixtures/dirty_config.yaml +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_api_client.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_async_api_client.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_callbacks.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_current.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_list.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_migrate.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_show.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_smoke.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_validate.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_completions.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_config.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_docker.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_doctor_base.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_exceptions.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_git_ops.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_history.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_inheritance.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_mask_secret_fields.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_migration.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_monitor_base.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_output.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_plugins.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_profile_banner.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_progress.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_runner.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_schemas.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_secrets.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_self_update.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_skill_generator.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_ssh.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_ssh_tunnel.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_tui.py +0 -0
- {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_validate.py +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Shared helpers for log filtering and parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
DEFAULT_ERROR_KEYWORDS = ("ERROR", "CRITICAL", "FATAL", "EXCEPTION", "TRACEBACK")
|
|
12
|
+
|
|
13
|
+
LEVEL_RANKS = {
|
|
14
|
+
"DEBUG": 10,
|
|
15
|
+
"INFO": 20,
|
|
16
|
+
"NOTICE": 25,
|
|
17
|
+
"WARN": 30,
|
|
18
|
+
"WARNING": 30,
|
|
19
|
+
"ERROR": 40,
|
|
20
|
+
"CRITICAL": 50,
|
|
21
|
+
"FATAL": 50,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compile_grep(pattern: str | None) -> re.Pattern[str] | None:
|
|
26
|
+
"""Compile a case-insensitive grep regex and normalize invalid patterns."""
|
|
27
|
+
if not pattern:
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
return re.compile(pattern, re.IGNORECASE)
|
|
31
|
+
except re.error as exc:
|
|
32
|
+
raise ValueError(f"Invalid grep pattern: {exc}") from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_error_line(line: str, keywords: Iterable[str] = DEFAULT_ERROR_KEYWORDS) -> bool:
|
|
36
|
+
"""Return true when a log line contains a standard error indicator."""
|
|
37
|
+
upper_line = line.upper()
|
|
38
|
+
return any(keyword.upper() in upper_line for keyword in keywords)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract_json_payload(line: str) -> dict[str, object] | None:
|
|
42
|
+
"""Extract the first JSON object embedded in a log line."""
|
|
43
|
+
start = line.find("{")
|
|
44
|
+
if start == -1:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
value, _ = json.JSONDecoder().raw_decode(line[start:])
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if not isinstance(value, dict):
|
|
53
|
+
return None
|
|
54
|
+
return cast(dict[str, object], value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _level_rank(level: str | None) -> int | None:
|
|
58
|
+
if level is None:
|
|
59
|
+
return None
|
|
60
|
+
return LEVEL_RANKS.get(level.upper())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class LogFilter:
|
|
65
|
+
"""Reusable predicate for common log filtering options."""
|
|
66
|
+
|
|
67
|
+
min_level: str | None = None
|
|
68
|
+
grep: re.Pattern[str] | None = None
|
|
69
|
+
event: str | None = None
|
|
70
|
+
logger: str | None = None
|
|
71
|
+
|
|
72
|
+
def matches(
|
|
73
|
+
self,
|
|
74
|
+
line: str,
|
|
75
|
+
*,
|
|
76
|
+
level: str | None = None,
|
|
77
|
+
logger: str | None = None,
|
|
78
|
+
message: str | None = None,
|
|
79
|
+
payload: Mapping[str, object] | None = None,
|
|
80
|
+
) -> bool:
|
|
81
|
+
if not self._level_matches(level):
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
effective_logger = logger
|
|
85
|
+
effective_event: str | None = None
|
|
86
|
+
if payload is not None:
|
|
87
|
+
payload_logger = payload.get("logger")
|
|
88
|
+
payload_event = payload.get("event")
|
|
89
|
+
effective_logger = effective_logger or (payload_logger if isinstance(payload_logger, str) else None)
|
|
90
|
+
effective_event = payload_event if isinstance(payload_event, str) else None
|
|
91
|
+
|
|
92
|
+
if self.logger is not None and effective_logger != self.logger:
|
|
93
|
+
return False
|
|
94
|
+
if self.event is not None and effective_event != self.event:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
if self.grep is None:
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
haystack = "\n".join(part for part in (line, message, effective_logger, effective_event) if part)
|
|
101
|
+
return bool(self.grep.search(haystack))
|
|
102
|
+
|
|
103
|
+
def _level_matches(self, level: str | None) -> bool:
|
|
104
|
+
if self.min_level is None:
|
|
105
|
+
return True
|
|
106
|
+
min_rank = _level_rank(self.min_level)
|
|
107
|
+
if min_rank is None:
|
|
108
|
+
return False
|
|
109
|
+
rank = _level_rank(level)
|
|
110
|
+
return rank is not None and rank >= min_rank
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def iter_sticky_blocks(
|
|
114
|
+
lines: Iterable[str],
|
|
115
|
+
*,
|
|
116
|
+
is_match: Callable[[str], bool],
|
|
117
|
+
header_regex: re.Pattern[str],
|
|
118
|
+
) -> Iterator[str]:
|
|
119
|
+
"""Yield matching lines while preserving continuation lines after matching headers."""
|
|
120
|
+
keep_continuation = False
|
|
121
|
+
|
|
122
|
+
for line in lines:
|
|
123
|
+
if header_regex.match(line):
|
|
124
|
+
keep_continuation = is_match(line)
|
|
125
|
+
if keep_continuation:
|
|
126
|
+
yield line
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if keep_continuation or is_match(line):
|
|
130
|
+
keep_continuation = True
|
|
131
|
+
yield line
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def filter_sticky_blocks(
|
|
135
|
+
lines: Iterable[str],
|
|
136
|
+
*,
|
|
137
|
+
is_match: Callable[[str], bool],
|
|
138
|
+
header_regex: re.Pattern[str],
|
|
139
|
+
) -> list[str]:
|
|
140
|
+
"""Filter lines while preserving continuation lines after matching headers."""
|
|
141
|
+
return list(iter_sticky_blocks(lines, is_match=is_match, header_regex=header_regex))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -26,3 +26,11 @@ def _isolate_user_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, reques
|
|
|
26
26
|
target = tmp_path / "config.yaml"
|
|
27
27
|
monkeypatch.setattr("kctl_lib.config.CONFIG_FILE", target, raising=False)
|
|
28
28
|
monkeypatch.setattr("kctl_lib.config.CONFIG_DIR", tmp_path, raising=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(autouse=True)
|
|
32
|
+
def _isolate_schema_registry(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
33
|
+
"""Keep service-schema registration tests from leaking across packages."""
|
|
34
|
+
import kctl_lib.schemas as schemas
|
|
35
|
+
|
|
36
|
+
monkeypatch.setattr(schemas, "_REGISTRY", dict(schemas._REGISTRY), raising=False)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Tests for shared log parsing/filtering helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from kctl_lib.logging_utils import (
|
|
10
|
+
DEFAULT_ERROR_KEYWORDS,
|
|
11
|
+
LogFilter,
|
|
12
|
+
compile_grep,
|
|
13
|
+
extract_json_payload,
|
|
14
|
+
filter_sticky_blocks,
|
|
15
|
+
is_error_line,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
HEADER_RE = re.compile(r"^\d{4}-\d{2}-\d{2} (?P<level>INFO|WARNING|ERROR|CRITICAL) ")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_is_error_line_uses_standard_keywords() -> None:
|
|
22
|
+
assert "TRACEBACK" in DEFAULT_ERROR_KEYWORDS
|
|
23
|
+
assert is_error_line("worker CRITICAL failed")
|
|
24
|
+
assert is_error_line("Traceback (most recent call last):")
|
|
25
|
+
assert not is_error_line("INFO healthy")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_compile_grep_reports_invalid_regex() -> None:
|
|
29
|
+
with pytest.raises(ValueError):
|
|
30
|
+
compile_grep("[")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_extract_json_payload_from_prefixed_log_line() -> None:
|
|
34
|
+
assert extract_json_payload('api-1 | {"level":"error","event":"failed"}') == {
|
|
35
|
+
"level": "error",
|
|
36
|
+
"event": "failed",
|
|
37
|
+
}
|
|
38
|
+
assert extract_json_payload("plain text") is None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_filter_sticky_blocks_preserves_traceback_continuations() -> None:
|
|
42
|
+
lines = [
|
|
43
|
+
"2026-05-17 INFO startup ok\n",
|
|
44
|
+
"2026-05-17 ERROR request failed\n",
|
|
45
|
+
"Traceback (most recent call last):\n",
|
|
46
|
+
' File "app.py", line 1\n',
|
|
47
|
+
"ValueError: boom\n",
|
|
48
|
+
"2026-05-17 INFO next request ok\n",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
result = filter_sticky_blocks(lines, is_match=is_error_line, header_regex=HEADER_RE)
|
|
52
|
+
|
|
53
|
+
assert result == lines[1:5]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_log_filter_combines_level_and_grep() -> None:
|
|
57
|
+
filt = LogFilter(min_level="WARNING", grep=compile_grep("account"))
|
|
58
|
+
|
|
59
|
+
assert filt.matches(
|
|
60
|
+
"2026-05-17 ERROR account.move: failed",
|
|
61
|
+
level="ERROR",
|
|
62
|
+
logger="odoo.addons.account",
|
|
63
|
+
message="account.move failed",
|
|
64
|
+
)
|
|
65
|
+
assert not filt.matches(
|
|
66
|
+
"2026-05-17 INFO account.move: ok",
|
|
67
|
+
level="INFO",
|
|
68
|
+
logger="odoo.addons.account",
|
|
69
|
+
message="account.move ok",
|
|
70
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|