diff --git a/AUTHORS b/AUTHORS index 4bbaba2..40d7f58 100644 --- a/AUTHORS +++ b/AUTHORS @@ -137,7 +137,10 @@ Contributors: * Chris Rose (offbyone/offby1) * Mathieu Dupuy (deronnax) * Chris Novakovic + * Max Smolin (maximsmol) * Josh Lynch (josh-lynch) + * Fabio (3ximus) + * Doug Harris (dougharris) Creator: -------- diff --git a/changelog.rst b/changelog.rst index 0b219ea..dcf886a 100644 --- a/changelog.rst +++ b/changelog.rst @@ -1,3 +1,10 @@ +4.3.0 (2025-03-22) +================== + +Features +-------- +* The session time zone setting is set to the system time zone by default + 4.2.0 (2025-03-06) ================== @@ -5,6 +12,8 @@ Features -------- * Add a `--ping` command line option; allows pgcli to replace `pg_isready` * Changed the packaging metadata from setup.py to pyproject.toml +* Add bash completion for services defined in the service file `~/.pg_service.conf` +* Added support for per-column date/time formatting using `column_date_formats` in config Bug fixes: ---------- diff --git a/pgcli-completion.bash b/pgcli-completion.bash index 3549b56..620563d 100644 --- a/pgcli-completion.bash +++ b/pgcli-completion.bash @@ -3,9 +3,9 @@ _pg_databases() # -w was introduced in 8.4, https://launchpad.net/bugs/164772 # "Access privileges" in output may contain linefeeds, hence the NF > 1 COMPREPLY=( $( compgen -W "$( psql -AtqwlF $'\t' 2>/dev/null | \ - awk 'NF > 1 { print $1 }' )" -- "$cur" ) ) + awk 'NF > 1 { print $1 }' )" -- "$cur" ) ) } - + _pg_users() { # -w was introduced in 8.4, https://launchpad.net/bugs/164772 @@ -13,12 +13,23 @@ _pg_users() template1 2>/dev/null )" -- "$cur" ) ) [[ ${#COMPREPLY[@]} -eq 0 ]] && COMPREPLY=( $( compgen -u -- "$cur" ) ) } - + +_pg_services() +{ + # return list of available services + local services + if [[ -f "$HOME/.pg_service.conf" ]]; then + services=$(grep -oP '(?<=^\[).*?(?=\])' "$HOME/.pg_service.conf") + fi + local suffix="${cur#*=}" + COMPREPLY=( $(compgen -W "$services" -- "$suffix") ) +} + _pgcli() { local cur prev words cword _init_completion -s || return - + case $prev in -h|--host) _known_hosts_real "$cur" @@ -39,23 +50,27 @@ _pgcli() esac case "$cur" in - --*) - # return list of available options - COMPREPLY=( $( compgen -W '--host --port --user --password --no-password - --single-connection --version --dbname --pgclirc --dsn - --row-limit --help' -- "$cur" ) ) - [[ $COMPREPLY == *= ]] && compopt -o nospace - return 0 - ;; - -) - # only complete long options - compopt -o nospace - COMPREPLY=( -- ) - return 0 - ;; - *) + service=*) + _pg_services + return 0 + ;; + --*) + # return list of available options + COMPREPLY=( $( compgen -W '--host --port --user --password --no-password + --single-connection --version --dbname --pgclirc --dsn + --row-limit --help' -- "$cur" ) ) + [[ $COMPREPLY == *= ]] && compopt -o nospace + return 0 + ;; + -) + # only complete long options + compopt -o nospace + COMPREPLY=( -- ) + return 0 + ;; + *) # return list of available databases - _pg_databases + _pg_databases esac -} && +} && complete -F _pgcli pgcli diff --git a/pgcli/__init__.py b/pgcli/__init__.py index 0fd7811..111dc91 100644 --- a/pgcli/__init__.py +++ b/pgcli/__init__.py @@ -1 +1 @@ -__version__ = "4.2.0" +__version__ = "4.3.0" diff --git a/pgcli/main.py b/pgcli/main.py index d4c6dbf..0765efb 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -1,3 +1,4 @@ +from zoneinfo import ZoneInfoNotFoundError from configobj import ConfigObj, ParseError from pgspecial.namedqueries import NamedQueries from .config import skip_initial_comment @@ -19,10 +20,15 @@ from time import time, sleep from typing import Optional from cli_helpers.tabular_output import TabularOutputFormatter -from cli_helpers.tabular_output.preprocessors import align_decimals, format_numbers +from cli_helpers.tabular_output.preprocessors import ( + align_decimals, + format_numbers, + format_timestamps, +) from cli_helpers.utils import strip_ansi from .explain_output_formatter import ExplainOutputFormatter import click +import tzlocal try: import setproctitle @@ -111,12 +117,13 @@ MetaQuery.__new__.__defaults__ = ("", False, 0, 0, False, False, False, False) OutputSettings = namedtuple( "OutputSettings", - "table_format dcmlfmt floatfmt missingval expanded max_width case_function style_output max_field_width", + "table_format dcmlfmt floatfmt column_date_formats missingval expanded max_width case_function style_output max_field_width", ) OutputSettings.__new__.__defaults__ = ( None, None, None, + None, "", False, None, @@ -264,6 +271,7 @@ class PGCli: self.on_error = c["main"]["on_error"].upper() self.decimal_format = c["data_formats"]["decimal"] self.float_format = c["data_formats"]["float"] + self.column_date_formats = c["column_date_formats"] auth.keyring_initialize(c["main"].as_bool("keyring"), logger=self.logger) self.show_bottom_toolbar = c["main"].as_bool("show_bottom_toolbar") @@ -1179,6 +1187,7 @@ class PGCli: table_format=self.table_format, dcmlfmt=self.decimal_format, floatfmt=self.float_format, + column_date_formats=self.column_date_formats, missingval=self.null_string, expanded=expanded, max_width=max_width, @@ -1593,9 +1602,9 @@ def cli( if list_databases or ping_database: database = "postgres" + cfg = load_config(pgclirc, config_full_path) if dsn != "": try: - cfg = load_config(pgclirc, config_full_path) dsn_config = cfg["alias_dsn"][dsn] except KeyError: click.secho( @@ -1624,6 +1633,55 @@ def cli( else: pgcli.connect(database, host, user, port) + if "use_local_timezone" not in cfg["main"] or cfg["main"].as_bool( + "use_local_timezone" + ): + server_tz = pgcli.pgexecute.get_timezone() + + def echo_error(msg: str): + click.secho( + "Failed to determine the local time zone", + err=True, + fg="yellow", + ) + click.secho( + msg, + err=True, + fg="yellow", + ) + click.secho( + f"Continuing with the default time zone as preset by the server ({server_tz})", + err=True, + fg="yellow", + ) + click.secho( + "Set `use_local_timezone = False` in the config to avoid trying to override the server time zone\n", + err=True, + dim=True, + ) + + local_tz = None + try: + local_tz = tzlocal.get_localzone_name() + + if local_tz is None: + echo_error("No local time zone configuration found\n") + else: + click.secho( + f"Using local time zone {local_tz} (server uses {server_tz})", + fg="green", + ) + click.secho( + "Use `set time zone ` to override, or set `use_local_timezone = False` in the config", + dim=True, + ) + + pgcli.pgexecute.set_timezone(local_tz) + except ZoneInfoNotFoundError as e: + # e.args[0] is the pre-formatted message which includes a list + # of conflicting sources + echo_error(e.args[0]) + if list_databases: cur, headers, status = pgcli.pgexecute.full_databases() @@ -1830,6 +1888,7 @@ def format_output(title, cur, headers, status, settings, explain_mode=False): "missing_value": settings.missingval, "integer_format": settings.dcmlfmt, "float_format": settings.floatfmt, + "column_date_formats": settings.column_date_formats, "preprocessors": (format_numbers, format_arrays), "disable_numparse": True, "preserve_whitespace": True, @@ -1839,6 +1898,9 @@ def format_output(title, cur, headers, status, settings, explain_mode=False): if not settings.floatfmt: output_kwargs["preprocessors"] = (align_decimals,) + if settings.column_date_formats: + output_kwargs["preprocessors"] += (format_timestamps,) + if table_format == "csv": # The default CSV dialect is "excel" which is not handling newline values correctly # Nevertheless, we want to keep on using "excel" on Windows since it uses '\r\n' diff --git a/pgcli/pgclirc b/pgcli/pgclirc index dd8b15f..be10610 100644 --- a/pgcli/pgclirc +++ b/pgcli/pgclirc @@ -191,6 +191,10 @@ enable_pager = True # Use keyring to automatically save and load password in a secure manner keyring = True +# Automatically set the session time zone to the local time zone +# If unset, uses the server's time zone, which is the Postgres default +use_local_timezone = True + # Custom colors for the completion menu, toolbar, etc. [colors] completion-menu.completion.current = 'bg:#ffffff #000000' @@ -240,3 +244,8 @@ output.null = "#808080" [data_formats] decimal = "" float = "" + +# Per column formats for date/timestamp columns +[column_date_formats] +# use strftime format, e.g. +# created = "%Y-%m-%d" diff --git a/pgcli/pgexecute.py b/pgcli/pgexecute.py index e091757..4e7c637 100644 --- a/pgcli/pgexecute.py +++ b/pgcli/pgexecute.py @@ -881,3 +881,16 @@ class PGExecute: def explain_prefix(self): return "EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) " + + def get_timezone(self) -> str: + query = psycopg.sql.SQL("show time zone") + with self.conn.cursor() as cur: + cur.execute(query) + return cur.fetchone()[0] + + def set_timezone(self, timezone: str): + query = psycopg.sql.SQL("set time zone {}").format( + psycopg.sql.Identifier(timezone) + ) + with self.conn.cursor() as cur: + cur.execute(query) diff --git a/pyproject.toml b/pyproject.toml index d714282..6c73602 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,12 +34,13 @@ dependencies = [ "psycopg-binary >= 3.0.14; sys_platform == 'win32'", "sqlparse >=0.3.0,<0.6", "configobj >= 5.0.6", - "cli_helpers[styles] >= 2.2.1", + "cli_helpers[styles] >= 2.4.0", # setproctitle is used to mask the password when running `ps` in command line. # But this is not necessary in Windows since the password is never shown in the # task manager. Also setproctitle is a hard dependency to install in Windows, # so we'll only install it if we're not in Windows. "setproctitle >= 1.1.9; sys_platform != 'win32' and 'CYGWIN' not in sys_platform", + "tzlocal >= 5.2", ] dynamic = ["version"] diff --git a/release.py b/release.py index 42a72a9..ad6a957 100644 --- a/release.py +++ b/release.py @@ -2,10 +2,10 @@ """A script to publish a release of pgcli to PyPI.""" import io -from optparse import OptionParser import re import subprocess import sys +from optparse import OptionParser import click @@ -66,7 +66,8 @@ def create_git_tag(tag_name): def create_distribution_files(): - run_step("python", "setup.py", "clean", "--all", "sdist", "bdist_wheel") + run_step("rm", "-rf", "dist/") + run_step("python", "-m", "build") def upload_distribution_files(): @@ -91,11 +92,11 @@ if __name__ == "__main__": if DEBUG: subprocess.check_output = lambda x: x - checks = [ - "Have you updated the AUTHORS file?", - "Have you updated the `Usage` section of the README?", - ] - checklist(checks) + # checks = [ + # "Have you updated the AUTHORS file?", + # "Have you updated the `Usage` section of the README?", + # ] + # checklist(checks) ver = version("pgcli/__init__.py") print("Releasing Version:", ver) diff --git a/tests/test_main.py b/tests/test_main.py index 3683d49..102ebcd 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -76,6 +76,57 @@ def test_format_output(): assert list(results) == expected +def test_column_date_formats(): + settings = OutputSettings( + table_format="psql", + column_date_formats={ + "date_col": "%Y-%m-%d", + "datetime_col": "%I:%M:%S %m/%d/%y", + }, + ) + data = [ + ("name1", "2024-12-13T18:32:22", "2024-12-13T19:32:22", "2024-12-13T20:32:22"), + ("name2", "2025-02-13T02:32:22", "2025-02-13T02:32:22", "2025-02-13T02:32:22"), + ] + headers = ["name", "date_col", "datetime_col", "unchanged_col"] + + results = format_output("Title", data, headers, "test status", settings) + expected = [ + "Title", + "+-------+------------+-------------------+---------------------+", + "| name | date_col | datetime_col | unchanged_col |", + "|-------+------------+-------------------+---------------------|", + "| name1 | 2024-12-13 | 07:32:22 12/13/24 | 2024-12-13T20:32:22 |", + "| name2 | 2025-02-13 | 02:32:22 02/13/25 | 2025-02-13T02:32:22 |", + "+-------+------------+-------------------+---------------------+", + "test status", + ] + assert list(results) == expected + + +def test_no_column_date_formats(): + """Test that not setting any column date formats returns unaltered datetime columns""" + settings = OutputSettings(table_format="psql") + data = [ + ("name1", "2024-12-13T18:32:22", "2024-12-13T19:32:22", "2024-12-13T20:32:22"), + ("name2", "2025-02-13T02:32:22", "2025-02-13T02:32:22", "2025-02-13T02:32:22"), + ] + headers = ["name", "date_col", "datetime_col", "unchanged_col"] + + results = format_output("Title", data, headers, "test status", settings) + expected = [ + "Title", + "+-------+---------------------+---------------------+---------------------+", + "| name | date_col | datetime_col | unchanged_col |", + "|-------+---------------------+---------------------+---------------------|", + "| name1 | 2024-12-13T18:32:22 | 2024-12-13T19:32:22 | 2024-12-13T20:32:22 |", + "| name2 | 2025-02-13T02:32:22 | 2025-02-13T02:32:22 | 2025-02-13T02:32:22 |", + "+-------+---------------------+---------------------+---------------------+", + "test status", + ] + assert list(results) == expected + + def test_format_output_truncate_on(): settings = OutputSettings( table_format="psql", dcmlfmt="d", floatfmt="g", max_field_width=10