pgcli 4.0.1__tar.gz → 4.2.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 (101) hide show
  1. {pgcli-4.0.1 → pgcli-4.2.0}/AUTHORS +8 -0
  2. {pgcli-4.0.1/pgcli.egg-info → pgcli-4.2.0}/PKG-INFO +17 -14
  3. {pgcli-4.0.1 → pgcli-4.2.0}/README.rst +6 -3
  4. {pgcli-4.0.1 → pgcli-4.2.0}/changelog.rst +41 -2
  5. pgcli-4.2.0/pgcli/__init__.py +1 -0
  6. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/main.py +244 -20
  7. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/pgclirc +10 -4
  8. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/pgcompleter.py +15 -8
  9. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/pgexecute.py +16 -1
  10. {pgcli-4.0.1 → pgcli-4.2.0/pgcli.egg-info}/PKG-INFO +17 -14
  11. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli.egg-info/SOURCES.txt +1 -1
  12. pgcli-4.2.0/pgcli.egg-info/dependency_links.txt +1 -0
  13. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli.egg-info/requires.txt +9 -3
  14. pgcli-4.2.0/pyproject.toml +93 -0
  15. {pgcli-4.0.1 → pgcli-4.2.0}/tests/conftest.py +2 -0
  16. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/basic_commands.feature +4 -0
  17. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/steps/basic_commands.py +13 -0
  18. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/steps/crud_database.py +1 -0
  19. pgcli-4.2.0/tests/test_application_name.py +17 -0
  20. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_main.py +98 -1
  21. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_pgcompleter.py +24 -0
  22. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_pgexecute.py +64 -2
  23. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_ssh_tunnel.py +3 -1
  24. pgcli-4.0.1/pgcli/__init__.py +0 -1
  25. pgcli-4.0.1/pgcli.egg-info/dependency_links.txt +0 -1
  26. pgcli-4.0.1/pyproject.toml +0 -21
  27. pgcli-4.0.1/setup.py +0 -76
  28. {pgcli-4.0.1 → pgcli-4.2.0}/LICENSE.txt +0 -0
  29. {pgcli-4.0.1 → pgcli-4.2.0}/MANIFEST.in +0 -0
  30. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/__main__.py +0 -0
  31. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/auth.py +0 -0
  32. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/completion_refresher.py +0 -0
  33. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/config.py +0 -0
  34. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/explain_output_formatter.py +0 -0
  35. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/key_bindings.py +0 -0
  36. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/magic.py +0 -0
  37. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/__init__.py +0 -0
  38. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/formatter/__init__.py +0 -0
  39. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/formatter/sqlformatter.py +0 -0
  40. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/parseutils/__init__.py +0 -0
  41. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/parseutils/ctes.py +0 -0
  42. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/parseutils/meta.py +0 -0
  43. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/parseutils/tables.py +0 -0
  44. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/parseutils/utils.py +0 -0
  45. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/pgliterals/__init__.py +0 -0
  46. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/pgliterals/main.py +0 -0
  47. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/pgliterals/pgliterals.json +0 -0
  48. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/prioritization.py +0 -0
  49. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/prompt_utils.py +0 -0
  50. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/packages/sqlcompletion.py +0 -0
  51. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/pgbuffer.py +0 -0
  52. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/pgstyle.py +0 -0
  53. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/pgtoolbar.py +0 -0
  54. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli/pyev.py +0 -0
  55. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli.egg-info/entry_points.txt +0 -0
  56. {pgcli-4.0.1 → pgcli-4.2.0}/pgcli.egg-info/top_level.txt +0 -0
  57. {pgcli-4.0.1 → pgcli-4.2.0}/setup.cfg +0 -0
  58. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/__init__.py +0 -0
  59. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/auto_vertical.feature +0 -0
  60. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/crud_database.feature +0 -0
  61. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/crud_table.feature +0 -0
  62. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/db_utils.py +0 -0
  63. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/environment.py +0 -0
  64. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/expanded.feature +0 -0
  65. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/fixture_data/help.txt +0 -0
  66. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/fixture_data/help_commands.txt +0 -0
  67. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/fixture_utils.py +0 -0
  68. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/iocommands.feature +0 -0
  69. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/named_queries.feature +0 -0
  70. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/pgbouncer.feature +0 -0
  71. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/specials.feature +0 -0
  72. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/steps/__init__.py +0 -0
  73. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/steps/auto_vertical.py +0 -0
  74. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/steps/crud_table.py +0 -0
  75. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/steps/expanded.py +0 -0
  76. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/steps/iocommands.py +0 -0
  77. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/steps/named_queries.py +0 -0
  78. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/steps/pgbouncer.py +0 -0
  79. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/steps/specials.py +0 -0
  80. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/steps/wrappers.py +0 -0
  81. {pgcli-4.0.1 → pgcli-4.2.0}/tests/features/wrappager.py +0 -0
  82. {pgcli-4.0.1 → pgcli-4.2.0}/tests/formatter/__init__.py +0 -0
  83. {pgcli-4.0.1 → pgcli-4.2.0}/tests/formatter/test_sqlformatter.py +0 -0
  84. {pgcli-4.0.1 → pgcli-4.2.0}/tests/metadata.py +0 -0
  85. {pgcli-4.0.1 → pgcli-4.2.0}/tests/parseutils/test_ctes.py +0 -0
  86. {pgcli-4.0.1 → pgcli-4.2.0}/tests/parseutils/test_function_metadata.py +0 -0
  87. {pgcli-4.0.1 → pgcli-4.2.0}/tests/parseutils/test_parseutils.py +0 -0
  88. {pgcli-4.0.1 → pgcli-4.2.0}/tests/pytest.ini +0 -0
  89. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_auth.py +0 -0
  90. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_completion_refresher.py +0 -0
  91. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_config.py +0 -0
  92. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_fuzzy_completion.py +0 -0
  93. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_naive_completion.py +0 -0
  94. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_pgspecial.py +0 -0
  95. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_prioritization.py +0 -0
  96. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_prompt_utils.py +0 -0
  97. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_rowlimit.py +0 -0
  98. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_smart_completion_multiple_schemata.py +0 -0
  99. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_smart_completion_public_schema_only.py +0 -0
  100. {pgcli-4.0.1 → pgcli-4.2.0}/tests/test_sqlcompletion.py +0 -0
  101. {pgcli-4.0.1 → pgcli-4.2.0}/tests/utils.py +0 -0
