kctl-pg 0.7.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.
kctl_pg/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Kodemeio PostgreSQL CLI."""
2
+
3
+ __version__ = "0.1.0"
kctl_pg/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m kctl_pg."""
2
+
3
+ from kctl_pg.cli import _run
4
+
5
+ _run()
kctl_pg/cli.py ADDED
@@ -0,0 +1,162 @@
1
+ """Main CLI entry point for kctl-pg."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from kctl_lib import handle_cli_error
10
+
11
+ from kctl_pg import __version__
12
+ from kctl_pg.commands.activity import app as activity_app
13
+ from kctl_pg.commands.automation import app as automation_app
14
+ from kctl_pg.commands.backup import app as backup_app
15
+ from kctl_pg.commands.config_cmd import app as config_app
16
+ from kctl_pg.commands.dashboard import app as dashboard_app
17
+ from kctl_pg.commands.db import app as db_app
18
+ from kctl_pg.commands.dr import app as dr_app
19
+ from kctl_pg.commands.extensions import app as extensions_app
20
+ from kctl_pg.commands.health import app as health_app
21
+ from kctl_pg.commands.indexes import app as indexes_app
22
+ from kctl_pg.commands.lint import app as lint_app
23
+ from kctl_pg.commands.maintenance import app as maintenance_app
24
+ from kctl_pg.commands.performance import app as performance_app
25
+ from kctl_pg.commands.pg_config import app as pg_config_app
26
+ from kctl_pg.commands.pgbouncer import app as pgbouncer_app
27
+ from kctl_pg.commands.pipeline import app as pipeline_app
28
+ from kctl_pg.commands.query import app as query_app
29
+ from kctl_pg.commands.replication import app as replication_app
30
+ from kctl_pg.commands.schemas import app as schemas_app
31
+ from kctl_pg.commands.security import app as security_app
32
+ from kctl_pg.commands.stats import app as stats_app
33
+ from kctl_pg.commands.tables import app as tables_app
34
+ from kctl_pg.commands.users import app as users_app
35
+ from kctl_pg.core.callbacks import AppContext
36
+ from kctl_pg.core.exceptions import KctlError
37
+ from kctl_pg.commands.skill_cmd import app as skill_app
38
+ from kctl_pg.commands.doctor_cmd import app as doctor_app
39
+ from kctl_lib.self_update import notify_if_outdated
40
+ from kctl_lib.tui import add_tui_command
41
+
42
+
43
+ def version_callback(value: bool) -> None:
44
+ if value:
45
+ typer.echo(f"kctl-pg {__version__}")
46
+ raise typer.Exit()
47
+
48
+
49
+ app = typer.Typer(
50
+ name="kctl-pg",
51
+ help="Kodemeio PostgreSQL CLI - manage PostgreSQL servers via SSH tunnel.",
52
+ no_args_is_help=True,
53
+ rich_markup_mode="rich",
54
+ pretty_exceptions_enable=False,
55
+ )
56
+
57
+
58
+ @app.callback()
59
+ def main(
60
+ ctx: typer.Context,
61
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
62
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
63
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
64
+ host: Annotated[str | None, typer.Option("--host", "-H", help="PostgreSQL host override")] = None,
65
+ port: Annotated[int | None, typer.Option("--port", help="PostgreSQL port override")] = None,
66
+ user: Annotated[str | None, typer.Option("--user", "-U", help="PostgreSQL user override")] = None,
67
+ password: Annotated[str | None, typer.Option("--password", help="PostgreSQL password override")] = None,
68
+ version: Annotated[
69
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
70
+ ] = False,
71
+ ) -> None:
72
+ """Kodemeio PostgreSQL CLI."""
73
+ ctx.ensure_object(dict)
74
+ ctx.obj = AppContext(
75
+ json_mode=json_output,
76
+ quiet=quiet,
77
+ profile=profile,
78
+ host_override=host,
79
+ port_override=port,
80
+ user_override=user,
81
+ password_override=password,
82
+ )
83
+ notify_if_outdated(ctx.obj.output, "kctl-pg", __version__)
84
+
85
+
86
+ # Register all command groups
87
+ app.add_typer(config_app, name="config")
88
+ app.add_typer(db_app, name="db")
89
+ app.add_typer(users_app, name="users")
90
+ app.add_typer(health_app, name="health")
91
+ app.add_typer(dashboard_app, name="dashboard")
92
+ app.add_typer(query_app, name="query")
93
+ app.add_typer(activity_app, name="activity")
94
+ app.add_typer(backup_app, name="backup")
95
+ app.add_typer(extensions_app, name="extensions")
96
+ app.add_typer(maintenance_app, name="maintenance")
97
+ app.add_typer(performance_app, name="performance")
98
+ app.add_typer(pg_config_app, name="pg-config")
99
+ app.add_typer(replication_app, name="replication")
100
+ app.add_typer(security_app, name="security")
101
+ app.add_typer(stats_app, name="stats")
102
+ app.add_typer(tables_app, name="tables")
103
+ app.add_typer(indexes_app, name="indexes")
104
+ app.add_typer(schemas_app, name="schemas")
105
+ app.add_typer(pgbouncer_app, name="pgbouncer")
106
+ app.add_typer(lint_app, name="lint")
107
+ app.add_typer(automation_app, name="automation")
108
+ app.add_typer(dr_app, name="dr")
109
+ app.add_typer(pipeline_app, name="pipeline")
110
+ app.add_typer(skill_app, name="skill", hidden=True)
111
+ app.add_typer(doctor_app, name="doctor")
112
+ add_tui_command(app, service_key="pg", version=__version__)
113
+
114
+
115
+ @app.command("self-update")
116
+ def self_update_cmd(ctx: typer.Context) -> None:
117
+ """Check for updates and upgrade kctl-pg."""
118
+ actx = ctx.obj
119
+ out = actx.output
120
+
121
+ from kctl_lib.self_update import check_update
122
+ from kctl_lib.self_update import update as do_update
123
+
124
+ latest = check_update("kctl-pg", __version__)
125
+ if latest:
126
+ out.info(f"Updating to {latest}...")
127
+ do_update("kctl-pg")
128
+ out.success(f"Updated to {latest}")
129
+ else:
130
+ out.success("Already up to date")
131
+
132
+
133
+ @app.command()
134
+ def completions(
135
+ shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
136
+ install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
137
+ ) -> None:
138
+ """Generate or install shell completions."""
139
+ from kctl_lib.completions import get_completion_script, install_completions
140
+
141
+ if install:
142
+ path = install_completions("kctl-pg", shell)
143
+ if path:
144
+ typer.echo(f"Completions installed to {path}")
145
+ else:
146
+ typer.echo(f"Could not install completions for {shell}", err=True)
147
+ raise typer.Exit(code=1)
148
+ else:
149
+ script = get_completion_script("kctl-pg", shell)
150
+ typer.echo(script)
151
+
152
+
153
+ def _run() -> None:
154
+ """Entry point with error handling."""
155
+ try:
156
+ app()
157
+ except KctlError as e:
158
+ handle_cli_error(e)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ _run()
File without changes
@@ -0,0 +1,176 @@
1
+ """Connection and activity monitoring commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_pg.core.callbacks import AppContext
10
+
11
+ app = typer.Typer(help="Monitor connections and activity.")
12
+
13
+
14
+ @app.command("list")
15
+ def list_(
16
+ ctx: typer.Context,
17
+ database: Annotated[str | None, typer.Option("--db", "-d", help="Filter by database")] = None,
18
+ state: Annotated[
19
+ str | None, typer.Option("--state", help="Filter by state (active, idle, idle in transaction)")
20
+ ] = None,
21
+ ) -> None:
22
+ """List active connections."""
23
+ actx: AppContext = ctx.obj
24
+ out = actx.output
25
+ c = actx.client
26
+
27
+ sql = """
28
+ SELECT
29
+ pid,
30
+ datname AS database,
31
+ usename AS user,
32
+ client_addr,
33
+ state,
34
+ now() - backend_start AS backend_age,
35
+ now() - query_start AS query_duration,
36
+ left(query, 100) AS query
37
+ FROM pg_stat_activity
38
+ WHERE backend_type = 'client backend'
39
+ """
40
+ params: list = []
41
+
42
+ if database:
43
+ sql += " AND datname = %s"
44
+ params.append(database)
45
+ if state:
46
+ sql += " AND state = %s"
47
+ params.append(state)
48
+
49
+ sql += " ORDER BY backend_start"
50
+
51
+ rows_data = c.fetchall(sql, tuple(params) if params else None)
52
+
53
+ rows = [
54
+ [
55
+ str(r["pid"]),
56
+ r["database"] or "-",
57
+ r["user"] or "-",
58
+ str(r["client_addr"]) if r["client_addr"] else "-",
59
+ r["state"] or "-",
60
+ str(r["query_duration"]).split(".")[0] if r["query_duration"] else "-",
61
+ r["query"][:60] if r["query"] else "-",
62
+ ]
63
+ for r in rows_data
64
+ ]
65
+
66
+ out.table(
67
+ f"Connections ({len(rows_data)})",
68
+ [
69
+ ("PID", ""),
70
+ ("Database", "cyan"),
71
+ ("User", ""),
72
+ ("Client", ""),
73
+ ("State", ""),
74
+ ("Duration", "yellow"),
75
+ ("Query", "dim"),
76
+ ],
77
+ rows,
78
+ data_for_json=rows_data,
79
+ )
80
+
81
+
82
+ @app.command()
83
+ def kill(
84
+ ctx: typer.Context,
85
+ pid: Annotated[int, typer.Argument(help="Backend PID to terminate")],
86
+ force: Annotated[
87
+ bool, typer.Option("--force", help="Use pg_terminate_backend (instead of pg_cancel_backend)")
88
+ ] = False,
89
+ ) -> None:
90
+ """Cancel or terminate a backend process."""
91
+ actx: AppContext = ctx.obj
92
+ out = actx.output
93
+ c = actx.client
94
+
95
+ if force:
96
+ result = c.fetchval("SELECT pg_terminate_backend(%s)", (pid,))
97
+ action = "Terminated"
98
+ else:
99
+ result = c.fetchval("SELECT pg_cancel_backend(%s)", (pid,))
100
+ action = "Cancelled"
101
+
102
+ if result:
103
+ out.success(f"{action} backend PID {pid}")
104
+ else:
105
+ out.error(f"Failed to {action.lower()} PID {pid} (process may not exist)")
106
+
107
+
108
+ @app.command()
109
+ def locks(
110
+ ctx: typer.Context,
111
+ database: Annotated[str | None, typer.Option("--db", "-d", help="Filter by database")] = None,
112
+ ) -> None:
113
+ """Show lock contention (blocked queries waiting for locks)."""
114
+ actx: AppContext = ctx.obj
115
+ out = actx.output
116
+ c = actx.client
117
+
118
+ rows_data = c.fetchall("""
119
+ SELECT
120
+ blocked.pid AS blocked_pid,
121
+ blocked.usename AS blocked_user,
122
+ blocked_activity.query AS blocked_query,
123
+ blocking.pid AS blocking_pid,
124
+ blocking.usename AS blocking_user,
125
+ blocking_activity.query AS blocking_query,
126
+ blocked.datname AS database
127
+ FROM pg_catalog.pg_locks blocked
128
+ JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked.pid
129
+ JOIN pg_catalog.pg_locks blocking ON blocking.locktype = blocked.locktype
130
+ AND blocking.database IS NOT DISTINCT FROM blocked.database
131
+ AND blocking.relation IS NOT DISTINCT FROM blocked.relation
132
+ AND blocking.page IS NOT DISTINCT FROM blocked.page
133
+ AND blocking.tuple IS NOT DISTINCT FROM blocked.tuple
134
+ AND blocking.virtualxid IS NOT DISTINCT FROM blocked.virtualxid
135
+ AND blocking.transactionid IS NOT DISTINCT FROM blocked.transactionid
136
+ AND blocking.classid IS NOT DISTINCT FROM blocked.classid
137
+ AND blocking.objid IS NOT DISTINCT FROM blocked.objid
138
+ AND blocking.objsubid IS NOT DISTINCT FROM blocked.objsubid
139
+ AND blocking.pid != blocked.pid
140
+ JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking.pid
141
+ JOIN pg_stat_activity blocked_sa ON blocked_sa.pid = blocked.pid
142
+ WHERE NOT blocked.granted
143
+ """)
144
+
145
+ if database:
146
+ rows_data = [r for r in rows_data if r["database"] == database]
147
+
148
+ if not rows_data:
149
+ out.success("No lock contention detected")
150
+ return
151
+
152
+ rows = [
153
+ [
154
+ str(r["blocked_pid"]),
155
+ r["blocked_user"] or "-",
156
+ (r["blocked_query"] or "-")[:50],
157
+ str(r["blocking_pid"]),
158
+ r["blocking_user"] or "-",
159
+ (r["blocking_query"] or "-")[:50],
160
+ ]
161
+ for r in rows_data
162
+ ]
163
+
164
+ out.table(
165
+ f"Lock Contention ({len(rows_data)})",
166
+ [
167
+ ("Blocked PID", "red"),
168
+ ("User", ""),
169
+ ("Blocked Query", "dim"),
170
+ ("Blocking PID", "yellow"),
171
+ ("User", ""),
172
+ ("Blocking Query", "dim"),
173
+ ],
174
+ rows,
175
+ data_for_json=rows_data,
176
+ )