kctl-api 0.5.2__tar.gz → 0.5.3__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_api-0.5.2 → kctl_api-0.5.3}/PKG-INFO +1 -1
- {kctl_api-0.5.2 → kctl_api-0.5.3}/pyproject.toml +1 -1
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/__init__.py +1 -1
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/deploy.py +32 -2
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/logs.py +21 -34
- {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_cli.py +2 -1
- {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_deploy.py +65 -13
- kctl_api-0.5.3/tests/test_logs.py +43 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/.gitignore +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/README.md +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/skills/api-admin/SKILL.md +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/__main__.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/cli.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/__init__.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/ai.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/aliases.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/apps.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/auth.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/automation.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/build_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/clean.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/config_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/dashboard.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/db.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/deps.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/dev.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/docker_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/doctor_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/env.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/files.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/fmt_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/health.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/jobs.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/lint_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/marketplace.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/monitor_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/notifications.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/odoo_proxy.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/openapi.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/perf.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/rate_limit.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/realtime.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/redis_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/routes_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/saas.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/scaffold.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/security_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/services.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/shell.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/skill_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/streams.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/stripe_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/tenant_ai.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/test_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/users.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/webhooks.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/workflows.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/ws.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/__init__.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/async_client.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/callbacks.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/client.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/config.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/db.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/exceptions.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/output.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/plugins.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/redis.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/resolve.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/utils.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/__init__.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/conftest.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_auth.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_config_cmd.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_core.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_smoke.py +0 -0
- {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_users.py +0 -0
|
@@ -5,11 +5,14 @@ Status, logs, restart, and rollback via Dokploy API (stubs).
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import contextlib
|
|
9
|
+
import subprocess
|
|
8
10
|
from typing import Annotated
|
|
9
11
|
|
|
10
12
|
import typer
|
|
11
13
|
|
|
12
14
|
from kctl_api.core.callbacks import AppContext
|
|
15
|
+
from kctl_api.core.utils import find_project_root
|
|
13
16
|
|
|
14
17
|
app = typer.Typer(name="deploy", help="Deployment management — status, logs, restart, rollback.", no_args_is_help=True)
|
|
15
18
|
|
|
@@ -36,11 +39,38 @@ def logs(
|
|
|
36
39
|
ctx: typer.Context,
|
|
37
40
|
app_name: Annotated[str, typer.Argument(help="App name.")],
|
|
38
41
|
tail: Annotated[int, typer.Option("--tail", "-n", help="Number of lines.")] = 100,
|
|
42
|
+
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs in real time.")] = False,
|
|
43
|
+
compose_id: Annotated[
|
|
44
|
+
str | None,
|
|
45
|
+
typer.Option("--compose-id", help="Dokploy compose ID. Uses local docker compose when omitted."),
|
|
46
|
+
] = None,
|
|
47
|
+
service: Annotated[str | None, typer.Option("--service", "-s", help="Runtime service/container name.")] = None,
|
|
48
|
+
dokploy_profile: Annotated[str | None, typer.Option("--dokploy-profile", help="kctl-dokploy profile name.")] = None,
|
|
39
49
|
) -> None:
|
|
40
|
-
"""View deployment logs
|
|
50
|
+
"""View deployment logs via kctl-dokploy or local docker compose."""
|
|
41
51
|
actx: AppContext = ctx.obj
|
|
42
52
|
out = actx.output
|
|
43
|
-
|
|
53
|
+
|
|
54
|
+
log_service = service or app_name
|
|
55
|
+
if compose_id:
|
|
56
|
+
cmd = ["kctl-dokploy"]
|
|
57
|
+
if dokploy_profile:
|
|
58
|
+
cmd.extend(["--profile", dokploy_profile])
|
|
59
|
+
cmd.extend(["compose", "service-logs", compose_id, "--service", log_service, "--tail", str(tail)])
|
|
60
|
+
if follow:
|
|
61
|
+
cmd.append("--follow")
|
|
62
|
+
else:
|
|
63
|
+
root = find_project_root()
|
|
64
|
+
cmd = ["docker", "compose", "-f", str(root / "docker-compose.yml"), "logs", "--tail", str(tail)]
|
|
65
|
+
if follow:
|
|
66
|
+
cmd.append("--follow")
|
|
67
|
+
cmd.append(log_service)
|
|
68
|
+
|
|
69
|
+
out.info(f"Streaming logs for {log_service} ({'Dokploy' if compose_id else 'local compose'}).")
|
|
70
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
71
|
+
result = subprocess.run(cmd, capture_output=False)
|
|
72
|
+
if result.returncode != 0:
|
|
73
|
+
raise typer.Exit(result.returncode)
|
|
44
74
|
|
|
45
75
|
|
|
46
76
|
# ---------------------------------------------------------------------------
|
|
@@ -6,16 +6,23 @@ Follow, search, and filter service logs via docker compose.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import contextlib
|
|
9
|
+
import re
|
|
9
10
|
import subprocess
|
|
10
11
|
from typing import Annotated
|
|
11
12
|
|
|
12
13
|
import typer
|
|
14
|
+
from kctl_lib.logging_utils import extract_json_payload, filter_sticky_blocks, is_error_line
|
|
13
15
|
|
|
14
16
|
from kctl_api.core.callbacks import AppContext
|
|
15
17
|
from kctl_api.core.utils import find_project_root
|
|
16
18
|
|
|
17
19
|
app = typer.Typer(name="logs", help="Log viewer — follow, errors, search, tail.", no_args_is_help=True)
|
|
18
20
|
|
|
21
|
+
LOG_HEADER_RE = re.compile(
|
|
22
|
+
r"^(?:[^|]+\|\s*)?\d{4}-\d{2}-\d{2}[\sT].*?\b"
|
|
23
|
+
r"(?:DEBUG|INFO|NOTICE|WARN|WARNING|ERROR|CRITICAL|FATAL)\b"
|
|
24
|
+
)
|
|
25
|
+
|
|
19
26
|
|
|
20
27
|
def _compose_log_cmd(args: list[str]) -> list[str]:
|
|
21
28
|
"""Build a docker compose logs command."""
|
|
@@ -62,11 +69,7 @@ def errors(
|
|
|
62
69
|
out.error(f"Failed to fetch logs: {result.stderr.strip()}")
|
|
63
70
|
raise typer.Exit(1)
|
|
64
71
|
|
|
65
|
-
error_lines =
|
|
66
|
-
line
|
|
67
|
-
for line in result.stdout.splitlines()
|
|
68
|
-
if any(kw in line.upper() for kw in ("ERROR", "CRITICAL", "FATAL", "EXCEPTION", "TRACEBACK"))
|
|
69
|
-
]
|
|
72
|
+
error_lines = filter_sticky_blocks(result.stdout.splitlines(), is_match=is_error_line, header_regex=LOG_HEADER_RE)
|
|
70
73
|
|
|
71
74
|
if not error_lines:
|
|
72
75
|
out.success("No errors found in recent logs.")
|
|
@@ -151,24 +154,15 @@ def structured(
|
|
|
151
154
|
out.error(f"Failed to fetch logs: {result.stderr.strip()}")
|
|
152
155
|
raise typer.Exit(1)
|
|
153
156
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
parsed: list[dict] = []
|
|
157
|
+
parsed: list[dict[str, object]] = []
|
|
157
158
|
for line in result.stdout.splitlines():
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if json_start == -1:
|
|
161
|
-
continue
|
|
162
|
-
json_part = line[json_start:]
|
|
163
|
-
try:
|
|
164
|
-
entry = json.loads(json_part)
|
|
159
|
+
entry = extract_json_payload(line)
|
|
160
|
+
if entry is not None:
|
|
165
161
|
parsed.append(entry)
|
|
166
|
-
except json.JSONDecodeError:
|
|
167
|
-
continue
|
|
168
162
|
|
|
169
163
|
# Filter
|
|
170
164
|
if level:
|
|
171
|
-
parsed = [e for e in parsed if e.get("level", e.get("severity", "")).lower() == level.lower()]
|
|
165
|
+
parsed = [e for e in parsed if str(e.get("level", e.get("severity", ""))).lower() == level.lower()]
|
|
172
166
|
if event:
|
|
173
167
|
parsed = [e for e in parsed if event.lower() in str(e.get("event", e.get("msg", ""))).lower()]
|
|
174
168
|
|
|
@@ -178,7 +172,7 @@ def structured(
|
|
|
178
172
|
|
|
179
173
|
rows = [
|
|
180
174
|
[
|
|
181
|
-
str(e.get("timestamp", e.get("time", ""))[:19]
|
|
175
|
+
str(e.get("timestamp", e.get("time", "")))[:19],
|
|
182
176
|
str(e.get("level", e.get("severity", ""))),
|
|
183
177
|
str(e.get("event", e.get("msg", ""))),
|
|
184
178
|
str(e.get("logger", e.get("name", ""))),
|
|
@@ -216,23 +210,16 @@ def correlation(
|
|
|
216
210
|
out.error(f"Failed to fetch logs: {result.stderr.strip()}")
|
|
217
211
|
raise typer.Exit(1)
|
|
218
212
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
matching_lines: list[dict] = []
|
|
213
|
+
matching_lines: list[dict[str, object]] = []
|
|
222
214
|
for line in result.stdout.splitlines():
|
|
223
215
|
if request_id not in line:
|
|
224
216
|
continue
|
|
225
217
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
entry["_raw"] = line
|
|
232
|
-
matching_lines.append(entry)
|
|
233
|
-
continue
|
|
234
|
-
except json.JSONDecodeError:
|
|
235
|
-
pass
|
|
218
|
+
entry = extract_json_payload(line)
|
|
219
|
+
if entry is not None:
|
|
220
|
+
entry["_raw"] = line
|
|
221
|
+
matching_lines.append(entry)
|
|
222
|
+
continue
|
|
236
223
|
|
|
237
224
|
# Plain text match
|
|
238
225
|
matching_lines.append({"_raw": line, "event": line.strip()})
|
|
@@ -243,8 +230,8 @@ def correlation(
|
|
|
243
230
|
|
|
244
231
|
out.header(f"Correlation Trace: {request_id} ({len(matching_lines)} entries)")
|
|
245
232
|
for entry in matching_lines:
|
|
246
|
-
ts = entry.get("timestamp", entry.get("time", ""))[:19]
|
|
247
|
-
level = entry.get("level", entry.get("severity", "INFO"))
|
|
233
|
+
ts = str(entry.get("timestamp", entry.get("time", "")))[:19]
|
|
234
|
+
level = str(entry.get("level", entry.get("severity", "INFO")))
|
|
248
235
|
event = entry.get("event", entry.get("msg", entry.get("_raw", "")))
|
|
249
236
|
logger = entry.get("logger", entry.get("name", ""))
|
|
250
237
|
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typer.testing import CliRunner
|
|
6
6
|
|
|
7
|
+
from kctl_api import __version__
|
|
7
8
|
from kctl_api.cli import app
|
|
8
9
|
|
|
9
10
|
runner = CliRunner()
|
|
@@ -13,7 +14,7 @@ def test_version() -> None:
|
|
|
13
14
|
result = runner.invoke(app, ["--version"])
|
|
14
15
|
assert result.exit_code == 0
|
|
15
16
|
assert "kctl-api" in result.output
|
|
16
|
-
assert
|
|
17
|
+
assert __version__ in result.output
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def test_help() -> None:
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
5
7
|
import pytest
|
|
6
8
|
from typer.testing import CliRunner
|
|
7
9
|
|
|
@@ -59,27 +61,77 @@ class TestDeployStatus:
|
|
|
59
61
|
|
|
60
62
|
|
|
61
63
|
# ---------------------------------------------------------------------------
|
|
62
|
-
# deploy logs
|
|
64
|
+
# deploy logs
|
|
63
65
|
# ---------------------------------------------------------------------------
|
|
64
66
|
class TestDeployLogs:
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
def test_logs_uses_local_compose_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
68
|
+
calls: list[list[str]] = []
|
|
69
|
+
|
|
70
|
+
def fake_run(cmd: list[str], capture_output: bool = False) -> subprocess.CompletedProcess[str]:
|
|
71
|
+
calls.append(cmd)
|
|
72
|
+
return subprocess.CompletedProcess(cmd, 0)
|
|
73
|
+
|
|
74
|
+
monkeypatch.setattr("kctl_api.commands.deploy.subprocess.run", fake_run)
|
|
69
75
|
|
|
70
|
-
def test_logs_default_tail(self) -> None:
|
|
71
76
|
result = runner.invoke(app, ["deploy", "logs", "api-main"])
|
|
77
|
+
|
|
72
78
|
assert result.exit_code == 0
|
|
73
|
-
assert
|
|
79
|
+
assert calls
|
|
80
|
+
assert calls[0][-3:] == ["--tail", "100", "api-main"]
|
|
81
|
+
|
|
82
|
+
def test_logs_custom_tail(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
83
|
+
calls: list[list[str]] = []
|
|
84
|
+
|
|
85
|
+
def fake_run(cmd: list[str], capture_output: bool = False) -> subprocess.CompletedProcess[str]:
|
|
86
|
+
calls.append(cmd)
|
|
87
|
+
return subprocess.CompletedProcess(cmd, 0)
|
|
88
|
+
|
|
89
|
+
monkeypatch.setattr("kctl_api.commands.deploy.subprocess.run", fake_run)
|
|
74
90
|
|
|
75
|
-
def test_logs_custom_tail(self) -> None:
|
|
76
91
|
result = runner.invoke(app, ["deploy", "logs", "api-main", "--tail", "50"])
|
|
77
|
-
assert result.exit_code == 0
|
|
78
|
-
assert "50" in result.output
|
|
79
92
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
assert result.exit_code == 0
|
|
94
|
+
assert calls[0][-3:] == ["--tail", "50", "api-main"]
|
|
95
|
+
|
|
96
|
+
def test_logs_uses_dokploy_service_logs_when_compose_id_is_given(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
97
|
+
calls: list[list[str]] = []
|
|
98
|
+
|
|
99
|
+
def fake_run(cmd: list[str], capture_output: bool = False) -> subprocess.CompletedProcess[str]:
|
|
100
|
+
calls.append(cmd)
|
|
101
|
+
return subprocess.CompletedProcess(cmd, 0)
|
|
102
|
+
|
|
103
|
+
monkeypatch.setattr("kctl_api.commands.deploy.subprocess.run", fake_run)
|
|
104
|
+
|
|
105
|
+
result = runner.invoke(
|
|
106
|
+
app,
|
|
107
|
+
[
|
|
108
|
+
"deploy",
|
|
109
|
+
"logs",
|
|
110
|
+
"api-main",
|
|
111
|
+
"--compose-id",
|
|
112
|
+
"cmp123",
|
|
113
|
+
"--dokploy-profile",
|
|
114
|
+
"prod",
|
|
115
|
+
"--service",
|
|
116
|
+
"api",
|
|
117
|
+
"--tail",
|
|
118
|
+
"50",
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
assert result.exit_code == 0
|
|
123
|
+
assert calls[0] == [
|
|
124
|
+
"kctl-dokploy",
|
|
125
|
+
"--profile",
|
|
126
|
+
"prod",
|
|
127
|
+
"compose",
|
|
128
|
+
"service-logs",
|
|
129
|
+
"cmp123",
|
|
130
|
+
"--service",
|
|
131
|
+
"api",
|
|
132
|
+
"--tail",
|
|
133
|
+
"50",
|
|
134
|
+
]
|
|
83
135
|
|
|
84
136
|
|
|
85
137
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Tests for kctl-api log commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from kctl_api.cli import app
|
|
11
|
+
|
|
12
|
+
runner = CliRunner()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_errors_preserves_traceback_continuations(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
16
|
+
stdout = "\n".join(
|
|
17
|
+
[
|
|
18
|
+
"api-1 | 2026-05-17 10:00:00 INFO startup ok",
|
|
19
|
+
"api-1 | 2026-05-17 10:00:01 ERROR request failed",
|
|
20
|
+
"api-1 | Traceback (most recent call last):",
|
|
21
|
+
'api-1 | File "app.py", line 1, in handler',
|
|
22
|
+
"api-1 | ValueError: boom",
|
|
23
|
+
"api-1 | 2026-05-17 10:00:02 INFO next request ok",
|
|
24
|
+
]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def fake_run(
|
|
28
|
+
cmd: list[str],
|
|
29
|
+
capture_output: bool = False,
|
|
30
|
+
text: bool = False,
|
|
31
|
+
) -> subprocess.CompletedProcess[str]:
|
|
32
|
+
return subprocess.CompletedProcess(cmd, 0, stdout=stdout, stderr="")
|
|
33
|
+
|
|
34
|
+
monkeypatch.setattr("kctl_api.commands.logs.subprocess.run", fake_run)
|
|
35
|
+
|
|
36
|
+
result = runner.invoke(app, ["logs", "errors", "--tail", "50"])
|
|
37
|
+
|
|
38
|
+
assert result.exit_code == 0
|
|
39
|
+
assert "ERROR request failed" in result.output
|
|
40
|
+
assert 'File "app.py"' in result.output
|
|
41
|
+
assert "ValueError: boom" in result.output
|
|
42
|
+
assert "startup ok" not in result.output
|
|
43
|
+
assert "next request ok" not in result.output
|
|
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
|