@@ -130,6 +130,14 @@ Contributors:
130
130
  * blag
131
131
  * Rob Berry (rob-b)
132
132
  * Sharon Yogev (sharonyogev)
133
+ * Hollis Wu (holi0317)
134
+ * Antonio Aguilar (crazybolillo)
135
+ * Andrew M. MacFie (amacfie)
136
+ * saucoide
137
+ * Chris Rose (offbyone/offby1)
138
+ * Mathieu Dupuy (deronnax)
139
+ * Chris Novakovic
140
+ * Josh Lynch (josh-lynch)
133
141
 
134
142
  Creator:
135
143
  --------
@@ -1,39 +1,39 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: pgcli
3
- Version: 4.0.1
3
+ Version: 4.2.0
4
4
  Summary: CLI for Postgres Database. With auto-completion and syntax highlighting.
5
- Home-page: http://pgcli.com
6
- Author: Pgcli Core Team
7
- Author-email: pgcli-dev@googlegroups.com
5
+ Author-email: Pgcli Core Team <pgcli-dev@googlegroups.com>
8
6
  License: BSD
7
+ Project-URL: Homepage, https://pgcli.com
9
8
  Classifier: Intended Audience :: Developers
10
9
  Classifier: License :: OSI Approved :: BSD License
11
10
  Classifier: Operating System :: Unix
12
11
  Classifier: Programming Language :: Python
13
12
  Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.8
15
13
  Classifier: Programming Language :: Python :: 3.9
16
14
  Classifier: Programming Language :: Python :: 3.10
17
15
  Classifier: Programming Language :: Python :: 3.11
18
16
  Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: Programming Language :: SQL
20
19
  Classifier: Topic :: Database
21
20
  Classifier: Topic :: Database :: Front-Ends
22
21
  Classifier: Topic :: Software Development
23
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
- Requires-Python: >=3.8
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/x-rst
25
25
  License-File: LICENSE.txt
26
26
  License-File: AUTHORS
27
27
  Requires-Dist: pgspecial>=2.0.0
28
28
  Requires-Dist: click>=4.1
29
29
  Requires-Dist: Pygments>=2.0
30
30
  Requires-Dist: prompt_toolkit<4.0.0,>=2.0.6
31
- Requires-Dist: psycopg>=3.0.14
32
- Requires-Dist: sqlparse<0.5,>=0.3.0
31
+ Requires-Dist: psycopg>=3.0.14; sys_platform != "win32"
32
+ Requires-Dist: psycopg-binary>=3.0.14; sys_platform == "win32"
33
+ Requires-Dist: sqlparse<0.6,>=0.3.0
33
34
  Requires-Dist: configobj>=5.0.6
