kctl-prefect 0.5.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_prefect-0.5.2/.gitignore +33 -0
- kctl_prefect-0.5.2/PKG-INFO +15 -0
- kctl_prefect-0.5.2/pyproject.toml +38 -0
- kctl_prefect-0.5.2/src/kctl_prefect/__init__.py +1 -0
- kctl_prefect-0.5.2/src/kctl_prefect/cli.py +58 -0
- kctl_prefect-0.5.2/src/kctl_prefect/commands/__init__.py +0 -0
- kctl_prefect-0.5.2/src/kctl_prefect/commands/config_cmd.py +37 -0
- kctl_prefect-0.5.2/src/kctl_prefect/commands/deployments.py +52 -0
- kctl_prefect-0.5.2/src/kctl_prefect/commands/flows.py +49 -0
- kctl_prefect-0.5.2/src/kctl_prefect/commands/health.py +22 -0
- kctl_prefect-0.5.2/src/kctl_prefect/commands/reports.py +70 -0
- kctl_prefect-0.5.2/src/kctl_prefect/commands/runs.py +75 -0
- kctl_prefect-0.5.2/src/kctl_prefect/core/__init__.py +0 -0
- kctl_prefect-0.5.2/src/kctl_prefect/core/callbacks.py +30 -0
- kctl_prefect-0.5.2/src/kctl_prefect/core/client.py +55 -0
- kctl_prefect-0.5.2/src/kctl_prefect/core/config.py +40 -0
- kctl_prefect-0.5.2/tests/__init__.py +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
*.egg
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.eggs/
|
|
9
|
+
|
|
10
|
+
# Virtual environments
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
|
|
14
|
+
# IDE
|
|
15
|
+
.idea/
|
|
16
|
+
.vscode/
|
|
17
|
+
*.swp
|
|
18
|
+
*.swo
|
|
19
|
+
|
|
20
|
+
# Testing
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
.coverage
|
|
23
|
+
htmlcov/
|
|
24
|
+
.mypy_cache/
|
|
25
|
+
.ruff_cache/
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
|
|
31
|
+
# Environment
|
|
32
|
+
.env
|
|
33
|
+
.env.local
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kctl-prefect
|
|
3
|
+
Version: 0.5.2
|
|
4
|
+
Summary: CLI for managing kodemeio Prefect flow scheduler
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: boto3>=1.35.0
|
|
7
|
+
Requires-Dist: httpx>=0.28.0
|
|
8
|
+
Requires-Dist: kctl-lib>=0.8.0
|
|
9
|
+
Requires-Dist: pydantic>=2.10.0
|
|
10
|
+
Requires-Dist: rich>=13.9.0
|
|
11
|
+
Requires-Dist: typer>=0.15.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: mypy>=1.14.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kctl-prefect"
|
|
7
|
+
version = "0.5.2"
|
|
8
|
+
description = "CLI for managing kodemeio Prefect flow scheduler"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"kctl-lib>=0.8.0",
|
|
12
|
+
"typer>=0.15.0",
|
|
13
|
+
"rich>=13.9.0",
|
|
14
|
+
"pydantic>=2.10.0",
|
|
15
|
+
"httpx>=0.28.0",
|
|
16
|
+
"boto3>=1.35.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8.3.0",
|
|
22
|
+
"ruff>=0.9.0",
|
|
23
|
+
"mypy>=1.14.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
kctl-prefect = "kctl_prefect.cli:_run"
|
|
28
|
+
|
|
29
|
+
[tool.uv.sources]
|
|
30
|
+
kctl-lib = { workspace = true }
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
target-version = "py312"
|
|
34
|
+
line-length = 120
|
|
35
|
+
|
|
36
|
+
[tool.mypy]
|
|
37
|
+
python_version = "3.12"
|
|
38
|
+
strict = true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""kctl-prefect — CLI for kodemeio Prefect operations."""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""kctl-prefect CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_prefect.commands.config_cmd import app as config_app
|
|
10
|
+
from kctl_prefect.commands.deployments import app as deployments_app
|
|
11
|
+
from kctl_prefect.commands.flows import app as flows_app
|
|
12
|
+
from kctl_prefect.commands.health import app as health_app
|
|
13
|
+
from kctl_prefect.commands.reports import app as reports_app
|
|
14
|
+
from kctl_prefect.commands.runs import app as runs_app
|
|
15
|
+
from kctl_prefect.core.callbacks import AppContext
|
|
16
|
+
from kctl_lib.tui import add_tui_command
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="kctl-prefect",
|
|
20
|
+
help="CLI for managing kodemeio Prefect flow scheduler.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
pretty_exceptions_enable=False,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.callback()
|
|
27
|
+
def main(
|
|
28
|
+
ctx: typer.Context,
|
|
29
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
30
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress info"),
|
|
31
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Config profile"),
|
|
32
|
+
fmt: str = typer.Option("pretty", "--format", help="Output format"),
|
|
33
|
+
no_header: bool = typer.Option(False, "--no-header", help="No CSV header"),
|
|
34
|
+
version: bool = typer.Option(False, "--version", "-V", help="Show version"),
|
|
35
|
+
) -> None:
|
|
36
|
+
if version:
|
|
37
|
+
typer.echo("kctl-prefect 0.1.0")
|
|
38
|
+
raise typer.Exit()
|
|
39
|
+
ctx.obj = AppContext(json_mode=json_output, quiet=quiet, profile=profile, format=fmt, no_header=no_header)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
app.add_typer(health_app, name="health")
|
|
43
|
+
app.add_typer(flows_app, name="flows")
|
|
44
|
+
app.add_typer(deployments_app, name="deployments")
|
|
45
|
+
app.add_typer(runs_app, name="runs")
|
|
46
|
+
app.add_typer(reports_app, name="reports")
|
|
47
|
+
app.add_typer(config_app, name="config")
|
|
48
|
+
add_tui_command(app, service_key="prefect", version="0.1.0")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run() -> None:
|
|
52
|
+
from kctl_lib.exceptions import KctlError
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
app()
|
|
56
|
+
except KctlError as e:
|
|
57
|
+
typer.echo(f"Error: {e}", err=True)
|
|
58
|
+
sys.exit(1)
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Configuration management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from kctl_prefect.core.callbacks import AppContext
|
|
8
|
+
from kctl_prefect.core.config import ServiceConfig, resolve_active_profile_name, set_service_config
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Configuration management", no_args_is_help=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("init")
|
|
14
|
+
def init(ctx: typer.Context) -> None:
|
|
15
|
+
"""Interactive config setup."""
|
|
16
|
+
actx: AppContext = ctx.obj
|
|
17
|
+
profile = resolve_active_profile_name(actx.profile)
|
|
18
|
+
|
|
19
|
+
url = typer.prompt("Prefect API URL", default="http://localhost:4200/api")
|
|
20
|
+
api_key = typer.prompt("API key (optional, press Enter to skip)", default="")
|
|
21
|
+
s3_bucket = typer.prompt("S3 bucket for reports", default="kodemeio-prefect")
|
|
22
|
+
|
|
23
|
+
cfg = ServiceConfig(url=url, api_key=api_key, s3_bucket=s3_bucket)
|
|
24
|
+
set_service_config(profile, cfg)
|
|
25
|
+
actx.output.success(f"Config saved for profile '{profile}'")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("test")
|
|
29
|
+
def test(ctx: typer.Context) -> None:
|
|
30
|
+
"""Test connection to Prefect server."""
|
|
31
|
+
actx: AppContext = ctx.obj
|
|
32
|
+
try:
|
|
33
|
+
actx.client.health()
|
|
34
|
+
actx.output.success("Connection OK")
|
|
35
|
+
except Exception as e:
|
|
36
|
+
actx.output.error(f"Connection failed: {e}")
|
|
37
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Deployment management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from kctl_prefect.core.callbacks import AppContext
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Deployment management", no_args_is_help=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("list")
|
|
13
|
+
def list_deployments(ctx: typer.Context) -> None:
|
|
14
|
+
"""List all deployments with schedules."""
|
|
15
|
+
actx: AppContext = ctx.obj
|
|
16
|
+
out = actx.output
|
|
17
|
+
deps = actx.client.list_deployments()
|
|
18
|
+
if out.json_mode:
|
|
19
|
+
out.raw_json(deps)
|
|
20
|
+
return
|
|
21
|
+
rows = []
|
|
22
|
+
for d in deps:
|
|
23
|
+
schedules = d.get("schedules", [])
|
|
24
|
+
cron = schedules[0].get("schedule", {}).get("cron", "N/A") if schedules else "N/A"
|
|
25
|
+
rows.append([d.get("name", ""), cron, "Paused" if d.get("paused") else "Active", d.get("id", "")])
|
|
26
|
+
out.table("Deployments", [("Name", "cyan"), ("Schedule", ""), ("Status", ""), ("ID", "dim")], rows)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("pause")
|
|
30
|
+
def pause(ctx: typer.Context, name: str = typer.Argument(help="Deployment name")) -> None:
|
|
31
|
+
"""Pause a deployment schedule."""
|
|
32
|
+
actx: AppContext = ctx.obj
|
|
33
|
+
deps = actx.client.list_deployments()
|
|
34
|
+
dep = next((d for d in deps if d.get("name") == name), None)
|
|
35
|
+
if not dep:
|
|
36
|
+
actx.output.error(f"Deployment '{name}' not found")
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
actx.client.pause_deployment(dep["id"])
|
|
39
|
+
actx.output.success(f"Paused: {name}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command("resume")
|
|
43
|
+
def resume(ctx: typer.Context, name: str = typer.Argument(help="Deployment name")) -> None:
|
|
44
|
+
"""Resume a paused deployment."""
|
|
45
|
+
actx: AppContext = ctx.obj
|
|
46
|
+
deps = actx.client.list_deployments()
|
|
47
|
+
dep = next((d for d in deps if d.get("name") == name), None)
|
|
48
|
+
if not dep:
|
|
49
|
+
actx.output.error(f"Deployment '{name}' not found")
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
actx.client.resume_deployment(dep["id"])
|
|
52
|
+
actx.output.success(f"Resumed: {name}")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Flow management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from kctl_prefect.core.callbacks import AppContext
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Flow management", no_args_is_help=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("list")
|
|
13
|
+
def list_flows(ctx: typer.Context) -> None:
|
|
14
|
+
"""List all registered flows."""
|
|
15
|
+
actx: AppContext = ctx.obj
|
|
16
|
+
out = actx.output
|
|
17
|
+
flows = actx.client.list_flows()
|
|
18
|
+
if out.json_mode:
|
|
19
|
+
out.raw_json(flows)
|
|
20
|
+
return
|
|
21
|
+
rows = [[f.get("name", ""), f.get("id", "")] for f in flows]
|
|
22
|
+
out.table("Flows", [("Name", "cyan"), ("ID", "dim")], rows)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("run")
|
|
26
|
+
def run_flow(ctx: typer.Context, name: str = typer.Argument(help="Deployment name")) -> None:
|
|
27
|
+
"""Trigger an ad-hoc flow run."""
|
|
28
|
+
actx: AppContext = ctx.obj
|
|
29
|
+
out = actx.output
|
|
30
|
+
deployments = actx.client.list_deployments()
|
|
31
|
+
dep = next((d for d in deployments if d.get("name") == name), None)
|
|
32
|
+
if not dep:
|
|
33
|
+
out.error(f"Deployment '{name}' not found")
|
|
34
|
+
raise typer.Exit(1)
|
|
35
|
+
run = actx.client.create_flow_run(dep["id"])
|
|
36
|
+
out.success(f"Flow run created: {run.get('id', 'unknown')}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("history")
|
|
40
|
+
def history(ctx: typer.Context, name: str = typer.Argument(help="Flow name"), limit: int = 10) -> None:
|
|
41
|
+
"""Show recent runs for a flow."""
|
|
42
|
+
actx: AppContext = ctx.obj
|
|
43
|
+
out = actx.output
|
|
44
|
+
runs = actx.client.list_flow_runs(limit=limit)
|
|
45
|
+
if out.json_mode:
|
|
46
|
+
out.raw_json(runs)
|
|
47
|
+
return
|
|
48
|
+
rows = [[r.get("name", ""), r.get("state_type", ""), r.get("start_time", ""), r.get("id", "")] for r in runs]
|
|
49
|
+
out.table("Flow Runs", [("Name", "cyan"), ("State", ""), ("Start", ""), ("ID", "dim")], rows)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Health check commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from kctl_prefect.core.callbacks import AppContext
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Health checks", no_args_is_help=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("check")
|
|
13
|
+
def check(ctx: typer.Context) -> None:
|
|
14
|
+
"""Check Prefect server health."""
|
|
15
|
+
actx: AppContext = ctx.obj
|
|
16
|
+
out = actx.output
|
|
17
|
+
try:
|
|
18
|
+
actx.client.health()
|
|
19
|
+
out.success("Prefect server is healthy")
|
|
20
|
+
except Exception as e:
|
|
21
|
+
out.error(f"Prefect server unreachable: {e}")
|
|
22
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""S3 report archive browsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_prefect.core.callbacks import AppContext
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Report archive (S3)", no_args_is_help=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_reports(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
flow: str | None = typer.Option(None, help="Filter by flow name"),
|
|
18
|
+
prefix: str = typer.Option("reports/", help="S3 prefix"),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""List stored reports in S3."""
|
|
21
|
+
actx: AppContext = ctx.obj
|
|
22
|
+
out = actx.output
|
|
23
|
+
cfg = actx.config
|
|
24
|
+
|
|
25
|
+
import boto3
|
|
26
|
+
|
|
27
|
+
s3 = boto3.client(
|
|
28
|
+
"s3",
|
|
29
|
+
endpoint_url=cfg.s3_endpoint,
|
|
30
|
+
aws_access_key_id=cfg.s3_access_key,
|
|
31
|
+
aws_secret_access_key=cfg.s3_secret_key,
|
|
32
|
+
region_name=cfg.s3_region,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
search_prefix = f"reports/{flow}/" if flow else prefix
|
|
36
|
+
resp = s3.list_objects_v2(Bucket=cfg.s3_bucket, Prefix=search_prefix, MaxKeys=100)
|
|
37
|
+
objects = resp.get("Contents", [])
|
|
38
|
+
|
|
39
|
+
if out.json_mode:
|
|
40
|
+
out.raw_json([{"key": o["Key"], "size": o["Size"], "modified": str(o["LastModified"])} for o in objects])
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
rows = [[o["Key"], f"{o['Size']:,}", str(o["LastModified"])[:19]] for o in objects]
|
|
44
|
+
out.table("Reports", [("Key", "cyan"), ("Size", ""), ("Modified", "dim")], rows)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command("download")
|
|
48
|
+
def download(
|
|
49
|
+
ctx: typer.Context,
|
|
50
|
+
key: str = typer.Argument(help="S3 key"),
|
|
51
|
+
output_path: str = typer.Option(".", help="Local directory"),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Download a report from S3."""
|
|
54
|
+
actx: AppContext = ctx.obj
|
|
55
|
+
cfg = actx.config
|
|
56
|
+
|
|
57
|
+
import boto3
|
|
58
|
+
|
|
59
|
+
s3 = boto3.client(
|
|
60
|
+
"s3",
|
|
61
|
+
endpoint_url=cfg.s3_endpoint,
|
|
62
|
+
aws_access_key_id=cfg.s3_access_key,
|
|
63
|
+
aws_secret_access_key=cfg.s3_secret_key,
|
|
64
|
+
region_name=cfg.s3_region,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
filename = Path(key).name
|
|
68
|
+
local_path = Path(output_path) / filename
|
|
69
|
+
s3.download_file(cfg.s3_bucket, key, str(local_path))
|
|
70
|
+
actx.output.success(f"Downloaded: {local_path}")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Flow run inspection commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from kctl_prefect.core.callbacks import AppContext
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Flow run inspection", no_args_is_help=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("list")
|
|
13
|
+
def list_runs(
|
|
14
|
+
ctx: typer.Context,
|
|
15
|
+
limit: int = typer.Option(20, help="Max results"),
|
|
16
|
+
state: str | None = typer.Option(None, help="Filter by state"),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""List recent flow runs."""
|
|
19
|
+
actx: AppContext = ctx.obj
|
|
20
|
+
out = actx.output
|
|
21
|
+
runs = actx.client.list_flow_runs(limit=limit, state=state)
|
|
22
|
+
if out.json_mode:
|
|
23
|
+
out.raw_json(runs)
|
|
24
|
+
return
|
|
25
|
+
rows = [[r.get("name", ""), r.get("state_type", ""), r.get("start_time", ""), r.get("id", "")[:8]] for r in runs]
|
|
26
|
+
out.table("Flow Runs", [("Name", "cyan"), ("State", ""), ("Start", ""), ("ID", "dim")], rows)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("get")
|
|
30
|
+
def get_run(ctx: typer.Context, run_id: str = typer.Argument(help="Flow run ID")) -> None:
|
|
31
|
+
"""Get details of a flow run."""
|
|
32
|
+
actx: AppContext = ctx.obj
|
|
33
|
+
run = actx.client.get_flow_run(run_id)
|
|
34
|
+
if actx.output.json_mode:
|
|
35
|
+
actx.output.raw_json(run)
|
|
36
|
+
return
|
|
37
|
+
actx.output.detail(
|
|
38
|
+
f"Flow Run: {run.get('name', '')}",
|
|
39
|
+
sections=[
|
|
40
|
+
(
|
|
41
|
+
"Info",
|
|
42
|
+
[
|
|
43
|
+
("ID", run.get("id", "")),
|
|
44
|
+
("State", run.get("state_type", "")),
|
|
45
|
+
("Start", run.get("start_time", "")),
|
|
46
|
+
("End", run.get("end_time", "")),
|
|
47
|
+
("Duration", run.get("total_run_time", "")),
|
|
48
|
+
],
|
|
49
|
+
),
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("cancel")
|
|
55
|
+
def cancel(ctx: typer.Context, run_id: str = typer.Argument(help="Flow run ID")) -> None:
|
|
56
|
+
"""Cancel a running flow."""
|
|
57
|
+
actx: AppContext = ctx.obj
|
|
58
|
+
actx.client.cancel_flow_run(run_id)
|
|
59
|
+
actx.output.success(f"Cancelling: {run_id}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command("logs")
|
|
63
|
+
def logs(ctx: typer.Context, run_id: str = typer.Argument(help="Flow run ID"), limit: int = 100) -> None:
|
|
64
|
+
"""Show logs for a flow run."""
|
|
65
|
+
actx: AppContext = ctx.obj
|
|
66
|
+
log_entries = actx.client.get_flow_run_logs(run_id, limit=limit)
|
|
67
|
+
if actx.output.json_mode:
|
|
68
|
+
actx.output.raw_json(log_entries)
|
|
69
|
+
return
|
|
70
|
+
for entry in log_entries:
|
|
71
|
+
ts = entry.get("timestamp", "")[:19]
|
|
72
|
+
level = entry.get("level", 0)
|
|
73
|
+
msg = entry.get("message", "")
|
|
74
|
+
level_name = {10: "DEBUG", 20: "INFO", 30: "WARN", 40: "ERROR"}.get(level, str(level))
|
|
75
|
+
actx.output.text(f"[{ts}] {level_name}: {msg}")
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Application context with lazy client initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from kctl_lib.callbacks import AppContextBase
|
|
8
|
+
|
|
9
|
+
from kctl_prefect.core.client import PrefectClient
|
|
10
|
+
from kctl_prefect.core.config import ServiceConfig, get_service_config, resolve_active_profile_name
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class AppContext(AppContextBase):
|
|
15
|
+
_client: PrefectClient | None = field(default=None, repr=False)
|
|
16
|
+
_config: ServiceConfig | None = field(default=None, repr=False)
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def client(self) -> PrefectClient:
|
|
20
|
+
if self._client is None:
|
|
21
|
+
cfg = self.config
|
|
22
|
+
self._client = PrefectClient(base_url=cfg.url, credential=cfg.api_key)
|
|
23
|
+
return self._client
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def config(self) -> ServiceConfig:
|
|
27
|
+
if self._config is None:
|
|
28
|
+
profile = resolve_active_profile_name(self.profile)
|
|
29
|
+
self._config = get_service_config(profile)
|
|
30
|
+
return self._config
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Prefect REST API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kctl_lib.api_client import APIClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PrefectClient(APIClient):
|
|
9
|
+
AUTH_HEADER = "Authorization"
|
|
10
|
+
AUTH_PREFIX = "Bearer"
|
|
11
|
+
API_PREFIX = "/api"
|
|
12
|
+
|
|
13
|
+
def health(self) -> dict:
|
|
14
|
+
return self.get("/health")
|
|
15
|
+
|
|
16
|
+
def list_flows(self) -> list[dict]:
|
|
17
|
+
resp = self.post("/flows/filter", json={})
|
|
18
|
+
return resp if isinstance(resp, list) else []
|
|
19
|
+
|
|
20
|
+
def list_deployments(self) -> list[dict]:
|
|
21
|
+
resp = self.post("/deployments/filter", json={})
|
|
22
|
+
return resp if isinstance(resp, list) else []
|
|
23
|
+
|
|
24
|
+
def create_flow_run(self, deployment_id: str) -> dict:
|
|
25
|
+
return self.post(f"/deployments/{deployment_id}/create_flow_run", json={})
|
|
26
|
+
|
|
27
|
+
def list_flow_runs(self, limit: int = 20, state: str | None = None) -> list[dict]:
|
|
28
|
+
body: dict = {"sort": "EXPECTED_START_TIME_DESC", "limit": limit}
|
|
29
|
+
if state:
|
|
30
|
+
body["flow_runs"] = {"state": {"type": {"any_": [state.upper()]}}}
|
|
31
|
+
resp = self.post("/flow_runs/filter", json=body)
|
|
32
|
+
return resp if isinstance(resp, list) else []
|
|
33
|
+
|
|
34
|
+
def get_flow_run(self, flow_run_id: str) -> dict:
|
|
35
|
+
return self.get(f"/flow_runs/{flow_run_id}")
|
|
36
|
+
|
|
37
|
+
def cancel_flow_run(self, flow_run_id: str) -> None:
|
|
38
|
+
self.post(f"/flow_runs/{flow_run_id}/set_state", json={"state": {"type": "CANCELLING"}})
|
|
39
|
+
|
|
40
|
+
def get_flow_run_logs(self, flow_run_id: str, limit: int = 100) -> list[dict]:
|
|
41
|
+
resp = self.post(
|
|
42
|
+
"/logs/filter",
|
|
43
|
+
json={"logs": {"flow_run_id": {"any_": [flow_run_id]}}, "limit": limit, "sort": "TIMESTAMP_ASC"},
|
|
44
|
+
)
|
|
45
|
+
return resp if isinstance(resp, list) else []
|
|
46
|
+
|
|
47
|
+
def list_work_pools(self) -> list[dict]:
|
|
48
|
+
resp = self.post("/work_pools/filter", json={})
|
|
49
|
+
return resp if isinstance(resp, list) else []
|
|
50
|
+
|
|
51
|
+
def pause_deployment(self, deployment_id: str) -> None:
|
|
52
|
+
self.patch(f"/deployments/{deployment_id}", json={"paused": True})
|
|
53
|
+
|
|
54
|
+
def resume_deployment(self, deployment_id: str) -> None:
|
|
55
|
+
self.patch(f"/deployments/{deployment_id}", json={"paused": False})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Prefect service configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from kctl_lib.config import (
|
|
8
|
+
get_service_config as _get_service_config,
|
|
9
|
+
resolve_active_profile_name as _resolve_active_profile_name,
|
|
10
|
+
set_service_config as _set_service_config,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
SERVICE_KEY = "prefect"
|
|
14
|
+
ENV_PREFIX = "KCTL_PREFECT"
|
|
15
|
+
|
|
16
|
+
VALID_FIELDS = {"url", "api_key", "s3_bucket", "s3_endpoint", "s3_access_key", "s3_secret_key", "s3_region"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ServiceConfig(BaseModel):
|
|
20
|
+
url: str = ""
|
|
21
|
+
api_key: str = ""
|
|
22
|
+
s3_bucket: str = ""
|
|
23
|
+
s3_endpoint: str = ""
|
|
24
|
+
s3_access_key: str = ""
|
|
25
|
+
s3_secret_key: str = ""
|
|
26
|
+
s3_region: str = "fsn1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_service_config(profile_name: str) -> ServiceConfig:
|
|
30
|
+
data = _get_service_config(profile_name, SERVICE_KEY, VALID_FIELDS)
|
|
31
|
+
return ServiceConfig(**data) if data else ServiceConfig()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_service_config(profile_name: str, svc_config: ServiceConfig) -> None:
|
|
35
|
+
cleaned = {k: v for k, v in svc_config.model_dump().items() if v}
|
|
36
|
+
_set_service_config(profile_name, SERVICE_KEY, cleaned)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_active_profile_name(profile_name: str | None = None) -> str:
|
|
40
|
+
return _resolve_active_profile_name(profile_name, ENV_PREFIX)
|
|
File without changes
|