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.
Files changed (77) hide show
  1. {kctl_api-0.5.2 → kctl_api-0.5.3}/PKG-INFO +1 -1
  2. {kctl_api-0.5.2 → kctl_api-0.5.3}/pyproject.toml +1 -1
  3. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/__init__.py +1 -1
  4. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/deploy.py +32 -2
  5. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/logs.py +21 -34
  6. {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_cli.py +2 -1
  7. {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_deploy.py +65 -13
  8. kctl_api-0.5.3/tests/test_logs.py +43 -0
  9. {kctl_api-0.5.2 → kctl_api-0.5.3}/.gitignore +0 -0
  10. {kctl_api-0.5.2 → kctl_api-0.5.3}/README.md +0 -0
  11. {kctl_api-0.5.2 → kctl_api-0.5.3}/skills/api-admin/SKILL.md +0 -0
  12. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/__main__.py +0 -0
  13. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/cli.py +0 -0
  14. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/__init__.py +0 -0
  15. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/ai.py +0 -0
  16. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/aliases.py +0 -0
  17. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/apps.py +0 -0
  18. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/auth.py +0 -0
  19. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/automation.py +0 -0
  20. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/build_cmd.py +0 -0
  21. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/clean.py +0 -0
  22. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/config_cmd.py +0 -0
  23. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/dashboard.py +0 -0
  24. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/db.py +0 -0
  25. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/deps.py +0 -0
  26. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/dev.py +0 -0
  27. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/docker_cmd.py +0 -0
  28. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/doctor_cmd.py +0 -0
  29. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/env.py +0 -0
  30. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/files.py +0 -0
  31. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/fmt_cmd.py +0 -0
  32. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/health.py +0 -0
  33. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/jobs.py +0 -0
  34. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/lint_cmd.py +0 -0
  35. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/marketplace.py +0 -0
  36. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/monitor_cmd.py +0 -0
  37. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/notifications.py +0 -0
  38. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/odoo_proxy.py +0 -0
  39. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/openapi.py +0 -0
  40. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/perf.py +0 -0
  41. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/rate_limit.py +0 -0
  42. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/realtime.py +0 -0
  43. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/redis_cmd.py +0 -0
  44. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/routes_cmd.py +0 -0
  45. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/saas.py +0 -0
  46. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/scaffold.py +0 -0
  47. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/security_cmd.py +0 -0
  48. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/services.py +0 -0
  49. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/shell.py +0 -0
  50. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/skill_cmd.py +0 -0
  51. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/streams.py +0 -0
  52. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/stripe_cmd.py +0 -0
  53. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/tenant_ai.py +0 -0
  54. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/test_cmd.py +0 -0
  55. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/users.py +0 -0
  56. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/webhooks.py +0 -0
  57. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/workflows.py +0 -0
  58. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/commands/ws.py +0 -0
  59. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/__init__.py +0 -0
  60. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/async_client.py +0 -0
  61. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/callbacks.py +0 -0
  62. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/client.py +0 -0
  63. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/config.py +0 -0
  64. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/db.py +0 -0
  65. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/exceptions.py +0 -0
  66. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/output.py +0 -0
  67. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/plugins.py +0 -0
  68. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/redis.py +0 -0
  69. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/resolve.py +0 -0
  70. {kctl_api-0.5.2 → kctl_api-0.5.3}/src/kctl_api/core/utils.py +0 -0
  71. {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/__init__.py +0 -0
  72. {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/conftest.py +0 -0
  73. {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_auth.py +0 -0
  74. {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_config_cmd.py +0 -0
  75. {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_core.py +0 -0
  76. {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_smoke.py +0 -0
  77. {kctl_api-0.5.2 → kctl_api-0.5.3}/tests/test_users.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kctl-api
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Kodemeio API CLI - manage your FastAPI platform
5
5
  Author-email: Kodemeio <dev@kodeme.io>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "kctl-api"
7
- version = "0.5.2"
7
+ version = "0.5.3"
8
8
  description = "Kodemeio API CLI - manage your FastAPI platform"
9
9
  license = "MIT"
10
10
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """kctl-api: Kodemeio API CLI."""
2
2
 
3
- __version__ = "0.5.2"
3
+ __version__ = "0.5.3"
@@ -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 (will use Dokploy API)."""
50
+ """View deployment logs via kctl-dokploy or local docker compose."""
41
51
  actx: AppContext = ctx.obj
42
52
  out = actx.output
43
- out.info(f"Not yet implemented. Will fetch last {tail} log lines for: {app_name}")
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
- import json
155
-
156
- parsed: list[dict] = []
157
+ parsed: list[dict[str, object]] = []
157
158
  for line in result.stdout.splitlines():
158
- # Try to extract JSON from lines (structlog may have container prefix)
159
- json_start = line.find("{")
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
- import json
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
- # Try to parse as structured log
227
- json_start = line.find("{")
228
- if json_start >= 0:
229
- try:
230
- entry = json.loads(line[json_start:])
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 "0.1.0" in result.output
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 (stub)
64
+ # deploy logs
63
65
  # ---------------------------------------------------------------------------
64
66
  class TestDeployLogs:
65
- def test_logs_shows_app_name(self) -> None:
66
- result = runner.invoke(app, ["deploy", "logs", "api-main"])
67
- assert result.exit_code == 0
68
- assert "api-main" in result.output
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 "100" in result.output
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
- def test_logs_not_implemented_message(self) -> None:
81
- result = runner.invoke(app, ["deploy", "logs", "api-main"])
82
- assert "not yet implemented" in result.output.lower()
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