kctl-prefect 0.5.2__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ """kctl-prefect — CLI for kodemeio Prefect operations."""
kctl_prefect/cli.py ADDED
@@ -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)
@@ -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,17 @@
1
+ kctl_prefect/__init__.py,sha256=cqCM4M7TOtP_qgbSgBVK6jt3OUyAi0oOFGcQcnWyCzw,60
2
+ kctl_prefect/cli.py,sha256=8nt4QvljSLd3-ylDXyKgB37OC5PXzGaEKxmTrA9lS9I,1957
3
+ kctl_prefect/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ kctl_prefect/commands/config_cmd.py,sha256=I853L76ZIuyVpS1U2V07hea-j3zmukFf9WdtJ73zSIc,1238
5
+ kctl_prefect/commands/deployments.py,sha256=S0HzJXujmz2V2iYLm2BoZhHfE_kt57FsTFL5F9ihrxU,1837
6
+ kctl_prefect/commands/flows.py,sha256=2brI1TtOAUs_JYhAAFR2tQE5f_19aQ7BqPTWZswg41I,1674
7
+ kctl_prefect/commands/health.py,sha256=8Wi-Fq8-OgzFP36xegrdCSttdKvgTdujqKCeV-mGD4k,545
8
+ kctl_prefect/commands/reports.py,sha256=1Z3n3Y1OHP7SCffOfT4LC7cIZHpkTqo6ak3GCVRXecs,1988
9
+ kctl_prefect/commands/runs.py,sha256=TUN4FEzQcx1WG7tMuWqbNXRiqSH8O0arpYcl45-qmls,2535
10
+ kctl_prefect/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ kctl_prefect/core/callbacks.py,sha256=ao8S7hUiOF83DTxgti2mdD9ABK8Q2YqNiCSW8JO6OkI,981
12
+ kctl_prefect/core/client.py,sha256=-zlCmTaLwnMhbYcA4Wqm3eGxqj57hnmUSE2uzJ9Lclo,2117
13
+ kctl_prefect/core/config.py,sha256=fhprAW21-3d_iRdAiCBj_0WhPX8xVQ8EwoBhsnw4zvg,1215
14
+ kctl_prefect-0.5.2.dist-info/METADATA,sha256=jiNmslTUFdTxU_KVzZfTcjiQc0-f6RZfq-MhO1gaCxU,468
15
+ kctl_prefect-0.5.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
16
+ kctl_prefect-0.5.2.dist-info/entry_points.txt,sha256=UXy9d5xrY7RRViukBUiQD7LfH2Yrst3zlj4nvmVJD3s,55
17
+ kctl_prefect-0.5.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kctl-prefect = kctl_prefect.cli:_run