tsdb-sql-tool 0.1.0__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.
- sql_tool/__about__.py +1 -0
- sql_tool/__init__.py +5 -0
- sql_tool/__main__.py +6 -0
- sql_tool/cli/__init__.py +0 -0
- sql_tool/cli/commands/__init__.py +0 -0
- sql_tool/cli/commands/_shared.py +125 -0
- sql_tool/cli/commands/config.py +118 -0
- sql_tool/cli/commands/query.py +35 -0
- sql_tool/cli/commands/service.py +94 -0
- sql_tool/cli/commands/ts.py +1019 -0
- sql_tool/cli/helpers.py +121 -0
- sql_tool/cli/main.py +924 -0
- sql_tool/cli/output.py +64 -0
- sql_tool/core/__init__.py +0 -0
- sql_tool/core/client.py +133 -0
- sql_tool/core/config.py +278 -0
- sql_tool/core/exceptions.py +41 -0
- sql_tool/core/exit_codes.py +19 -0
- sql_tool/core/logging.py +52 -0
- sql_tool/core/models.py +30 -0
- sql_tool/core/monitoring.py +22 -0
- sql_tool/core/postgres.py +510 -0
- sql_tool/core/query_source.py +43 -0
- sql_tool/core/timescaledb.py +490 -0
- sql_tool/formatters/__init__.py +6 -0
- sql_tool/formatters/base.py +54 -0
- sql_tool/formatters/csv.py +36 -0
- sql_tool/formatters/json.py +41 -0
- sql_tool/formatters/table.py +53 -0
- tsdb_sql_tool-0.1.0.dist-info/METADATA +309 -0
- tsdb_sql_tool-0.1.0.dist-info/RECORD +34 -0
- tsdb_sql_tool-0.1.0.dist-info/WHEEL +4 -0
- tsdb_sql_tool-0.1.0.dist-info/entry_points.txt +2 -0
- tsdb_sql_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
sql_tool/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
sql_tool/__init__.py
ADDED
sql_tool/__main__.py
ADDED
sql_tool/cli/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Shared CLI plumbing for command modules.
|
|
2
|
+
|
|
3
|
+
Client creation, format-option handling, and output helpers.
|
|
4
|
+
Distinct from cli.helpers which contains pure data-formatting functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from sql_tool.cli.helpers import fmt_size
|
|
13
|
+
from sql_tool.cli.output import get_formatter, resolve_format, write_output
|
|
14
|
+
from sql_tool.core.client import PgClient
|
|
15
|
+
from sql_tool.core.config import load_config, resolve_config
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from sql_tool.cli.output import OutputFormat
|
|
21
|
+
from sql_tool.core.models import QueryResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_client(ctx: typer.Context, timeout: float | None = None) -> PgClient:
|
|
25
|
+
obj = ctx.ensure_object(dict)
|
|
26
|
+
config = load_config(obj.get("config_file"))
|
|
27
|
+
|
|
28
|
+
cli_overrides: dict[str, Any] = {}
|
|
29
|
+
for key in ("host", "port", "database", "user", "password", "schema"):
|
|
30
|
+
val = obj.get(key)
|
|
31
|
+
if val is not None:
|
|
32
|
+
cli_overrides[key] = val
|
|
33
|
+
if timeout is not None:
|
|
34
|
+
cli_overrides["timeout"] = timeout
|
|
35
|
+
|
|
36
|
+
resolved = resolve_config(
|
|
37
|
+
config,
|
|
38
|
+
profile_name=obj.get("profile"),
|
|
39
|
+
dsn=obj.get("dsn"),
|
|
40
|
+
**cli_overrides,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return PgClient(resolved)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_options(ctx: typer.Context) -> dict[str, Any]:
|
|
47
|
+
obj = ctx.ensure_object(dict)
|
|
48
|
+
return {
|
|
49
|
+
"format_flag": obj.get("format"),
|
|
50
|
+
"compact": obj.get("compact", False),
|
|
51
|
+
"width": obj.get("width", 40),
|
|
52
|
+
"no_header": obj.get("no_header", False),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def output_result(ctx: typer.Context, result: QueryResult) -> None:
|
|
57
|
+
opts = format_options(ctx)
|
|
58
|
+
formatter = get_formatter(**opts)
|
|
59
|
+
write_output(formatter, result)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def apply_local_format_options(
|
|
63
|
+
ctx: typer.Context,
|
|
64
|
+
*,
|
|
65
|
+
format: OutputFormat | None = None,
|
|
66
|
+
table: bool = False,
|
|
67
|
+
compact: bool = False,
|
|
68
|
+
width: int | None = None,
|
|
69
|
+
no_header: bool = False,
|
|
70
|
+
) -> None:
|
|
71
|
+
if format is not None or table or compact or width is not None or no_header:
|
|
72
|
+
obj = ctx.ensure_object(dict)
|
|
73
|
+
if format is not None:
|
|
74
|
+
obj["format"] = format.value
|
|
75
|
+
if table:
|
|
76
|
+
obj["format"] = "table"
|
|
77
|
+
if compact:
|
|
78
|
+
obj["compact"] = compact
|
|
79
|
+
if width is not None:
|
|
80
|
+
obj["width"] = width
|
|
81
|
+
if no_header:
|
|
82
|
+
obj["no_header"] = no_header
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_table_format(ctx: typer.Context) -> bool:
|
|
86
|
+
obj = ctx.ensure_object(dict)
|
|
87
|
+
return resolve_format(obj.get("format")) == "table"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def size_formatter(ctx: typer.Context) -> tuple[Any, bool]:
|
|
91
|
+
is_tbl = is_table_format(ctx)
|
|
92
|
+
fmt = fmt_size if is_tbl else (lambda b: str(b or 0))
|
|
93
|
+
return fmt, is_tbl
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_table_arg(table_arg: str) -> tuple[str, str]:
|
|
97
|
+
if "." in table_arg:
|
|
98
|
+
schema, table = table_arg.split(".", 1)
|
|
99
|
+
return schema, table
|
|
100
|
+
return "public", table_arg
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def preprocess_optional_int_flags() -> None:
|
|
104
|
+
"""Insert default '10' after bare --head/--tail/--sample when used without a value.
|
|
105
|
+
|
|
106
|
+
Typer 0.23 doesn't support Click's flag_value parameter, so these options
|
|
107
|
+
always require an explicit argument. This preprocessor lets users write
|
|
108
|
+
``--head`` instead of ``--head 10`` by inserting the default before Typer
|
|
109
|
+
parses argv.
|
|
110
|
+
"""
|
|
111
|
+
if "table" not in sys.argv:
|
|
112
|
+
return
|
|
113
|
+
flags = {"--head", "--tail", "--sample"}
|
|
114
|
+
new_argv: list[str] = []
|
|
115
|
+
i = 0
|
|
116
|
+
while i < len(sys.argv):
|
|
117
|
+
new_argv.append(sys.argv[i])
|
|
118
|
+
if sys.argv[i] in flags:
|
|
119
|
+
next_is_value = i + 1 < len(sys.argv) and not sys.argv[i + 1].startswith(
|
|
120
|
+
"-"
|
|
121
|
+
)
|
|
122
|
+
if not next_is_value:
|
|
123
|
+
new_argv.append("10")
|
|
124
|
+
i += 1
|
|
125
|
+
sys.argv = new_argv
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Configuration management CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from sql_tool.core.config import DEFAULT_CONFIG_PATH, load_config, resolve_config
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from sql_tool.core.config import ResolvedConfig
|
|
15
|
+
|
|
16
|
+
config_app = typer.Typer(help="Configuration management commands")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@config_app.callback(invoke_without_command=True)
|
|
20
|
+
def config_callback(ctx: typer.Context) -> None:
|
|
21
|
+
if not ctx.invoked_subcommand:
|
|
22
|
+
typer.echo(ctx.get_help())
|
|
23
|
+
raise typer.Exit()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_resolved_config(ctx: typer.Context) -> tuple[ResolvedConfig, Path | None]:
|
|
27
|
+
config_path: Path | None = ctx.obj.get("config_file")
|
|
28
|
+
app_config = load_config(config_path)
|
|
29
|
+
resolved = resolve_config(
|
|
30
|
+
app_config,
|
|
31
|
+
profile_name=ctx.obj.get("profile"),
|
|
32
|
+
dsn=ctx.obj.get("dsn"),
|
|
33
|
+
host=ctx.obj.get("host"),
|
|
34
|
+
port=ctx.obj.get("port"),
|
|
35
|
+
database=ctx.obj.get("database"),
|
|
36
|
+
user=ctx.obj.get("user"),
|
|
37
|
+
password=ctx.obj.get("password"),
|
|
38
|
+
)
|
|
39
|
+
return resolved, config_path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _mask_password(value: str | None) -> str:
|
|
43
|
+
if value is None:
|
|
44
|
+
return "not set"
|
|
45
|
+
return "***"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@config_app.command("show")
|
|
49
|
+
def config_show(ctx: typer.Context) -> None:
|
|
50
|
+
"""Display resolved configuration with source attribution."""
|
|
51
|
+
resolved, config_path = _get_resolved_config(ctx)
|
|
52
|
+
sources = resolved.sources
|
|
53
|
+
|
|
54
|
+
typer.echo("Connection Settings (resolved):")
|
|
55
|
+
connection_fields = [
|
|
56
|
+
("host", resolved.host),
|
|
57
|
+
("port", str(resolved.port)),
|
|
58
|
+
("database", resolved.dbname),
|
|
59
|
+
("user", resolved.user or "not set"),
|
|
60
|
+
("password", _mask_password(resolved.password)),
|
|
61
|
+
("sslmode", resolved.sslmode),
|
|
62
|
+
]
|
|
63
|
+
for field_name, value in connection_fields:
|
|
64
|
+
source_key = "dbname" if field_name == "database" else field_name
|
|
65
|
+
source = sources.get(source_key, "default")
|
|
66
|
+
typer.echo(f" {field_name}: {value} ({source})")
|
|
67
|
+
|
|
68
|
+
typer.echo("")
|
|
69
|
+
typer.echo("General:")
|
|
70
|
+
timeout_source = sources.get("default_timeout", "default")
|
|
71
|
+
typer.echo(f" timeout: {resolved.default_timeout}s ({timeout_source})")
|
|
72
|
+
format_source = sources.get("default_format", "default")
|
|
73
|
+
typer.echo(f" format: {resolved.default_format} ({format_source})")
|
|
74
|
+
|
|
75
|
+
typer.echo("")
|
|
76
|
+
if resolved.active_profile:
|
|
77
|
+
typer.echo(f"Active Profile: {resolved.active_profile}")
|
|
78
|
+
else:
|
|
79
|
+
typer.echo("Active Profile: none")
|
|
80
|
+
|
|
81
|
+
display_path = config_path or DEFAULT_CONFIG_PATH
|
|
82
|
+
typer.echo(f"Config File: {display_path}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@config_app.command("profiles")
|
|
86
|
+
def config_profiles(ctx: typer.Context) -> None:
|
|
87
|
+
"""List available connection profiles."""
|
|
88
|
+
config_path: Path | None = ctx.obj.get("config_file")
|
|
89
|
+
app_config = load_config(config_path)
|
|
90
|
+
active_profile = ctx.obj.get("profile") or None
|
|
91
|
+
|
|
92
|
+
if not app_config.profiles:
|
|
93
|
+
typer.echo("No profiles configured.")
|
|
94
|
+
display_path = config_path or DEFAULT_CONFIG_PATH
|
|
95
|
+
typer.echo(f"Add profiles to: {display_path}")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
typer.echo("Available Profiles:")
|
|
99
|
+
typer.echo("")
|
|
100
|
+
for name, profile in sorted(app_config.profiles.items()):
|
|
101
|
+
is_active = name == active_profile
|
|
102
|
+
marker = "* " if is_active else " "
|
|
103
|
+
label = " (active)" if is_active else ""
|
|
104
|
+
typer.echo(f"{marker}{name}{label}")
|
|
105
|
+
|
|
106
|
+
display_fields = [
|
|
107
|
+
("host", profile.host),
|
|
108
|
+
("port", str(profile.port)),
|
|
109
|
+
("database", profile.dbname),
|
|
110
|
+
]
|
|
111
|
+
if profile.user:
|
|
112
|
+
display_fields.append(("user", profile.user))
|
|
113
|
+
if profile.sslmode != "prefer":
|
|
114
|
+
display_fields.append(("sslmode", profile.sslmode))
|
|
115
|
+
|
|
116
|
+
for field_name, value in display_fields:
|
|
117
|
+
typer.echo(f" {field_name}: {value}")
|
|
118
|
+
typer.echo("")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from sql_tool.cli.commands._shared import get_client, output_result
|
|
9
|
+
from sql_tool.core.query_source import resolve_query_source
|
|
10
|
+
|
|
11
|
+
log = structlog.get_logger()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def query_command(
|
|
15
|
+
ctx: typer.Context,
|
|
16
|
+
file: Annotated[
|
|
17
|
+
str | None,
|
|
18
|
+
typer.Argument(help="SQL file to execute"),
|
|
19
|
+
] = None,
|
|
20
|
+
execute: Annotated[
|
|
21
|
+
str | None,
|
|
22
|
+
typer.Option("--execute", "-e", help="Execute inline SQL query"),
|
|
23
|
+
] = None,
|
|
24
|
+
timeout: Annotated[
|
|
25
|
+
float | None,
|
|
26
|
+
typer.Option("--timeout", "-t", help="Query timeout in seconds"),
|
|
27
|
+
] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Execute a SQL query from file, inline (-e), or stdin."""
|
|
30
|
+
sql = resolve_query_source(inline=execute, file_path=file)
|
|
31
|
+
|
|
32
|
+
with get_client(ctx, timeout=timeout) as client:
|
|
33
|
+
result = client.execute_query(sql)
|
|
34
|
+
|
|
35
|
+
output_result(ctx, result)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Service and maintenance CLI commands.
|
|
2
|
+
|
|
3
|
+
Thin CLI layer: typer decorators, argument parsing, output formatting.
|
|
4
|
+
Business logic delegated to core.postgres module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from sql_tool.cli.commands._shared import get_client, output_result
|
|
14
|
+
from sql_tool.core.postgres import (
|
|
15
|
+
check_server,
|
|
16
|
+
kill_backend,
|
|
17
|
+
list_user_tables,
|
|
18
|
+
vacuum_tables,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
service_app = typer.Typer(help="Service and maintenance commands")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@service_app.callback(invoke_without_command=True)
|
|
25
|
+
def service_callback(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
) -> None:
|
|
28
|
+
if not ctx.invoked_subcommand:
|
|
29
|
+
typer.echo(ctx.get_help())
|
|
30
|
+
raise typer.Exit()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@service_app.command("vacuum")
|
|
34
|
+
def vacuum_command(
|
|
35
|
+
ctx: typer.Context,
|
|
36
|
+
table_arg: Annotated[
|
|
37
|
+
str | None,
|
|
38
|
+
typer.Argument(help="Table name to vacuum"),
|
|
39
|
+
] = None,
|
|
40
|
+
full: Annotated[
|
|
41
|
+
bool,
|
|
42
|
+
typer.Option("--full", help="Run VACUUM FULL (locks table, rewrites)"),
|
|
43
|
+
] = False,
|
|
44
|
+
all_tables: Annotated[
|
|
45
|
+
bool,
|
|
46
|
+
typer.Option("--all", help="Vacuum all user tables"),
|
|
47
|
+
] = False,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""VACUUM marks dead rows as reusable. VACUUM FULL rewrites tables but requires exclusive lock."""
|
|
50
|
+
if not table_arg and not all_tables:
|
|
51
|
+
typer.echo("Error: Must specify table name or --all", err=True)
|
|
52
|
+
raise typer.Exit(2)
|
|
53
|
+
|
|
54
|
+
with get_client(ctx) as client:
|
|
55
|
+
table_names = list_user_tables(client) if all_tables else [table_arg] # type: ignore[list-item]
|
|
56
|
+
|
|
57
|
+
count = vacuum_tables(client, table_names, full=full)
|
|
58
|
+
|
|
59
|
+
typer.echo(f"Vacuumed {count} table(s)")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@service_app.command("kill")
|
|
63
|
+
def kill_command(
|
|
64
|
+
ctx: typer.Context,
|
|
65
|
+
pid: Annotated[int, typer.Argument(help="Backend process ID to terminate")],
|
|
66
|
+
cancel: Annotated[
|
|
67
|
+
bool,
|
|
68
|
+
typer.Option("--cancel", help="Cancel query only (don't kill connection)"),
|
|
69
|
+
] = False,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""pg_terminate_backend() kills the connection. pg_cancel_backend() cancels query but keeps connection alive."""
|
|
72
|
+
with get_client(ctx) as client:
|
|
73
|
+
success = kill_backend(client, pid, cancel=cancel)
|
|
74
|
+
|
|
75
|
+
action = "Cancelled" if cancel else "Terminated"
|
|
76
|
+
if success:
|
|
77
|
+
typer.echo(f"{action} backend {pid}")
|
|
78
|
+
else:
|
|
79
|
+
typer.echo(f"Error: Backend {pid} not found or already terminated", err=True)
|
|
80
|
+
raise typer.Exit(1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@service_app.command("check")
|
|
84
|
+
def check_command(ctx: typer.Context) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Check PostgreSQL server connectivity and version.
|
|
87
|
+
|
|
88
|
+
Reports server version, current database, connected user, and uptime.
|
|
89
|
+
Useful for verifying connection parameters and server availability.
|
|
90
|
+
"""
|
|
91
|
+
with get_client(ctx) as client:
|
|
92
|
+
result = check_server(client)
|
|
93
|
+
|
|
94
|
+
output_result(ctx, result)
|