34
- Requires-Dist: pendulum>=2.1.0
35
35
  Requires-Dist: cli_helpers[styles]>=2.2.1
36
- Requires-Dist: setproctitle>=1.1.9
36
+ Requires-Dist: setproctitle>=1.1.9; sys_platform != "win32" and "CYGWIN" not in sys_platform
37
37
  Provides-Extra: keyring
38
38
  Requires-Dist: keyring>=12.2.0; extra == "keyring"
39
39
  Provides-Extra: sshtunnel
@@ -220,7 +220,7 @@ Alternatively, you can install ``pgcli`` as a python package using a package
220
220
  manager called called ``pip``. You will need postgres installed on your system
221
221
  for this to work.
222
222
 
223
- In depth getting started guide for ``pip`` - https://pip.pypa.io/en/latest/installing.html.
223
+ In depth getting started guide for ``pip`` - https://pip.pypa.io/en/latest/installation/
224
224
 
225
225
  ::
226
226
 
@@ -250,7 +250,7 @@ If pip is not installed check if easy_install is available on the system.
250
250
  Linux:
251
251
  ======
252
252
 
253
- In depth getting started guide for ``pip`` - https://pip.pypa.io/en/latest/installing.html.
253
+ In depth getting started guide for ``pip`` - https://pip.pypa.io/en/latest/installation/
254
254
 
255
255
  Check if pip is already available in your system.
256
256
 
@@ -393,7 +393,10 @@ choice:
393
393
 
394
394
  In [3]: my_result = _
395
395
 
396
- Pgcli dropped support for Python<3.8 as of 4.0.0. If you need it, install ``pgcli <= 4.0.0``.
396
+ Pgcli dropped support for:
397
+
398
+ * Python<3.8 as of 4.0.0.
399
+ * Python<3.9 as of 4.2.0.
397
400
 
398
401
  Thanks:
399
402
  -------
@@ -179,7 +179,7 @@ Alternatively, you can install ``pgcli`` as a python package using a package
179
179
  manager called called ``pip``. You will need postgres installed on your system
180
180
  for this to work.
181
181
 
182
- In depth getting started guide for ``pip`` - https://pip.pypa.io/en/latest/installing.html.
182
+ In depth getting started guide for ``pip`` - https://pip.pypa.io/en/latest/installation/
183
183
 
184
184
  ::
185
185
 
@@ -209,7 +209,7 @@ If pip is not installed check if easy_install is available on the system.
209
209
  Linux:
210
210
  ======
211
211
 
212
- In depth getting started guide for ``pip`` - https://pip.pypa.io/en/latest/installing.html.
212
+ In depth getting started guide for ``pip`` - https://pip.pypa.io/en/latest/installation/
213
213
 
214
214
  Check if pip is already available in your system.
215
215
 
@@ -352,7 +352,10 @@ choice:
352
352
 
353
353
  In [3]: my_result = _
354
354
 
355
- Pgcli dropped support for Python<3.8 as of 4.0.0. If you need it, install ``pgcli <= 4.0.0``.
355
+ Pgcli dropped support for:
356
+
357
+ * Python<3.8 as of 4.0.0.
358
+ * Python<3.9 as of 4.2.0.
356
359
 
357
360
  Thanks:
358
361
  -------
@@ -1,5 +1,44 @@
1
+ 4.2.0 (2025-03-06)
1
2
  ==================
