pgcli 4.6.0__tar.gz → 4.7.0__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.
Files changed (135) hide show
  1. {pgcli-4.6.0 → pgcli-4.7.0}/.github/workflows/publish.yml +1 -1
  2. {pgcli-4.6.0 → pgcli-4.7.0}/AUTHORS +2 -0
  3. {pgcli-4.6.0 → pgcli-4.7.0}/PKG-INFO +5 -2
  4. {pgcli-4.6.0 → pgcli-4.7.0}/README.rst +1 -1
  5. {pgcli-4.6.0 → pgcli-4.7.0}/changelog.rst +70 -0
  6. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/main.py +242 -16
  7. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/parseutils/__init__.py +20 -5
  8. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/prompt_utils.py +13 -4
  9. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/pgclirc +7 -0
  10. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/pgexecute.py +12 -5
  11. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli.egg-info/PKG-INFO +5 -2
  12. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli.egg-info/SOURCES.txt +6 -0
  13. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli.egg-info/scm_file_list.json +6 -0
  14. pgcli-4.7.0/pgcli.egg-info/scm_version.json +8 -0
  15. {pgcli-4.6.0 → pgcli-4.7.0}/pyproject.toml +5 -1
  16. pgcli-4.7.0/tests/features/command_option.feature +38 -0
  17. pgcli-4.7.0/tests/features/file_option.feature +33 -0
  18. pgcli-4.7.0/tests/features/steps/command_option.py +144 -0
  19. pgcli-4.7.0/tests/features/steps/file_option.py +137 -0
  20. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/steps/iocommands.py +10 -10
  21. {pgcli-4.6.0 → pgcli-4.7.0}/tests/parseutils/test_parseutils.py +22 -0
  22. pgcli-4.7.0/tests/test_alias_dsn.py +90 -0
  23. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_main.py +358 -1
  24. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_pgexecute.py +56 -0
  25. pgcli-4.7.0/tests/test_tuples_only.py +69 -0
  26. pgcli-4.6.0/pgcli.egg-info/scm_version.json +0 -8
  27. {pgcli-4.6.0 → pgcli-4.7.0}/.coveragerc +0 -0
  28. {pgcli-4.6.0 → pgcli-4.7.0}/.editorconfig +0 -0
  29. {pgcli-4.6.0 → pgcli-4.7.0}/.git-blame-ignore-revs +0 -0
  30. {pgcli-4.6.0 → pgcli-4.7.0}/.github/ISSUE_TEMPLATE.md +0 -0
  31. {pgcli-4.6.0 → pgcli-4.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {pgcli-4.6.0 → pgcli-4.7.0}/.github/workflows/ci.yml +0 -0
  33. {pgcli-4.6.0 → pgcli-4.7.0}/.github/workflows/codeql.yml +0 -0
  34. {pgcli-4.6.0 → pgcli-4.7.0}/.github/workflows/codex-review.yml +0 -0
  35. {pgcli-4.6.0 → pgcli-4.7.0}/.gitignore +0 -0
  36. {pgcli-4.6.0 → pgcli-4.7.0}/.pre-commit-config.yaml +0 -0
  37. {pgcli-4.6.0 → pgcli-4.7.0}/CONTRIBUTING.rst +0 -0
  38. {pgcli-4.6.0 → pgcli-4.7.0}/Dockerfile +0 -0
  39. {pgcli-4.6.0 → pgcli-4.7.0}/LICENSE.txt +0 -0
  40. {pgcli-4.6.0 → pgcli-4.7.0}/MANIFEST.in +0 -0
  41. {pgcli-4.6.0 → pgcli-4.7.0}/RELEASES.md +0 -0
  42. {pgcli-4.6.0 → pgcli-4.7.0}/TODO +0 -0
  43. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/__init__.py +0 -0
  44. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/__main__.py +0 -0
  45. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/auth.py +0 -0
  46. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/completion_refresher.py +0 -0
  47. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/config.py +0 -0
  48. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/explain_output_formatter.py +0 -0
  49. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/key_bindings.py +0 -0
  50. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/magic.py +0 -0
  51. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/__init__.py +0 -0
  52. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/formatter/__init__.py +0 -0
  53. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/formatter/sqlformatter.py +0 -0
  54. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/parseutils/ctes.py +0 -0
  55. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/parseutils/meta.py +0 -0
  56. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/parseutils/tables.py +0 -0
  57. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/parseutils/utils.py +0 -0
  58. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/pgliterals/__init__.py +0 -0
  59. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/pgliterals/main.py +0 -0
  60. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/pgliterals/pgliterals.json +0 -0
  61. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/prioritization.py +0 -0
  62. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/packages/sqlcompletion.py +0 -0
  63. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/pgbuffer.py +0 -0
  64. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/pgcompleter.py +0 -0
  65. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/pgstyle.py +0 -0
  66. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/pgtoolbar.py +0 -0
  67. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli/pyev.py +0 -0
  68. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli-completion.bash +0 -0
  69. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli.egg-info/dependency_links.txt +0 -0
  70. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli.egg-info/entry_points.txt +0 -0
  71. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli.egg-info/requires.txt +0 -0
  72. {pgcli-4.6.0 → pgcli-4.7.0}/pgcli.egg-info/top_level.txt +0 -0
  73. {pgcli-4.6.0 → pgcli-4.7.0}/post-install +0 -0
  74. {pgcli-4.6.0 → pgcli-4.7.0}/post-remove +0 -0
  75. {pgcli-4.6.0 → pgcli-4.7.0}/pylintrc +0 -0
  76. {pgcli-4.6.0 → pgcli-4.7.0}/release.py +0 -0
  77. {pgcli-4.6.0 → pgcli-4.7.0}/sanity_checks.txt +0 -0
  78. {pgcli-4.6.0 → pgcli-4.7.0}/screenshots/image01.png +0 -0
  79. {pgcli-4.6.0 → pgcli-4.7.0}/screenshots/image02.png +0 -0
  80. {pgcli-4.6.0 → pgcli-4.7.0}/screenshots/kharkiv-destroyed.jpg +0 -0
  81. {pgcli-4.6.0 → pgcli-4.7.0}/screenshots/pgcli.gif +0 -0
  82. {pgcli-4.6.0 → pgcli-4.7.0}/setup.cfg +0 -0
  83. {pgcli-4.6.0 → pgcli-4.7.0}/tests/conftest.py +0 -0
  84. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/__init__.py +0 -0
  85. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/auto_vertical.feature +0 -0
  86. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/basic_commands.feature +0 -0
  87. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/crud_database.feature +0 -0
  88. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/crud_table.feature +0 -0
  89. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/db_utils.py +0 -0
  90. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/environment.py +0 -0
  91. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/expanded.feature +0 -0
  92. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/fixture_data/help.txt +0 -0
  93. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/fixture_data/help_commands.txt +0 -0
  94. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/fixture_data/mock_pg_service.conf +0 -0
  95. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/fixture_utils.py +0 -0
  96. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/iocommands.feature +0 -0
  97. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/named_queries.feature +0 -0
  98. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/pgbouncer.feature +0 -0
  99. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/specials.feature +0 -0
  100. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/steps/__init__.py +0 -0
  101. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/steps/auto_vertical.py +0 -0
  102. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/steps/basic_commands.py +0 -0
  103. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/steps/crud_database.py +0 -0
  104. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/steps/crud_table.py +0 -0
  105. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/steps/expanded.py +0 -0
  106. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/steps/named_queries.py +0 -0
  107. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/steps/pgbouncer.py +0 -0
  108. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/steps/specials.py +0 -0
  109. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/steps/wrappers.py +0 -0
  110. {pgcli-4.6.0 → pgcli-4.7.0}/tests/features/wrappager.py +0 -0
  111. {pgcli-4.6.0 → pgcli-4.7.0}/tests/formatter/__init__.py +0 -0
  112. {pgcli-4.6.0 → pgcli-4.7.0}/tests/formatter/test_sqlformatter.py +0 -0
  113. {pgcli-4.6.0 → pgcli-4.7.0}/tests/metadata.py +0 -0
  114. {pgcli-4.6.0 → pgcli-4.7.0}/tests/parseutils/test_ctes.py +0 -0
  115. {pgcli-4.6.0 → pgcli-4.7.0}/tests/parseutils/test_function_metadata.py +0 -0
  116. {pgcli-4.6.0 → pgcli-4.7.0}/tests/pytest.ini +0 -0
  117. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_application_name.py +0 -0
  118. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_auth.py +0 -0
  119. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_completion_refresher.py +0 -0
  120. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_config.py +0 -0
  121. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_fuzzy_completion.py +0 -0
  122. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_init_commands_simple.py +0 -0
  123. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_naive_completion.py +0 -0
  124. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_pgcompleter.py +0 -0
  125. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_pgspecial.py +0 -0
  126. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_prioritization.py +0 -0
  127. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_prompt_utils.py +0 -0
  128. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_rowlimit.py +0 -0
  129. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_smart_completion_multiple_schemata.py +0 -0
  130. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_smart_completion_public_schema_only.py +0 -0
  131. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_sqlcompletion.py +0 -0
  132. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_ssh_tunnel.py +0 -0
  133. {pgcli-4.6.0 → pgcli-4.7.0}/tests/test_trailing_comments.py +0 -0
  134. {pgcli-4.6.0 → pgcli-4.7.0}/tests/utils.py +0 -0
  135. {pgcli-4.6.0 → pgcli-4.7.0}/tox.ini +0 -0
@@ -65,7 +65,7 @@ jobs:
65
65
  - name: Set up Python
66
66
  uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
67
67
  with:
68
- python-version: '3.13'
68
+ python-version: '3.14'
69
69
 
70
70
  - name: Install dependencies
71
71
  run: uv sync --all-extras -p 3.14
@@ -145,6 +145,7 @@ Contributors:
145
145
  * fbdb
146
146
  * Charbel Jacquin (charbeljc)
147
147
  * Jeronimo Garcia (bechampion)
148
+ * Diego
148
149
  * Devadathan M B (devadathanmb)
149
150
  * Charalampos Stratakis
150
151
  * Laszlo Bimba (bimlas)
@@ -154,6 +155,7 @@ Contributors:
154
155
  * Diego
155
156
  * Chris (ChrisJr404)
156
157
  * Pieter Ouwerkerk (pouwerkerk)
158
+ * VXNCXNX
157
159
 
158
160
  Creator:
159
161
  --------
@@ -1,10 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgcli
3
- Version: 4.6.0
3
+ Version: 4.7.0
4
4
  Summary: CLI for Postgres Database. With auto-completion and syntax highlighting.
5
5
  Author-email: Pgcli Core Team <pgcli-dev@googlegroups.com>
6
6
  License-Expression: BSD-3-Clause
7
7
  Project-URL: Homepage, https://pgcli.com
8
+ Project-URL: Changelog, https://github.com/dbcli/pgcli/blob/main/changelog.rst
9
+ Project-URL: Repository, https://github.com/dbcli/pgcli/
10
+ Project-URL: Issues, https://github.com/dbcli/pgcli/issues
8
11
  Classifier: Intended Audience :: Developers
9
12
  Classifier: Operating System :: Unix
10
13
  Classifier: Programming Language :: Python
@@ -405,7 +408,7 @@ which is quite literally the backbone library, that made this app possible.
405
408
  Jonathan has also provided valuable feedback and support during the development
406
409
  of this app.
407
410
 
408
- `Click <https://click.pocoo.org/>`_ is used for command line option parsing
411
+ `Click <https://click.palletsprojects.com/>`_ is used for command line option parsing
409
412
  and printing error messages.
410
413
 
411
414
  Thanks to `psycopg <https://www.psycopg.org/>`_ for providing a rock solid
@@ -352,7 +352,7 @@ which is quite literally the backbone library, that made this app possible.
352
352
  Jonathan has also provided valuable feedback and support during the development
353
353
  of this app.
354
354
 
355
- `Click <https://click.pocoo.org/>`_ is used for command line option parsing
355
+ `Click <https://click.palletsprojects.com/>`_ is used for command line option parsing
356
356
  and printing error messages.
357
357
 
358
358
  Thanks to `psycopg <https://www.psycopg.org/>`_ for providing a rock solid
@@ -1,8 +1,70 @@
1
+ Upcoming
2
+ ========
3
+
4
+
5
+
6
+ 4.7.0 (2026-09-19)
7
+ ==================
8
+
9
+ Features:
10
+ ---------
11
+ * Add support for `single-command` to run a SQL command and exit.
12
+ * Command line option `-c` or `--command`.
13
+ * You can specify multiple times.
14
+ * Runs one statement at a time, like `-f`, and can be combined with `-f`:
15
+ both run, the same way psql does.
16
+ * Add support for forcing destructive commands without confirmation.
17
+ * Command line option `-y` or `--yes`.
18
+ * Skips the destructive command confirmation prompt when enabled.
19
+ * Useful for automated scripts and CI/CD pipelines.
20
+ * Add a ``-t``/``--tuples-only`` command line option that prints the rows and
21
+ nothing else, matching psql: no column headers, no title, no status footer
22
+ and no timing line. The configured table format is left untouched, so ``\T``
23
+ still reports it and can still change it mid-session.
24
+ * Add support for executing SQL commands from file and exit.
25
+ * Command line option `-f` or `--file`.
26
+ * Multiple files can be specified.
27
+ * Files run one statement at a time, like psql, so a ``\watch`` only
28
+ repeats its own statement (and a bare ``\watch`` re-runs the statement
29
+ before it), instead of re-running the whole file. A backslash command
30
+ spans only its own line, also like psql, so a metacommand followed by
31
+ SQL on the next line does not swallow the SQL.
32
+
33
+ Bug fixes:
34
+ ----------
35
+ * Fix special commands being broken while explain mode (F5) is on. Every input
36
+ was prefixed with ``EXPLAIN (...)`` and sent to the server as SQL, including
37
+ backslash commands and the bare words ``exit``/``quit``, so ``\q``, ``\d``,
38
+ ``\i``, named queries and ``\G`` all failed with ``syntax error at or near
39
+ "\"`` and there was no way to leave explain mode or quit. Special commands
40
+ are now detected first and the EXPLAIN prefix is applied only to real SQL.
41
+ * Fix ``-l``/``--list`` and ``--ping`` discarding the connection string. The
42
+ positional argument was unconditionally replaced with ``postgres``, which
43
+ also threw away a connection URI or ``key=value`` conninfo (host, user, port,
44
+ ``sslmode``, everything) and silently fell back to a local socket connection
45
+ as the OS user. The database argument is now kept, like psql; only when no
46
+ database is given at all does the listing connect to ``postgres``.
47
+
1
48
  4.6.0 (2026-08-26)
2
49
  ==================
3
50
 
51
+ Internal:
52
+ ---------
53
+ * Make the external-editor behave scenario less flaky: the ``expect_exact``
54
+ timeouts in ``tests/features/steps/iocommands.py`` were as low as 1-2
55
+ seconds, which intermittently expired on loaded CI runners and reported
56
+ ``Scenario: edit sql in file with external editor`` as an error. Raised to 10
57
+ seconds; passing runs are unaffected because pexpect returns as soon as the
58
+ expected text appears.
59
+
4
60
  Bug fixes:
5
61
  ----------
62
+ * Fix ``--list-dsn`` and ``-D``/``--dsn`` not finding ``[alias_dsn]`` entries.
63
+ On a fresh install (no config written yet) ``--list-dsn`` printed a misleading
64
+ "Invalid DSNs found" error. ``--list-dsn`` now treats a missing config or
65
+ ``[alias_dsn]`` section as simply nothing to list, without writing a config
66
+ file, and ``-D`` resolves the alias from the config already loaded at startup
67
+ instead of reading it again ([issue 1489](https://github.com/dbcli/pgcli/issues/1489)).
6
68
  * Restore cursor shape behaviour for Emacs mode
7
69
  * Fix ``TypeError: cannot use a string pattern on a bytes-like object`` when
8
70
  completion metadata comes back as bytes (e.g. ``SQL_ASCII`` client encoding).
@@ -11,9 +73,17 @@ Bug fixes:
11
73
  issues (CVE-2026-59893, CVE-2026-54284, CVE-2026-71491) and a string-escaping
12
74
  bug (CVE-2026-59894); the previous ``<0.6`` cap prevented users from
13
75
  installing the fixed release.
76
+ * Detect an unconditional ``UPDATE`` with ``sqlparse`` rather than splitting on whitespace, so a
77
+ ``WHERE`` appearing inside a string literal no longer suppresses the destructive-statement warning.
14
78
 
15
79
  Features:
16
80
  ---------
81
+ * Add a ``--timeout`` command line option and a ``connect_timeout`` config value
82
+ (default 30 seconds) for the connection timeout. Precedence, highest first:
83
+ ``--timeout``, then a ``connect_timeout`` in the connection string, then
84
+ ``$PGCONNECT_TIMEOUT``, then the config value. libpq's own default is 0,
85
+ which waits until the operating system gives up on the TCP connection, so an
86
+ unreachable host used to hang for minutes.
17
87
  * Honor the ``PSQL_EDITOR`` environment variable when opening the external
18
88
  editor (``\\e``, ``\\ev``, ``\\ef``, ``\\ne``), matching psql's precedence of
19
89
  ``PSQL_EDITOR``, then ``EDITOR``, then ``VISUAL`` ([issue 1398](https://github.com/dbcli/pgcli/issues/1398)).
@@ -119,7 +119,8 @@ MetaQuery.__new__.__defaults__ = ("", False, 0, 0, False, False, False, False)
119
119
 
120
120
  OutputSettings = namedtuple(
121
121
  "OutputSettings",
122
- "table_format dcmlfmt floatfmt column_date_formats missingval expanded max_width case_function style_output max_field_width",
122
+ "table_format dcmlfmt floatfmt column_date_formats missingval expanded max_width case_function style_output "
123
+ "max_field_width tuples_only",
123
124
  )
124
125
  OutputSettings.__new__.__defaults__ = (
125
126
  None,
@@ -132,6 +133,7 @@ OutputSettings.__new__.__defaults__ = (
132
133
  lambda x: x,
133
134
  None,
134
135
  DEFAULT_MAX_FIELD_WIDTH,
136
+ False,
135
137
  )
136
138
 
137
139
 
@@ -157,6 +159,35 @@ def get_editor():
157
159
  return os.environ.get("PSQL_EDITOR") or os.environ.get("EDITOR") or os.environ.get("VISUAL") or None
158
160
 
159
161
 
162
+ def get_connect_timeout(explicit, dsn, kwargs, default):
163
+ """Pick the connection timeout to apply, in seconds.
164
+
165
+ Precedence, highest first:
166
+
167
+ 1. ``explicit``, i.e. ``--timeout`` on the command line
168
+ 2. ``connect_timeout`` in the connection string, or in ``kwargs``
169
+ 3. ``$PGCONNECT_TIMEOUT``
170
+ 4. ``default``, the ``connect_timeout`` config value
171
+
172
+ Returns ``None`` when the user already stated a timeout by one of the
173
+ means we must not override, in which case the caller leaves the
174
+ connection parameters alone and libpq reads it from where it already is.
175
+
176
+ A default matters because libpq's own is 0, which waits until the
177
+ operating system gives up on the TCP connection, so an unreachable host
178
+ hangs for minutes.
179
+ """
180
+ if explicit is not None:
181
+ return explicit
182
+ if "connect_timeout" in kwargs:
183
+ return None
184
+ if dsn and "connect_timeout" in conninfo_to_dict(dsn):
185
+ return None
186
+ if os.environ.get("PGCONNECT_TIMEOUT"):
187
+ return None
188
+ return default
189
+
190
+
160
191
  class PGCli:
161
192
  default_prompt = "\\u@\\h:\\d> "
162
193
  max_len_prompt = 30
@@ -192,18 +223,22 @@ class PGCli:
192
223
  application_name="pgcli",
193
224
  single_connection=False,
194
225
  less_chatty=None,
226
+ tuples_only=None,
195
227
  prompt=None,
196
228
  prompt_dsn=None,
197
229
  auto_vertical_output=False,
198
230
  warn=None,
199
231
  ssh_tunnel_url: str | None = None,
232
+ connect_timeout: int | None = None,
200
233
  log_file: str | None = None,
234
+ force_destructive: bool = False,
201
235
  ):
202
236
  self.force_passwd_prompt = force_passwd_prompt
203
237
  self.never_passwd_prompt = never_passwd_prompt
204
238
  self.pgexecute = pgexecute
205
239
  self.dsn_alias = None
206
240
  self.watch_command = None
241
+ self.force_destructive = force_destructive
207
242
 
208
243
  # Load config.
209
244
  c = self.config = get_config(pgclirc_file)
@@ -250,6 +285,13 @@ class PGCli:
250
285
  self.min_num_menu_lines = c["main"].as_int("min_num_menu_lines")
251
286
  self.multiline_continuation_char = c["main"]["multiline_continuation_char"]
252
287
  self.table_format = c["main"]["table_format"]
288
+ # psql's -t prints the rows and nothing else: no column headers, no
289
+ # title, no status footer and no timing line. The table format is left
290
+ # alone here and switched to an unadorned one at output time, so \T
291
+ # still reports (and can change) the configured format.
292
+ self.tuples_only = bool(tuples_only)
293
+ if self.tuples_only:
294
+ self.pgspecial.timing_enabled = False
253
295
  self.syntax_style = c["main"]["syntax_style"]
254
296
  self.cli_style = c["colors"]
255
297
  self.wider_completion_menu = c["main"].as_bool("wider_completion_menu")
@@ -263,6 +305,9 @@ class PGCli:
263
305
  self.prompt_format = prompt if prompt is not None else c["main"].get("prompt", self.default_prompt)
264
306
  self.prompt_dsn_format = prompt_dsn
265
307
  self.on_error = c["main"]["on_error"].upper()
308
+ # Connection timeout, in seconds. See connect() for the precedence.
309
+ self.connect_timeout = connect_timeout
310
+ self.default_connect_timeout = c["main"].as_int("connect_timeout")
266
311
  self.decimal_format = c["data_formats"]["decimal"]
267
312
  self.float_format = c["data_formats"]["float"]
268
313
  self.column_date_formats = c["column_date_formats"]
@@ -553,7 +598,7 @@ class PGCli:
553
598
  ):
554
599
  message = "Destructive statements must be run within a transaction. Command execution stopped."
555
600
  return [(None, None, None, message)]
556
- destroy = confirm_destructive_query(query, self.destructive_warning, self.dsn_alias)
601
+ destroy = confirm_destructive_query(query, self.destructive_warning, self.dsn_alias, self.force_destructive)
557
602
  if destroy is False:
558
603
  message = "Wise choice. Command execution stopped."
559
604
  return [(None, None, None, message)]
@@ -676,6 +721,12 @@ class PGCli:
676
721
 
677
722
  kwargs.setdefault("application_name", self.application_name)
678
723
 
724
+ # The resolved value is passed as a connection parameter rather than
725
+ # merged into the dsn, leaving the user's connection string untouched.
726
+ timeout = get_connect_timeout(self.connect_timeout, dsn, kwargs, self.default_connect_timeout)
727
+ if timeout is not None:
728
+ kwargs["connect_timeout"] = str(timeout)
729
+
679
730
  # If password prompt is not forced but no password is provided, try
680
731
  # getting it from environment variable.
681
732
  if not self.force_passwd_prompt and not passwd:
@@ -871,11 +922,11 @@ class PGCli:
871
922
  ):
872
923
  click.secho("Destructive statements must be run within a transaction.")
873
924
  raise KeyboardInterrupt
874
- destroy = confirm_destructive_query(text, self.destructive_warning, self.dsn_alias)
925
+ destroy = confirm_destructive_query(text, self.destructive_warning, self.dsn_alias, self.force_destructive)
875
926
  if destroy is False:
876
927
  click.secho("Wise choice!")
877
928
  raise KeyboardInterrupt
878
- elif destroy:
929
+ elif destroy and not self.force_destructive:
879
930
  click.secho("Your call!")
880
931
 
881
932
  output, query = self._evaluate_command(text)
@@ -1006,6 +1057,56 @@ class PGCli:
1006
1057
  def run_cli(self):
1007
1058
  logger = self.logger
1008
1059
 
1060
+ # Handle command mode (-c flag) - similar to psql behavior
1061
+ # Multiple -c options are executed sequentially
1062
+ if hasattr(self, 'commands') and self.commands:
1063
+ try:
1064
+ for command in self.commands:
1065
+ logger.debug("Running command: %s", command)
1066
+ # Statement by statement, like psql -c: \watch only repeats
1067
+ # its own statement, not the whole -c block.
1068
+ if not self._execute_statements(command):
1069
+ break
1070
+ except PgCliQuitError:
1071
+ # Normal exit from quit command
1072
+ sys.exit(0)
1073
+ except Exception as e:
1074
+ logger.error("Error executing command: %s", e)
1075
+ logger.error("traceback: %r", traceback.format_exc())
1076
+ click.secho(str(e), err=True, fg="red")
1077
+ sys.exit(1)
1078
+ # psql runs both -c and -f when they are given together, so only
1079
+ # exit here when there is no file left to run.
1080
+ if not (hasattr(self, 'input_files') and self.input_files):
1081
+ sys.exit(0)
1082
+
1083
+ # Handle file mode (-f flag) - similar to psql behavior
1084
+ # Multiple -f options are executed sequentially
1085
+ if hasattr(self, 'input_files') and self.input_files:
1086
+ try:
1087
+ for input_file in self.input_files:
1088
+ logger.debug("Reading commands from file: %s", input_file)
1089
+ with open(input_file, 'r', encoding='utf-8') as f:
1090
+ file_content = f.read()
1091
+
1092
+ if file_content.strip():
1093
+ logger.debug("Executing commands from file: %s", input_file)
1094
+ # Statement by statement, like psql -f: \watch only
1095
+ # repeats its own statement, not the whole file.
1096
+ if not self._execute_statements(file_content):
1097
+ break
1098
+
1099
+ except PgCliQuitError:
1100
+ # Normal exit from quit command
1101
+ sys.exit(0)
1102
+ except Exception as e:
1103
+ logger.error("Error executing command: %s", e)
1104
+ logger.error("traceback: %r", traceback.format_exc())
1105
+ click.secho(str(e), err=True, fg="red")
1106
+ sys.exit(1)
1107
+ # Exit successfully after executing all commands
1108
+ sys.exit(0)
1109
+
1009
1110
  history_file = self.config["main"]["history_file"]
1010
1111
  if history_file == "default":
1011
1112
  history_file = config_location() + "history"
@@ -1082,6 +1183,43 @@ class PGCli:
1082
1183
  query = self.execute_command(text)
1083
1184
 
1084
1185
  self.query_history.append(query)
1186
+ return query
1187
+
1188
+ def _execute_statements(self, text):
1189
+ r"""Run a block of SQL the way psql -f does: one statement at a time.
1190
+
1191
+ get_watch_command()'s regex captures ALL the text before a \watch, so
1192
+ feeding a whole file to handle_watch_command would make \watch repeat
1193
+ every statement in it. Splitting first keeps \watch scoped to its own
1194
+ statement, and a bare \watch picks up the previous statement through
1195
+ query_history, exactly like psql.
1196
+
1197
+ A backslash command spans only its own line, like in psql, so a
1198
+ metacommand followed by SQL on the next line does not swallow the
1199
+ SQL (sqlparse only cuts at semicolons).
1200
+
1201
+ Honors on_error: with STOP, the first failed statement stops the run.
1202
+ Returns True when every statement succeeded.
1203
+ """
1204
+ ok = True
1205
+ statements = sqlparse.split(text)
1206
+ while statements:
1207
+ statement = statements.pop(0)
1208
+ stripped = statement.strip()
1209
+ if not stripped:
1210
+ continue
1211
+ if stripped.startswith("\\") and "\n" in stripped:
1212
+ # psql's rule: a backslash command ends at its newline. Put
1213
+ # the rest back through the splitter.
1214
+ first_line, rest = stripped.split("\n", 1)
1215
+ statements = sqlparse.split(rest) + statements
1216
+ statement = first_line
1217
+ query = self.handle_watch_command(statement)
1218
+ if query is not None and not query.successful:
1219
+ ok = False
1220
+ if self.on_error != "RESUME":
1221
+ break
1222
+ return ok
1085
1223
 
1086
1224
  def _build_cli(self, history):
1087
1225
  key_bindings = pgcli_bindings(self)
@@ -1231,6 +1369,7 @@ class PGCli:
1231
1369
  case_function=(self.completer.case if self.settings["case_column_headers"] else lambda x: x),
1232
1370
  style_output=self.style_output,
1233
1371
  max_field_width=self.max_field_width,
1372
+ tuples_only=self.tuples_only,
1234
1373
  )
1235
1374
 
1236
1375
  # Hide query text for named queries in quiet mode
@@ -1387,7 +1526,13 @@ class PGCli:
1387
1526
  return len(lines) >= (self.prompt_app.output.get_size().rows - 4)
1388
1527
 
1389
1528
  def echo_via_pager(self, text, color=None):
1390
- if self.pgspecial.pager_config == PAGER_OFF or self.watch_command:
1529
+ # Disable pager for -c/--command and -f/--file modes and \watch command
1530
+ if (
1531
+ self.pgspecial.pager_config == PAGER_OFF
1532
+ or self.watch_command
1533
+ or (hasattr(self, 'commands') and self.commands)
1534
+ or (hasattr(self, 'input_files') and self.input_files)
1535
+ ):
1391
1536
  click.echo(text, color=color)
1392
1537
  elif self.pgspecial.pager_config == PAGER_LONG_OUTPUT and self.table_format != "csv":
1393
1538
  lines = text.split("\n")
@@ -1425,6 +1570,13 @@ class PGCli:
1425
1570
  help="Username to connect to the postgres database.",
1426
1571
  )
1427
1572
  @click.option("-u", "--user", "username_opt", help="Username to connect to the postgres database.")
1573
+ @click.option(
1574
+ "--timeout",
1575
+ "connect_timeout",
1576
+ type=click.INT,
1577
+ default=None,
1578
+ help="Seconds to wait for a connection before giving up (0 waits forever). Overrides the connection string and $PGCONNECT_TIMEOUT.",
1579
+ )
1428
1580
  @click.option(
1429
1581
  "-W",
1430
1582
  "--password",
@@ -1490,6 +1642,14 @@ class PGCli:
1490
1642
  default=False,
1491
1643
  help="Skip intro on startup and goodbye on exit.",
1492
1644
  )
1645
+ @click.option(
1646
+ "-t",
1647
+ "--tuples-only",
1648
+ "tuples_only",
1649
+ is_flag=True,
1650
+ default=False,
1651
+ help="Print rows only: no column headers, no status footer and no timing, like psql.",
1652
+ )
1493
1653
  @click.option("--prompt", help='Prompt format (Default: "\\u@\\h:\\d> ").')
1494
1654
  @click.option(
1495
1655
  "--prompt-dsn",
@@ -1535,6 +1695,29 @@ class PGCli:
1535
1695
  type=str,
1536
1696
  help="SQL statement to execute after connecting.",
1537
1697
  )
1698
+ @click.option(
1699
+ "-c",
1700
+ "--command",
1701
+ "commands",
1702
+ multiple=True,
1703
+ help="run command (SQL or internal) and exit. Multiple -c options are allowed.",
1704
+ )
1705
+ @click.option(
1706
+ "-y",
1707
+ "--yes",
1708
+ "force_destructive",
1709
+ is_flag=True,
1710
+ default=False,
1711
+ help="Force destructive commands without confirmation prompt.",
1712
+ )
1713
+ @click.option(
1714
+ "-f",
1715
+ "--file",
1716
+ "input_files",
1717
+ multiple=True,
1718
+ type=click.Path(exists=True, readable=True, dir_okay=False),
1719
+ help="execute commands from file, then exit. Multiple -f options are allowed.",
1720
+ )
1538
1721
  @click.argument("dbname", default=lambda: None, envvar="PGDATABASE", nargs=1)
1539
1722
  @click.argument("username", default=lambda: None, envvar="PGUSER", nargs=1)
1540
1723
  def cli(
@@ -1553,6 +1736,7 @@ def cli(
1553
1736
  row_limit,
1554
1737
  application_name,
1555
1738
  less_chatty,
1739
+ tuples_only,
1556
1740
  prompt,
1557
1741
  prompt_dsn,
1558
1742
  list_databases,
@@ -1563,6 +1747,10 @@ def cli(
1563
1747
  ssh_tunnel: str,
1564
1748
  init_command: str,
1565
1749
  log_file: str,
1750
+ commands: tuple,
1751
+ force_destructive: bool,
1752
+ input_files: tuple,
1753
+ connect_timeout: int | None,
1566
1754
  ):
1567
1755
  if version:
1568
1756
  print("Version:", __version__)
@@ -1585,9 +1773,14 @@ def cli(
1585
1773
  config_full_path,
1586
1774
  )
1587
1775
  if list_dsn:
1776
+ config_file = get_config_filename(pgclirc)
1777
+ if not os.path.exists(config_file):
1778
+ # Nothing is configured yet, so there is nothing to list. Don't write
1779
+ # out the default config just to read it back for a read-only command.
1780
+ sys.exit(0)
1588
1781
  try:
1589
- cfg = load_config(pgclirc, config_full_path)
1590
- for alias in cfg["alias_dsn"]:
1782
+ cfg = load_config(config_file)
1783
+ for alias in cfg.get("alias_dsn", {}):
1591
1784
  click.secho(alias + " : " + cfg["alias_dsn"][alias])
1592
1785
  sys.exit(0)
1593
1786
  except Exception:
@@ -1615,14 +1808,22 @@ def cli(
1615
1808
  application_name=application_name,
1616
1809
  single_connection=single_connection,
1617
1810
  less_chatty=less_chatty,
1811
+ tuples_only=tuples_only,
1618
1812
  prompt=prompt,
1619
1813
  prompt_dsn=prompt_dsn,
1620
1814
  auto_vertical_output=auto_vertical_output,
1621
1815
  warn=warn,
1622
1816
  ssh_tunnel_url=ssh_tunnel,
1623
1817
  log_file=log_file,
1818
+ force_destructive=force_destructive,
1819
+ connect_timeout=connect_timeout,
1624
1820
  )
1625
1821
 
1822
+ # Store commands for -c option (can be multiple)
1823
+ pgcli.commands = commands if commands else None
1824
+ # Store file paths for -f option (can be multiple)
1825
+ pgcli.input_files = input_files if input_files else None
1826
+
1626
1827
  # Choose which ever one has a valid value.
1627
1828
  if dbname_opt and dbname:
1628
1829
  # work as psql: when database is given as option and argument use the argument as user
@@ -1634,11 +1835,27 @@ def cli(
1634
1835
  service = database[8:]
1635
1836
  elif os.getenv("PGSERVICE") is not None:
1636
1837
  service = os.getenv("PGSERVICE")
1637
- # because option --ping, --list or -l are not supposed to have a db name
1838
+ # because option --ping, --list or -l are not supposed to have a db name.
1839
+ # A connection string is not a db name though: a URI or a key=value conninfo
1840
+ # carries the whole connection (host, user, port, sslmode, ...), so replacing
1841
+ # it with "postgres" would throw all of that away and fall back to a local
1842
+ # socket connection as the OS user. Only a plain db name is discarded here;
1843
+ # a connection string that names no database gets "postgres" for the
1844
+ # listing, since libpq would otherwise default to the OS user name.
1845
+ is_conn_string = "://" in database or ("=" in database and service is None)
1638
1846
  if list_databases or ping_database:
1639
- database = "postgres"
1640
-
1641
- cfg = load_config(pgclirc, config_full_path)
1847
+ if not database:
1848
+ database = "postgres"
1849
+ elif is_conn_string:
1850
+ try:
1851
+ if not conninfo_to_dict(database).get("dbname"):
1852
+ database = make_conninfo(database, dbname="postgres")
1853
+ except Exception:
1854
+ pass # invalid conninfo: let the connection attempt report it
1855
+
1856
+ # PGCli() already loaded (and, if needed, wrote) the config above, so reuse it
1857
+ # rather than reading the file a second time to resolve the -D alias.
1858
+ cfg = pgcli.config
1642
1859
  if dsn != "":
1643
1860
  try:
1644
1861
  dsn_config = cfg["alias_dsn"][dsn]
@@ -1906,7 +2123,15 @@ def exception_formatter(e, verbose_errors: bool = False):
1906
2123
  def format_output(title, cur, headers, status, settings, explain_mode=False):
1907
2124
  output = []
1908
2125
  expanded = settings.expanded or settings.table_format == "vertical"
1909
- table_format = "vertical" if settings.expanded else settings.table_format
2126
+ if settings.tuples_only:
2127
+ # Rows and nothing else, so an unadorned format. This wins over
2128
+ # expanded output: with the headers suppressed there is no label
2129
+ # column left for the vertical formatter to lay out.
2130
+ table_format = "plain"
2131
+ elif settings.expanded:
2132
+ table_format = "vertical"
2133
+ else:
2134
+ table_format = settings.table_format
1910
2135
  max_width = settings.max_width
1911
2136
  case_function = settings.case_function
1912
2137
  if explain_mode:
@@ -1964,11 +2189,12 @@ def format_output(title, cur, headers, status, settings, explain_mode=False):
1964
2189
  dialect = "excel" if platform.system() == "Windows" else "unix"
1965
2190
  output_kwargs["dialect"] = dialect
1966
2191
 
1967
- if title: # Only print the title if it's not None.
2192
+ # The title is printed unless there is none, or -t asked for rows only.
2193
+ if title and not settings.tuples_only:
1968
2194
  output.append(title)
1969
2195
 
1970
2196
  if cur:
1971
- headers = [case_function(x) for x in headers]
2197
+ headers = [] if settings.tuples_only else [case_function(x) for x in headers]
1972
2198
  if max_width is not None:
1973
2199
  cur = list(cur)
1974
2200
  column_types = None
@@ -2002,8 +2228,8 @@ def format_output(title, cur, headers, status, settings, explain_mode=False):
2002
2228
 
2003
2229
  output = itertools.chain(output, formatted)
2004
2230
 
2005
- # Only print the status if it's not None
2006
- if status:
2231
+ # Likewise the status footer.
2232
+ if status and not settings.tuples_only:
2007
2233
  output = itertools.chain(output, [format_status(cur, status)])
2008
2234
 
2009
2235
  return output
@@ -20,10 +20,25 @@ def query_starts_with(formatted_sql, prefixes):
20
20
  return bool(formatted_sql) and formatted_sql.split()[0] in prefixes
21
21
 
22
22
 
23
- def query_is_unconditional_update(formatted_sql):
24
- """Check if the query starts with UPDATE and contains no WHERE."""
25
- tokens = formatted_sql.split()
26
- return bool(tokens) and tokens[0] == "update" and "where" not in tokens
23
+ def query_is_unconditional_update(query):
24
+ """Check if the query starts with UPDATE and contains no top-level WHERE clause.
25
+
26
+ Uses sqlparse's parse tree (rather than naive whitespace splitting) so that
27
+ the word "where" appearing inside a string literal, comment, or a nested
28
+ subquery doesn't get mistaken for an actual WHERE clause.
29
+ """
30
+ statements = sqlparse.parse(query)
31
+ if not statements:
32
+ return False
33
+ statement = statements[0]
34
+
35
+ first_token = statement.token_first(skip_cm=True)
36
+ if first_token is None or first_token.ttype is not sqlparse.tokens.DML:
37
+ return False
38
+ if first_token.value.upper() != "UPDATE":
39
+ return False
40
+
41
+ return not any(isinstance(token, sqlparse.sql.Where) for token in statement.tokens)
27
42
 
28
43
 
29
44
  def is_destructive(queries, keywords):
@@ -31,7 +46,7 @@ def is_destructive(queries, keywords):
31
46
  for query in sqlparse.split(queries):
32
47
  if query:
33
48
  formatted_sql = sqlparse.format(query.lower(), strip_comments=True).strip()
34
- if "unconditional_update" in keywords and query_is_unconditional_update(formatted_sql):
49
+ if "unconditional_update" in keywords and query_is_unconditional_update(query):
35
50
  return True
36
51
  if query_starts_with(formatted_sql, keywords):
37
52
  return True
@@ -3,22 +3,31 @@ import click
3
3
  from .parseutils import is_destructive
4
4
 
5
5
 
6
- def confirm_destructive_query(queries, keywords, alias):
6
+ def confirm_destructive_query(queries, keywords, alias, force=False):
7
7
  """Check if the query is destructive and prompts the user to confirm.
8
8
 
9
9
  Returns:
10
10
  * None if the query is non-destructive or we can't prompt the user.
11
- * True if the query is destructive and the user wants to proceed.
11
+ * True if the query is destructive and the user wants to proceed, or if the
12
+ confirmation was bypassed with `force`.
12
13
  * False if the query is destructive and the user doesn't want to proceed.
13
14
 
14
15
  """
16
+ if not is_destructive(queries, keywords):
17
+ return None
18
+
19
+ if force:
20
+ return True
21
+
22
+ if not sys.stdin.isatty():
23
+ return None
24
+
15
25
  info = "You're about to run a destructive command"
16
26
  if alias:
17
27
  info += f" in {click.style(alias, fg='red')}"
18
28
 
19
29
  prompt_text = f"{info}.\nDo you want to proceed?"
20
- if is_destructive(queries, keywords) and sys.stdin.isatty():
21
- return confirm(prompt_text)
30
+ return confirm(prompt_text)
22
31
 
23
32
 
24
33
  def confirm(*args, **kwargs):