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.
Files changed (78) hide show
  1. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/PKG-INFO +1 -1
  2. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/pyproject.toml +1 -1
  3. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/__init__.py +1 -1
  4. kctl_lib-0.8.2/src/kctl_lib/logging_utils.py +141 -0
  5. kctl_lib-0.8.2/src/kctl_lib/py.typed +1 -0
  6. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/conftest.py +8 -0
  7. kctl_lib-0.8.2/tests/test_logging_utils.py +70 -0
  8. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/.gitignore +0 -0
  9. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/README.md +0 -0
  10. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/_migration.py +0 -0
  11. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/api_client.py +0 -0
  12. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/async_api_client.py +0 -0
  13. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/callbacks.py +0 -0
  14. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/__init__.py +0 -0
  15. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/__init__.py +0 -0
  16. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/current.py +0 -0
  17. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/list_cmd.py +0 -0
  18. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/migrate.py +0 -0
  19. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/show.py +0 -0
  20. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/commands/validate.py +0 -0
  21. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/cli/main.py +0 -0
  22. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/completions.py +0 -0
  23. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/config.py +0 -0
  24. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/docker.py +0 -0
  25. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/doctor_base.py +0 -0
  26. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/exceptions.py +0 -0
  27. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/git_ops.py +0 -0
  28. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/history.py +0 -0
  29. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/monitor_base.py +0 -0
  30. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/output.py +0 -0
  31. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/plugins.py +0 -0
  32. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/progress.py +0 -0
  33. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/runner.py +0 -0
  34. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/schemas.py +0 -0
  35. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/secrets.py +0 -0
  36. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/self_update.py +0 -0
  37. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/skill_generator.py +0 -0
  38. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/ssh.py +0 -0
  39. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/ssh_tunnel.py +0 -0
  40. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/testing.py +0 -0
  41. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/tui.py +0 -0
  42. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/tui.tcss +0 -0
  43. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/src/kctl_lib/validate.py +0 -0
  44. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/fixtures/clean_config.yaml +0 -0
  45. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/fixtures/dirty_config.yaml +0 -0
  46. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_api_client.py +0 -0
  47. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_async_api_client.py +0 -0
  48. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_callbacks.py +0 -0
  49. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_current.py +0 -0
  50. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_list.py +0 -0
  51. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_migrate.py +0 -0
  52. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_show.py +0 -0
  53. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_smoke.py +0 -0
  54. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_cli_validate.py +0 -0
  55. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_completions.py +0 -0
  56. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_config.py +0 -0
  57. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_docker.py +0 -0
  58. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_doctor_base.py +0 -0
  59. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_exceptions.py +0 -0
  60. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_git_ops.py +0 -0
  61. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_history.py +0 -0
  62. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_inheritance.py +0 -0
  63. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_mask_secret_fields.py +0 -0
  64. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_migration.py +0 -0
  65. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_monitor_base.py +0 -0
  66. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_output.py +0 -0
  67. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_plugins.py +0 -0
  68. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_profile_banner.py +0 -0
  69. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_progress.py +0 -0
  70. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_runner.py +0 -0
  71. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_schemas.py +0 -0
  72. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_secrets.py +0 -0
  73. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_self_update.py +0 -0
  74. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_skill_generator.py +0 -0
  75. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_ssh.py +0 -0
  76. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_ssh_tunnel.py +0 -0
  77. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_tui.py +0 -0
  78. {kctl_lib-0.8.0 → kctl_lib-0.8.2}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kctl-lib
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Shared core library for kctl-* CLI tools
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: httpx>=0.28.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "kctl-lib"
7
- version = "0.8.0"
7
+ version = "0.8.2"
8
8
  description = "Shared core library for kctl-* CLI tools"
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
@@ -24,7 +24,7 @@ Public API:
24
24
  - tui: Textual TUI (add_tui_command)
25
25
  """
26
26
 
27
- __version__ = "0.8.0"
27
+ __version__ = "0.8.2"
28
28
 
29
29
  from kctl_lib.api_client import APIClient
30
30
  from kctl_lib.async_api_client import AsyncAPIClient
@@ -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