2
- 4.0.1 (2023-11-30)
3
+
4
+ Features
5
+ --------
6
+ * Add a `--ping` command line option; allows pgcli to replace `pg_isready`
7
+ * Changed the packaging metadata from setup.py to pyproject.toml
8
+
9
+ Bug fixes:
10
+ ----------
11
+ * Avoid raising `NameError` when exiting unsuccessfully in some cases
12
+ * Use configured `alias_map_file` to generate table aliases if available.
13
+
14
+ Internal:
15
+ ---------
16
+
17
+ * Drop support for Python 3.8 and add 3.13.
18
+
19
+ 4.1.0 (2024-03-09)
20
+ ==================
21
+
22
+ Features:
23
+ ---------
24
+ * Support `PGAPPNAME` as an environment variable and `--application-name` as a command line argument.
25
+ * Add `verbose_errors` config and `\v` special command which enable the
26
+ displaying of all Postgres error fields received.
27
+ * Show Postgres notifications.
28
+ * Support sqlparse 0.5.x
29
+ * Add `--log-file [filename]` cli argument and `\log-file [filename]` special commands to
30
+ log to an external file in addition to the normal output
31
+
32
+ Bug fixes:
33
+ ----------
34
+
35
+ * Fix display of "short host" in prompt (with `\h`) for IPv4 addresses ([issue 964](https://github.com/dbcli/pgcli/issues/964)).
36
+ * Fix backwards display of NOTICEs from a Function ([issue 1443](https://github.com/dbcli/pgcli/issues/1443))
37
+ * Fix psycopg errors when installing on Windows. ([issue 1413](https://https://github.com/dbcli/pgcli/issues/1413))
38
+ * Use a home-made function to display query duration instead of relying on a third-party library (the general behaviour does not change), which fixes the installation of `pgcli` on 32-bit architectures ([issue 1451](https://github.com/dbcli/pgcli/issues/1451))
39
+
40
+ ==================
41
+ 4.0.1 (2023-10-30)
3
42
  ==================
4
43
 
5
44
  Internal:
@@ -7,7 +46,7 @@ Internal:
7
46
  * Allow stable version of pendulum.
8
47
 
9
48
  ==================
10
- 4.0.0 (2023-11-27)
49
+ 4.0.0 (2023-10-27)
11
50
  ==================
12
51
 
13
52
  Features:
@@ -0,0 +1 @@
1
+ __version__ = "4.2.0"
@@ -11,9 +11,9 @@ import logging
11
11
  import threading
12
12
  import shutil
13
13
  import functools
14
- import pendulum
15
14
  import datetime as dt
16
15
  import itertools
16
+ import pathlib
17
17
  import platform
18
18
  from time import time, sleep
19
19
  from typing import Optional
@@ -74,8 +74,9 @@ from urllib.parse import urlparse
74
74
 
75
75
  from getpass import getuser
76
76
 
77
- from psycopg import OperationalError, InterfaceError
77
+ from psycopg import OperationalError, InterfaceError, Notify
78
78
  from psycopg.conninfo import make_conninfo, conninfo_to_dict
79
+ from psycopg.errors import Diagnostic
79
80
 
80
81
  from collections import namedtuple
81
82
 
@@ -129,6 +130,15 @@ class PgCliQuitError(Exception):
129
130
  pass
130
131
 
131
132
 
133
+ def notify_callback(notify: Notify):
134
+ click.secho(
135
+ 'Notification received on channel "{}" (PID {}):\n{}'.format(
136
+ notify.channel, notify.pid, notify.payload
137
+ ),
138
+ fg="green",
139
+ )
140
+
141
+
132
142
  class PGCli:
133
143
  default_prompt = "\\u@\\h:\\d> "
134
144
  max_len_prompt = 30
@@ -165,6 +175,7 @@ class PGCli:
165
175
  pgexecute=None,
166
176
  pgclirc_file=None,
167
177
  row_limit=None,
178
+ application_name="pgcli",
168
179
  single_connection=False,
169
180
  less_chatty=None,
170
181
  prompt=None,
@@ -172,6 +183,7 @@ class PGCli:
172
183
  auto_vertical_output=False,
173
184
  warn=None,
174
185
  ssh_tunnel_url: Optional[str] = None,
186
+ log_file: Optional[str] = None,
175
187
  ):
176
188
  self.force_passwd_prompt = force_passwd_prompt
177
189
  self.never_passwd_prompt = never_passwd_prompt
@@ -210,6 +222,8 @@ class PGCli:
210
222
  else:
211
223
  self.row_limit = c["main"].as_int("row_limit")
212
224
 
225
+ self.application_name = application_name
226
+
213
227
  # if not specified, set to DEFAULT_MAX_FIELD_WIDTH
214
228
  # if specified but empty, set to None to disable truncation
215
229
  # ellipsis will take at least 3 symbols, so this can't be less than 3 if specified and > 0
@@ -237,6 +251,9 @@ class PGCli:
237
251
  )
238
252
 
239
253
  self.less_chatty = bool(less_chatty) or c["main"].as_bool("less_chatty")
254
+ self.verbose_errors = "verbose_errors" in c["main"] and c["main"].as_bool(
255
+ "verbose_errors"
256
+ )
240
257
  self.null_string = c["main"].get("null_string", "<null>")
241
258
  self.prompt_format = (
242
259
  prompt
@@ -295,6 +312,11 @@ class PGCli:
295
312
  self.ssh_tunnel_url = ssh_tunnel_url
296
313
  self.ssh_tunnel = None
297
314
 
315
+ if log_file:
316
+ with open(log_file, "a+"):
317
+ pass # ensure writeable
318
+ self.log_file = log_file
319
+
298
320
  # formatter setup
299
321
  self.formatter = TabularOutputFormatter(format_name=c["main"]["table_format"])
300
322
  register_new_formatter(self.formatter)
@@ -354,6 +376,12 @@ class PGCli:
354
376
  "\\o [filename]",
355
377
  "Send all query results to file.",
356
378
  )
379
+ self.pgspecial.register(
380
+ self.write_to_logfile,
381
+ "\\log-file",
382
+ "\\log-file [filename]",
383
+ "Log all query results to a logfile, in addition to the normal output destination.",
384
+ )
357
385
  self.pgspecial.register(
358
386
  self.info_connection, "\\conninfo", "\\conninfo", "Get connection details"
359
387
  )
@@ -378,6 +406,26 @@ class PGCli:
378
406
  "Echo a string to the query output channel.",
379
407
  )
380
408
 
409
+ self.pgspecial.register(
410
+ self.toggle_verbose_errors,
411
+ "\\v",
412
+ "\\v [on|off]",
413
+ "Toggle verbose errors.",
414
+ )
415
+
416
+ def toggle_verbose_errors(self, pattern, **_):
417
+ flag = pattern.strip()
418
+
419
+ if flag == "on":
420
+ self.verbose_errors = True
421
+ elif flag == "off":
422
+ self.verbose_errors = False
423
+ else:
424
+ self.verbose_errors = not self.verbose_errors
425
+
426
+ message = "Verbose errors " + "on." if self.verbose_errors else "off."
427
+ return [(None, None, None, message)]
428
+
381
429
  def echo(self, pattern, **_):
382
430
  return [(None, None, None, pattern)]
383
431
 
@@ -473,6 +521,26 @@ class PGCli:
473
521
  explain_mode=self.explain_mode,
474
522
  )
475
523
 
524
+ def write_to_logfile(self, pattern, **_):
525
+ if not pattern:
526
+ self.log_file = None
527
+ message = "Logfile capture disabled"
528
+ return [(None, None, None, message, "", True, True)]
529
+
530
+ log_file = pathlib.Path(pattern).expanduser().absolute()
531
+
532
+ try:
533
+ with open(log_file, "a+"):
534
+ pass # ensure writeable
535
+ except OSError as e:
536
+ self.log_file = None
537
+ message = str(e) + "\nLogfile capture disabled"
538
+ return [(None, None, None, message, "", False, True)]
539
+
540
+ self.log_file = str(log_file)
541
+ message = 'Writing to file "%s"' % self.log_file
542
+ return [(None, None, None, message, "", True, True)]
543
+
476
544
  def write_to_file(self, pattern, **_):
477
545
  if not pattern:
478
546
  self.output_file = None
@@ -542,7 +610,7 @@ class PGCli:
542
610
  click.secho(
543
611
  f"service '{service}' was not found in {file}", err=True, fg="red"
544
612
  )
545
- exit(1)
613
+ sys.exit(1)
546
614
  self.connect(
547
615
  database=service_config.get("dbname"),
548
616
  host=service_config.get("host"),
@@ -568,7 +636,7 @@ class PGCli:
568
636
  if not database:
569
637
  database = user
570
638
 
571
- kwargs.setdefault("application_name", "pgcli")
639
+ kwargs.setdefault("application_name", self.application_name)
572
640
 
573
641
  # If password prompt is not forced but no password is provided, try
574
642
  # getting it from environment variable.
@@ -642,7 +710,7 @@ class PGCli:
642
710
  self.logger.handlers = logger_handlers
643
711
  self.logger.error("traceback: %r", traceback.format_exc())
644
712
  click.secho(str(e), err=True, fg="red")
645
- exit(1)
713
+ sys.exit(1)
646
714
  self.logger.handlers = logger_handlers
647
715
 
648
716
  atexit.register(self.ssh_tunnel.stop)
@@ -658,7 +726,16 @@ class PGCli:
658
726
  # prompt for a password (no -w flag), prompt for a passwd and try again.
659
727
  try:
660
728
  try:
661
- pgexecute = PGExecute(database, user, passwd, host, port, dsn, **kwargs)
729
+ pgexecute = PGExecute(
730
+ database,
731
+ user,
732
+ passwd,
733
+ host,
734
+ port,
735
+ dsn,
736
+ notify_callback,
737
+ **kwargs,
738
+ )
662
739
  except (OperationalError, InterfaceError) as e:
663
740
  if should_ask_for_password(e):
664
741
  passwd = click.prompt(
@@ -668,7 +745,14 @@ class PGCli:
668
745
  type=str,
669
746
  )
670
747
  pgexecute = PGExecute(
671
- database, user, passwd, host, port, dsn, **kwargs
748
+ database,
749
+ user,
750
+ passwd,
751
+ host,
752
+ port,
753
+ dsn,
754
+ notify_callback,
755
+ **kwargs,
672
756
  )
673
757
  else:
674
758
  raise e
@@ -679,7 +763,7 @@ class PGCli:
679
763
  self.logger.debug("Database connection failed: %r.", e)
680
764
  self.logger.error("traceback: %r", traceback.format_exc())
681
765
  click.secho(str(e), err=True, fg="red")
682
- exit(1)
766
+ sys.exit(1)
683
767
 
684
768
  self.pgexecute = pgexecute
685
769
 
@@ -775,7 +859,7 @@ class PGCli:
775
859
  else:
776
860
  try:
777
861
  if self.output_file and not text.startswith(
778
- ("\\o ", "\\? ", "\\echo ")
862
+ ("\\o ", "\\log-file", "\\? ", "\\echo ")
779
863
  ):
780
864
  try:
781
865
  with open(self.output_file, "a", encoding="utf-8") as f:
@@ -787,6 +871,23 @@ class PGCli:
787
871
  else:
788
872
  if output:
789
873
  self.echo_via_pager("\n".join(output))
874
+
875
+ # Log to file in addition to normal output
876
+ if (
877
+ self.log_file
878
+ and not text.startswith(("\\o ", "\\log-file", "\\? ", "\\echo "))
879
+ and not text.strip() == ""
880
+ ):
881
+ try:
882
+ with open(self.log_file, "a", encoding="utf-8") as f:
883
+ click.echo(
884
+ dt.datetime.now().isoformat(), file=f
885
+ ) # timestamp log
886
+ click.echo(text, file=f)
887
+ click.echo("\n".join(output), file=f)
888
+ click.echo("", file=f) # extra newline
889
+ except OSError as e:
890
+ click.secho(str(e), err=True, fg="red")
790
891
  except KeyboardInterrupt:
791
892
  pass
792
893
 
@@ -797,9 +898,9 @@ class PGCli:
797
898
  "Time: %0.03fs (%s), executed in: %0.03fs (%s)"
798
899
  % (
799
900
  query.total_time,
800
- pendulum.Duration(seconds=query.total_time).in_words(),
901
+ duration_in_words(query.total_time),
801
902
  query.execution_time,
802
- pendulum.Duration(seconds=query.execution_time).in_words(),
903
+ duration_in_words(query.execution_time),
803
904
  )
804
905
  )
805
906
  else:
@@ -1053,7 +1154,7 @@ class PGCli:
1053
1154
  res = self.pgexecute.run(
1054
1155
  text,
1055
1156
  self.pgspecial,
1056
- exception_formatter,
1157
+ lambda x: exception_formatter(x, self.verbose_errors),
1057
1158
  on_error_resume,
1058
1159
  explain_mode=self.explain_mode,
1059
1160
  )
@@ -1337,6 +1438,12 @@ class PGCli:
1337
1438
  type=click.INT,
1338
1439
  help="Set threshold for row limit prompt. Use 0 to disable prompt.",
1339
1440
  )
1441
+ @click.option(
1442
+ "--application-name",
1443
+ default="pgcli",
1444
+ envvar="PGAPPNAME",
1445
+ help="Application name for the connection.",
1446
+ )
1340
1447
  @click.option(
1341
1448
  "--less-chatty",
1342
1449
  "less_chatty",
@@ -1356,6 +1463,13 @@ class PGCli:
1356
1463
  is_flag=True,
1357
1464
  help="list available databases, then exit.",
1358
1465
  )
1466
+ @click.option(
1467
+ "--ping",
1468
+ "ping_database",
1469
+ is_flag=True,
1470
+ default=False,
1471
+ help="Check database connectivity, then exit.",
1472
+ )
1359
1473
  @click.option(
1360
1474
  "--auto-vertical-output",
1361
1475
  is_flag=True,
@@ -1371,6 +1485,11 @@ class PGCli:
1371
1485
  default=None,
1372
1486
  help="Open an SSH tunnel to the given address and connect to the database from it.",
1373
1487
  )
1488
+ @click.option(
1489
+ "--log-file",
1490
+ default=None,
1491
+ help="Write all queries & output into a file, in addition to the normal output destination.",
1492
+ )
1374
1493
  @click.argument("dbname", default=lambda: None, envvar="PGDATABASE", nargs=1)
1375
1494
  @click.argument("username", default=lambda: None, envvar="PGUSER", nargs=1)
1376
1495
  def cli(
@@ -1387,14 +1506,17 @@ def cli(
1387
1506
  pgclirc,
1388
1507
  dsn,
1389
1508
  row_limit,
1509
+ application_name,
1390
1510
  less_chatty,
1391
1511
  prompt,
1392
1512
  prompt_dsn,
1393
1513
  list_databases,
1514
+ ping_database,
1394
1515
  auto_vertical_output,
1395
1516
  list_dsn,
1396
1517
  warn,
1397
1518
  ssh_tunnel: str,
1519
+ log_file: str,
1398
1520
  ):
1399
1521
  if version:
1400
1522
  print("Version:", __version__)
@@ -1429,7 +1551,7 @@ def cli(
1429
1551
  err=True,
1430
1552
  fg="red",
1431
1553
  )
1432
- exit(1)
1554
+ sys.exit(1)
1433
1555
 
1434
1556
  if ssh_tunnel and not SSH_TUNNEL_SUPPORT:
1435
1557
  click.secho(
@@ -1438,13 +1560,14 @@ def cli(
1438
1560
  err=True,
1439
1561
  fg="red",
1440
1562
  )
1441
- exit(1)
1563
+ sys.exit(1)
1442
1564
 
1443
1565
  pgcli = PGCli(
1444
1566
  prompt_passwd,
1445
1567
  never_prompt,
1446
1568
  pgclirc_file=pgclirc,
1447
1569
  row_limit=row_limit,
1570
+ application_name=application_name,
1448
1571
  single_connection=single_connection,
1449
1572
  less_chatty=less_chatty,
1450
1573
  prompt=prompt,
@@ -1452,6 +1575,7 @@ def cli(
1452
1575
  auto_vertical_output=auto_vertical_output,
1453
1576
  warn=warn,
1454
1577
  ssh_tunnel_url=ssh_tunnel,
1578
+ log_file=log_file,
1455
1579
  )
1456
1580
 
1457
1581
  # Choose which ever one has a valid value.
@@ -1465,8 +1589,8 @@ def cli(
1465
1589
  service = database[8:]
1466
1590
  elif os.getenv("PGSERVICE") is not None:
1467
1591
  service = os.getenv("PGSERVICE")
1468
- # because option --list or -l are not supposed to have a db name
1469
- if list_databases:
1592
+ # because option --ping, --list or -l are not supposed to have a db name
1593
+ if list_databases or ping_database:
1470
1594
  database = "postgres"
1471
1595
 
1472
1596
  if dsn != "":
@@ -1480,7 +1604,7 @@ def cli(
1480
1604
  err=True,
1481
1605
  fg="red",
1482
1606
  )
1483
- exit(1)
1607
+ sys.exit(1)
1484
1608
  except Exception:
1485
1609
  click.secho(
1486
1610
  "Invalid DSNs found in the config file. "
@@ -1488,7 +1612,7 @@ def cli(
1488
1612
  err=True,
1489
1613
  fg="red",
1490
1614
  )
1491
- exit(1)
1615
+ sys.exit(1)
1492
1616
  pgcli.connect_uri(dsn_config)
1493
1617
  pgcli.dsn_alias = dsn
1494
1618
  elif "://" in database:
@@ -1510,6 +1634,20 @@ def cli(
1510
1634
 
1511
1635
  sys.exit(0)
1512
1636
 
1637
+ if ping_database:
1638
+ try:
1639
+ list(pgcli.pgexecute.run("SELECT 1"))
1640
+ except Exception:
1641
+ click.secho(
1642
+ "Could not connect to the database. Please check that the database is running.",
1643
+ err=True,
1644
+ fg="red",
1645
+ )
1646
+ sys.exit(1)
1647
+ else:
1648
+ click.echo("PONG")
1649
+ sys.exit(0)
1650
+
1513
1651
  pgcli.logger.debug(
1514
1652
  "Launch Params: \n" "\tdatabase: %r" "\tuser: %r" "\thost: %r" "\tport: %r",
1515
1653
  database,
@@ -1583,8 +1721,71 @@ def is_select(status):
1583
1721
  return status.split(None, 1)[0].lower() == "select"
1584
1722
 
1585
1723
 
1586
- def exception_formatter(e):
1587
- return click.style(str(e), fg="red")
1724
+ def diagnostic_output(diagnostic: Diagnostic) -> str:
1725
+ fields = []
1726
+
1727
+ if diagnostic.severity is not None:
1728
+ fields.append("Severity: " + diagnostic.severity)
1729
+
1730
+ if diagnostic.severity_nonlocalized is not None:
1731
+ fields.append("Severity (non-localized): " + diagnostic.severity_nonlocalized)
1732
+
1733
+ if diagnostic.sqlstate is not None:
1734
+ fields.append("SQLSTATE code: " + diagnostic.sqlstate)
1735
+
1736
+ if diagnostic.message_primary is not None:
1737
+ fields.append("Message: " + diagnostic.message_primary)
1738
+
1739
+ if diagnostic.message_detail is not None:
1740
+ fields.append("Detail: " + diagnostic.message_detail)
1741
+
1742
+ if diagnostic.message_hint is not None:
1743
+ fields.append("Hint: " + diagnostic.message_hint)
1744
+
1745
+ if diagnostic.statement_position is not None:
1746
+ fields.append("Position: " + diagnostic.statement_position)
1747
+
1748
+ if diagnostic.internal_position is not None:
1749
+ fields.append("Internal position: " + diagnostic.internal_position)
1750
+
1751
+ if diagnostic.internal_query is not None:
1752
+ fields.append("Internal query: " + diagnostic.internal_query)
1753
+
1754
+ if diagnostic.context is not None:
1755
+ fields.append("Where: " + diagnostic.context)
1756
+
1757
+ if diagnostic.schema_name is not None:
1758
+ fields.append("Schema name: " + diagnostic.schema_name)
1759
+
1760
+ if diagnostic.table_name is not None:
1761
+ fields.append("Table name: " + diagnostic.table_name)
1762
+
1763
+ if diagnostic.column_name is not None:
1764
+ fields.append("Column name: " + diagnostic.column_name)
1765
+
1766
+ if diagnostic.datatype_name is not None:
1767
+ fields.append("Data type name: " + diagnostic.datatype_name)
1768
+
1769
+ if diagnostic.constraint_name is not None:
1770
+ fields.append("Constraint name: " + diagnostic.constraint_name)
1771
+
1772
+ if diagnostic.source_file is not None:
1773
+ fields.append("File: " + diagnostic.source_file)
1774
+
1775
+ if diagnostic.source_line is not None:
1776
+ fields.append("Line: " + diagnostic.source_line)
1777
+
1778
+ if diagnostic.source_function is not None:
1779
+ fields.append("Routine: " + diagnostic.source_function)
1780
+
1781
+ return "\n".join(fields)
1782
+
1783
+
1784
+ def exception_formatter(e, verbose_errors: bool = False):
1785
+ s = str(e)
1786
+ if verbose_errors:
1787
+ s += "\n" + diagnostic_output(e.diag)
1788
+ return click.style(s, fg="red")
1588
1789
 
1589
1790
 
1590
1791
  def format_output(title, cur, headers, status, settings, explain_mode=False):
@@ -1724,5 +1925,28 @@ def parse_service_info(service):
1724
1925
  return service_conf, service_file
1725
1926
 
1726
1927
 
1928
+ def duration_in_words(duration_in_seconds: float) -> str:
1929
+ if not duration_in_seconds:
1930
+ return "0 seconds"
1931
+ components = []
1932
+ hours, remainder = divmod(duration_in_seconds, 3600)
1933
+ if hours > 1:
1934
+ components.append(f"{hours} hours")
1935
+ elif hours == 1:
1936
+ components.append("1 hour")
1937
+ minutes, seconds = divmod(remainder, 60)
1938
+ if minutes > 1:
1939
+ components.append(f"{minutes} minutes")
1940
+ elif minutes == 1:
1941
+ components.append("1 minute")
1942
+ if seconds >= 2:
1943
+ components.append(f"{int(seconds)} seconds")
1944
+ elif seconds >= 1:
1945
+ components.append("1 second")
1946
+ elif seconds:
1947
+ components.append(f"{round(seconds, 3)} second")
1948
+ return " ".join(components)
1949
+
1950
+
1727
1951
  if __name__ == "__main__":
1728
1952
  cli()