1
0
Fork 0

Compare commits

...

47 Commits

Author SHA1 Message Date
Andrew MacFie 1261686bd1 Update changelog 2024-03-19 02:04:07 +07:00
Andrew MacFie 2f1b564469 Blackify 2024-03-19 02:00:26 +07:00
Andrew MacFie ca2f7568f9 Add test 2024-03-19 01:55:03 +07:00
Andrew MacFie 576b2744bc Clean up a bit 2024-03-19 00:53:35 +07:00
Andrew MacFie e6f12049a8 Merge branch 'main' into listen 2024-03-19 00:41:06 +07:00
Damien Baty 9f114c4549 feat: Replace pendulum by home-made duration-to-words function
`pgcli` uses Pendulum to display the query execution time in words:

    > select pg_sleep(62)
    +----------+
    | pg_sleep |
    |----------|
    |          |
    +----------+
    SELECT 1
    Time: 62.066s (1 minute 2 seconds), executed in: 62.063s (1 minute 2 seconds)

Pendulum 3 (which has been released in December 2023 and is now
written in Rust) does not build on 32-bit architectures [1]. As such,
installing `pgcli` on such architectures fails. We could pin Pendulum
to version 2 (which was written in Python and builds "everywhere"),
but requiring a whole library and its own dependencies for such a
small feature seems unwarranted.

This commit thus removes the requirement on Pendulum and replaces it
by a simple "duration-to-words" function.

Fixes #1451.

[1] Upstream issue: https://github.com/sdispater/pendulum/issues/784
2024-02-20 12:51:20 +01:00
ERYoung11 96eb37fd19
Fix psycopg installation error on windows, Issue #1413 (#1449)
* Fix Issue #1413, win32 psycopg

* Fix Issue #1413, win32 psycopg

* fixing syntax error

* black fix to a test

---------

Co-authored-by: Irina Truong <637013+j-bennet@users.noreply.github.com>
2024-02-05 21:44:05 -08:00
ERYoung11 27b2bc2d37
Raised notices (#1450)
* Fix - Raised notices are printed backwards #1443

* updated changelog

* removed a print

* fixed up syntax error

* removing unneeded Nones from output

* rem var due to github recommendation

* adjusting if statements.
2024-02-05 21:41:28 -08:00
Antonio e2ff38d170
Support PGAPPNAME (#1444)
The application_name to be used when connecting to a database can now be
specified as a command line argument (--application-name) or be taken
directly from environment variables (PGAPPNAME). It still defaults to
'pgcli' when not specified.

Closes #1421.
2023-12-08 20:21:28 -08:00
Damien Baty f2156b3151
Fix short host (\h) display in prompt when using an IP address (#1441)
When connecting to an IPv4 address (`pgcli -h 127.0.0.1`), trying to
use the "short host" in the prompt (with `\h`) would only display the
first octet (127). Now it shows the full IP.

Fixes #964.
2023-11-17 22:38:50 -08:00
Hollis Wu 89979a9353
Fix changelog date (#1438)
* Fix changelog date

* Add my name to authors
2023-11-08 17:07:21 -08:00
Damien Baty 5050f01abc
pgclirc: Clarify description of `destructive_warning_restarts_connection` option (#1437) 2023-11-06 14:10:47 +08:00
Irina Truong 04ca41a262 Releasing version 4.0.1 2023-10-30 20:34:14 -07:00
Irina Truong 525487c36a
Release the pendulum unbump. (#1435) 2023-10-30 10:18:09 +08:00
Dick Marinus 08cf5e720f
downgrade pendulum to released version (#1434)
* downgrade pendulum to released version

* install beta version of pendulum for 3.12
2023-10-29 16:04:00 -07:00
Irina Truong 8c72820d7e Fix readme. 2023-10-27 16:14:37 -07:00
Irina Truong c77529e6a1 Releasing version 4.0.0 2023-10-27 16:01:59 -07:00
Irina Truong 461f7dd267
Update changelog before release. (#1432) 2023-10-27 15:55:44 -07:00
Sharon Yogev 0ad3393fa8
confirm_destructive_query: Use confirm rather than prompt (#1410)
* confirm_destructive_query: Use confirm rather than prompt

* Fix tests
2023-10-18 09:25:29 -07:00
ERYoung11 13ca7d2430
fixed #1403, improved comment handling (#1404)
* fixed #1403, improved comment handling

* black + hooks + changelog

---------

Co-authored-by: Irina Truong <i.chernyavska@gmail.com>
2023-10-12 22:28:11 -07:00
Irina Truong 6332e18b48
Drop python 3.7, add 3.12 (#1426)
* Fix deprecation.

* Drop python 3.7 and add 3.12.

* Bump pendulum.

* Changelog.

* Update gh actions.

* See if things pass without this scenario.

* Skip failing scenarios in 3.12.
2023-10-11 12:34:59 +08:00
laixintao f157f3f72e
runs test on main branch as well. (#1429)
* runs test on main branch as well.

reasons:
- tests passed on PR branches do not mean they will pass on main branch,
  for e.g. alice changed A in branch-a, bob changed B in branch-b, both
  tests passed and no conflict, but after merged to main, a bug occurs.
- wanna fix the badge in main branch ;D

* change the badge status to main branch only.
2023-10-07 17:56:08 -07:00
Amjith Ramanujam a8e34be151
Merge pull request #1425 from dbcli/j-bennet/update-black
Update black
2023-10-06 19:51:15 -07:00
Irina Truong f8ef25309b Go back to black 23.3.0 which supports 3.7 2023-10-06 18:16:30 -07:00
Irina Truong 43f24a5338 Newer black requires 3.8. 2023-10-06 17:51:35 -07:00
Irina Truong 4a4de5260c Update black. 2023-10-06 17:46:46 -07:00
Rob Berry 97a1fd6c16
Allow defining a json file with preferred aliases (#1382)
* fix psycopg.sql.Identifier in \ev handling (#1384)

* Allow defining a json file with preferred aliases

At $WORK we have a lot of tables with names like `foo_noun_verb` or
`foo_noun_related-noun_verb` and so while the default aliasing is very
helpful for shortening unwieldy names we do end up with lots of aliases
like `LEFT JOIN fnv on fnv2.id = fnv.fnv2_id`

This change will allow defining a json file of preferred aliases

```
> cat ~/.config/pgcli/aliases.json
{
    "foo_user": "user",
    "foo_user_group": "user_group"
}
```

so the alias suggestion for `SELECT * FROM foo_user` will be `SELECT * FROM foo_user AS user`
instead of the default `SELECT * FROM foo_user AS fu`

* When cannot open or parse alias_map_file raise error

Raise a (hopefully) helpful exception when the alias_map_file cannot be
parsed or does not exist

* Add tests for load_alias_map_file

* Add tests for generate_alias

* Update AUTHORS file

* Remove comment.

Discussed this on the PR with a project maintainer

---------

Co-authored-by: Andy Schoenberger <akschoenberger@gmail.com>
Co-authored-by: Rob B <rob@example.com>
Co-authored-by: Irina Truong <i.chernyavska@gmail.com>
2023-10-06 16:13:28 -07:00
ERYoung11 43360b5d1b
Added \echo & \qecho for Issue #1335 (#1371)
* Added \echo & \qecho for Issue #1335

* black + changelog updates

* trying to re-kick build process
2023-10-06 15:56:48 -07:00
Amjith Ramanujam a615333686
Merge pull request #1419 from dbcli/j-bennet/maintainer-contact-info
Add maintainer contact info to readme
2023-09-26 22:03:38 -07:00
Irina Truong 44e213022d Add maintainer contact info to readme. 2023-09-26 21:45:55 -07:00
Damien Baty cdfa35830b
Ask for confirmation to quit cli if a transaction is ongoing. (#1400)
If user tries to quit the cli while a transaction is ongoing (i.e.
begun, but not committed or rolled back yet), pgcli now asks for a
confirmation. The user can choose to commit, rollback or cancel the
exit. If the user chooses to commit or rollback, we exit only if the
commit/rollback succeeds.

Fixes #1071.
2023-09-26 21:36:59 -07:00
blag ed89c154ee
For Python >= 3.11 directly use packaging to compare package versions (#1416)
* For Python >= 3.11 directly use packaging to compare package versions

* Improve prompt-toolkit check to test for feature explicitly
2023-09-12 12:46:34 -07:00
astroshot 69dcceb5f6
Fix sql-insert format emits NULL as 'None' (#1409)
* Sub: Fix issue #1408

Body:
1. Fix issue #1408 sql-insert format emits NULL as 'None';
2. Fix DUAL displays as ""DUAL"";

==== End ====

* Sub: Update changelog.rst

Body:

==== End ====

* Sub: Optimize if logic

Body:

==== End ====
2023-06-23 07:05:58 +02:00
Daniel Kukula 6b868bbfe8
Update pyev.py (#1406)
fix typos
2023-05-25 00:40:11 +02:00
Amjith Ramanujam c2f2f5abb2
Merge pull request #1399 from dbaty/dbaty/remove_python2_support
Remove leftovers of Python 2 support
2023-03-18 11:02:54 -07:00
Damien Baty d8eb8b5b82 Apply black (version 23.1.0) 2023-03-18 14:00:38 +01:00
Damien Baty 1c071770bb Remove leftovers of Python 2 support 2023-03-18 10:22:12 +01:00
Amjith Ramanujam 5e34e0b557
Merge pull request #1397 from dbaty/dbaty/require_transaction_for_destructive_statement
feature: Add config option to require a transaction for destructive statements
2023-03-17 19:05:10 -07:00
Damien Baty a93442aed7 lint: Remove unused variables and imported functions 2023-03-08 09:42:43 +01:00
Damien Baty f4dc796941 Add config option to require a transaction for destructive statements
When this option is on, any statement that is deemed destructive
(through the use of the `destructive_warning` config option) will
not be executed unless a transaction has been started.
2023-03-08 09:42:43 +01:00
Andy Schoenberger 8ef5392fd1
fix explain output when running with auto-vertical-output or max_width (#1396) 2023-02-27 15:20:59 -08:00
Andy Schoenberger 0a502accfc
add comment to pgclirc pager section on default LESS (#1395) 2023-02-27 14:14:48 -08:00
Andy Schoenberger 141873f86d
Add config option to always run with a single connection (#1386)
Co-authored-by: Irina Truong <i.chernyavska@gmail.com>
2023-01-03 13:52:31 -08:00
Amjith Ramanujam 81b0431d80
Merge pull request #1390 from dbcli/j-bennet/1373-fix-dsn
Fix connecting with dsn.
2023-01-02 15:15:21 -08:00
Irina Truong 7a4086c7c0 Python -m py.test doesn't work anymore. 2023-01-02 15:05:35 -08:00
Irina Truong 4e7bd7cc7a Changelog. 2023-01-02 14:53:08 -08:00
Irina Truong 87d9b2da77 Fix connecting with dsn. 2023-01-02 14:10:29 -08:00
35 changed files with 753 additions and 134 deletions

View File

@ -1,6 +1,9 @@
name: pgcli
on:
push:
branches:
- main
pull_request:
paths-ignore:
- '**.rst'
@ -11,7 +14,7 @@ jobs:
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
services:
postgres:
@ -28,10 +31,10 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
@ -72,7 +75,7 @@ jobs:
pip install keyrings.alt>=3.1
- name: Run unit tests
run: coverage run --source pgcli -m py.test
run: coverage run --source pgcli -m pytest
- name: Run integration tests
env:
@ -86,7 +89,7 @@ jobs:
- name: Run Black
run: black --check .
if: matrix.python-version == '3.7'
if: matrix.python-version == '3.8'
- name: Coverage
run: |

View File

@ -1,5 +1,5 @@
repos:
- repo: https://github.com/psf/black
rev: 22.3.0
rev: 23.3.0
hooks:
- id: black

View File

@ -126,6 +126,13 @@ Contributors:
* Rigo Neri (rigoneri)
* Anna Glasgall (annathyst)
* Andy Schoenberger (andyscho)
* Damien Baty (dbaty)
* blag
* Rob Berry (rob-b)
* Sharon Yogev (sharonyogev)
* Hollis Wu (holi0317)
* Antonio Aguilar (crazybolillo)
* Andrew M. MacFie (amacfie)
Creator:
--------

View File

@ -157,8 +157,9 @@ get this running in a development setup.
https://github.com/dbcli/pgcli/blob/master/DEVELOP.rst
Please feel free to reach out to me if you need help.
My email: amjith.r@gmail.com, Twitter: `@amjithr <http://twitter.com/amjithr>`_
Please feel free to reach out to us if you need help.
* Amjith, pgcli author: amjith.r@gmail.com, Twitter: `@amjithr <http://twitter.com/amjithr>`_
* Irina, pgcli maintainer: i.chernyavska@gmail.com, Twitter: `@irinatruong <http://twitter.com/irinatruong>`_
Detailed Installation Instructions:
-----------------------------------
@ -351,8 +352,7 @@ choice:
In [3]: my_result = _
Pgcli only runs on Python3.7+ since 4.0.0, if you use an old version of Python,
you should use install ``pgcli <= 4.0.0``.
Pgcli dropped support for Python<3.8 as of 4.0.0. If you need it, install ``pgcli <= 4.0.0``.
Thanks:
-------
@ -372,8 +372,8 @@ interface to Postgres database.
Thanks to all the beta testers and contributors for your time and patience. :)
.. |Build Status| image:: https://github.com/dbcli/pgcli/workflows/pgcli/badge.svg
:target: https://github.com/dbcli/pgcli/actions?query=workflow%3Apgcli
.. |Build Status| image:: https://github.com/dbcli/pgcli/actions/workflows/ci.yml/badge.svg?branch=main
:target: https://github.com/dbcli/pgcli/actions/workflows/ci.yml
.. |CodeCov| image:: https://codecov.io/gh/dbcli/pgcli/branch/master/graph/badge.svg
:target: https://codecov.io/gh/dbcli/pgcli

View File

@ -3,8 +3,36 @@ Upcoming
Features:
---------
* Support `PGAPPNAME` as an environment variable and `--application-name` as a command line argument.
* Show Postgres notifications
* Changed the `destructive_warning` config to be a list of commands that are considered
Bug fixes:
----------
* Fix display of "short host" in prompt (with `\h`) for IPv4 addresses ([issue 964](https://github.com/dbcli/pgcli/issues/964)).
* Fix backwards display of NOTICEs from a Function ([issue 1443](https://github.com/dbcli/pgcli/issues/1443))
* Fix psycopg errors when installing on Windows. ([issue 1413](https://https://github.com/dbcli/pgcli/issues/1413))
* 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))
==================
4.0.1 (2023-10-30)
==================
Internal:
---------
* Allow stable version of pendulum.
==================
4.0.0 (2023-10-27)
==================
Features:
---------
* Ask for confirmation when quitting cli while a transaction is ongoing.
* New `destructive_statements_require_transaction` config option to refuse to execute a
destructive SQL statement if outside a transaction. This option is off by default.
* Changed the `destructive_warning` config to be a list of commands that are considered
destructive. This would allow you to be warned on `create`, `grant`, or `insert` queries.
* Destructive warnings will now include the alias dsn connection string name if provided (-D option).
* pgcli.magic will now work with connection URLs that use TLS client certificates for authentication
@ -12,7 +40,28 @@ Features:
Also prevents getting stuck in a retry loop.
* Config option to not restart connection when cancelling a `destructive_warning` query. By default,
it will now not restart.
* Fix \ev not producing a correctly quoted "schema"."view"
* Config option to always run with a single connection.
* Add comment explaining default LESS environment variable behavior and change example pager setting.
* Added `\echo` & `\qecho` special commands. ([issue 1335](https://github.com/dbcli/pgcli/issues/1335)).
Bug fixes:
----------
* Fix `\ev` not producing a correctly quoted "schema"."view"
* Fix 'invalid connection option "dsn"' ([issue 1373](https://github.com/dbcli/pgcli/issues/1373)).
* Fix explain mode when used with `expand`, `auto_expand`, or `--explain-vertical-output` ([issue 1393](https://github.com/dbcli/pgcli/issues/1393)).
* Fix sql-insert format emits NULL as 'None' ([issue 1408](https://github.com/dbcli/pgcli/issues/1408)).
* Improve check for prompt-toolkit 3.0.6 ([issue 1416](https://github.com/dbcli/pgcli/issues/1416)).
* Allow specifying an `alias_map_file` in the config that will use
predetermined table aliases instead of generating aliases programmatically on
the fly
* Fixed SQL error when there is a comment on the first line: ([issue 1403](https://github.com/dbcli/pgcli/issues/1403))
* Fix wrong usage of prompt instead of confirm when confirm execution of destructive query
Internal:
---------
* Drop support for Python 3.7 and add 3.12.
3.5.0 (2022/09/15):
===================

View File

@ -1 +1 @@
__version__ = "3.5.0"
__version__ = "4.0.1"

View File

@ -26,7 +26,9 @@ def keyring_initialize(keyring_enabled, *, logger):
try:
keyring = importlib.import_module("keyring")
except Exception as e: # ImportError for Python 2, ModuleNotFoundError for Python 3
except (
ModuleNotFoundError
) as e: # ImportError for Python 2, ModuleNotFoundError for Python 3
logger.warning("import keyring failed: %r.", e)

View File

@ -6,7 +6,6 @@ from .pgcompleter import PGCompleter
class CompletionRefresher:
refreshers = OrderedDict()
def __init__(self):
@ -39,7 +38,7 @@ class CompletionRefresher:
args=(executor, special, callbacks, history, settings),
name="completion_refresh",
)
self._completer_thread.setDaemon(True)
self._completer_thread.daemon = True
self._completer_thread.start()
return [
(None, None, None, "Auto-completion refresh started in the background.")

View File

@ -10,7 +10,8 @@ class ExplainOutputFormatter:
self.max_width = max_width
def format_output(self, cur, headers, **output_kwargs):
(data,) = cur.fetchone()
# explain query results should always contain 1 row each
[(data,)] = list(cur)
explain_list = json.loads(data)
visualizer = Visualizer(self.max_width)
for explain in explain_list:

View File

@ -11,7 +11,6 @@ import logging
import threading
import shutil
import functools
import pendulum
import datetime as dt
import itertools
import platform
@ -64,15 +63,13 @@ from .config import (
from .key_bindings import pgcli_bindings
from .packages.formatter.sqlformatter import register_new_formatter
from .packages.prompt_utils import confirm, confirm_destructive_query
from .packages.parseutils import is_destructive
from .packages.parseutils import parse_destructive_warning
from .__init__ import __version__
click.disable_unicode_literals_warning = True
try:
from urlparse import urlparse, unquote, parse_qs
except ImportError:
from urllib.parse import urlparse, unquote, parse_qs
from urllib.parse import urlparse
from getpass import getuser
@ -131,6 +128,15 @@ class PgCliQuitError(Exception):
pass
def notify_callback(notify: Notify):
click.secho(
'Notification received on channel "{}" (PID {}):\n{}'.format(
notify.channel, notify.pid, notify.payload
),
fg="green",
)
class PGCli:
default_prompt = "\\u@\\h:\\d> "
max_len_prompt = 30
@ -167,6 +173,7 @@ class PGCli:
pgexecute=None,
pgclirc_file=None,
row_limit=None,
application_name="pgcli",
single_connection=False,
less_chatty=None,
prompt=None,
@ -212,6 +219,8 @@ class PGCli:
else:
self.row_limit = c["main"].as_int("row_limit")
self.application_name = application_name
# if not specified, set to DEFAULT_MAX_FIELD_WIDTH
# if specified but empty, set to None to disable truncation
# ellipsis will take at least 3 symbols, so this can't be less than 3 if specified and > 0
@ -234,6 +243,9 @@ class PGCli:
self.destructive_warning_restarts_connection = c["main"].as_bool(
"destructive_warning_restarts_connection"
)
self.destructive_statements_require_transaction = c["main"].as_bool(
"destructive_statements_require_transaction"
)
self.less_chatty = bool(less_chatty) or c["main"].as_bool("less_chatty")
self.null_string = c["main"].get("null_string", "<null>")
@ -264,6 +276,9 @@ class PGCli:
# Initialize completer
smart_completion = c["main"].as_bool("smart_completion")
keyword_casing = c["main"]["keyword_casing"]
single_connection = single_connection or c["main"].as_bool(
"always_use_single_connection"
)
self.settings = {
"casing_file": get_casing_file(c),
"generate_casing_file": c["main"].as_bool("generate_casing_file"),
@ -275,6 +290,7 @@ class PGCli:
"single_connection": single_connection,
"less_chatty": less_chatty,
"keyword_casing": keyword_casing,
"alias_map_file": c["main"]["alias_map_file"] or None,
}
completer = PGCompleter(
@ -298,7 +314,6 @@ class PGCli:
raise PgCliQuitError
def register_special_commands(self):
self.pgspecial.register(
self.change_db,
"\\c",
@ -360,6 +375,23 @@ class PGCli:
"Change the table format used to output results",
)
self.pgspecial.register(
self.echo,
"\\echo",
"\\echo [string]",
"Echo a string to stdout",
)
self.pgspecial.register(
self.echo,
"\\qecho",
"\\qecho [string]",
"Echo a string to the query output channel.",
)
def echo(self, pattern, **_):
return [(None, None, None, pattern)]
def change_table_format(self, pattern, **_):
try:
if pattern not in TabularOutputFormatter().supported_formats:
@ -429,15 +461,20 @@ class PGCli:
except OSError as e:
return [(None, None, None, str(e), "", False, True)]
if (
self.destructive_warning
and confirm_destructive_query(
if self.destructive_warning:
if (
self.destructive_statements_require_transaction
and not self.pgexecute.valid_transaction()
and is_destructive(query, self.destructive_warning)
):
message = "Destructive statements must be run within a transaction. Command execution stopped."
return [(None, None, None, message)]
destroy = confirm_destructive_query(
query, self.destructive_warning, self.dsn_alias
)
is False
):
message = "Wise choice. Command execution stopped."
return [(None, None, None, message)]
if destroy is False:
message = "Wise choice. Command execution stopped."
return [(None, None, None, message)]
on_error_resume = self.on_error == "RESUME"
return self.pgexecute.run(
@ -465,7 +502,6 @@ class PGCli:
return [(None, None, None, message, "", True, True)]
def initialize_logging(self):
log_file = self.config["main"]["log_file"]
if log_file == "default":
log_file = config_location() + "log"
@ -543,7 +579,7 @@ class PGCli:
if not database:
database = user
kwargs.setdefault("application_name", "pgcli")
kwargs.setdefault("application_name", self.application_name)
# If password prompt is not forced but no password is provided, try
# getting it from environment variable.
@ -627,14 +663,6 @@ class PGCli:
if dsn:
dsn = make_conninfo(dsn, host=host, port=port)
def notify_callback(notify: Notify):
click.echo(
'Notification received on channel "{}" (PID {}):'.format(
notify.channel, notify.pid
)
)
self.echo_via_pager(notify.payload)
# Attempt to connect to the database.
# Note that passwd may be empty on the first attempt. If connection
# fails because of a missing or incorrect password, but we're allowed to
@ -648,7 +676,7 @@ class PGCli:
host,
port,
dsn,
notify_callback=notify_callback,
notify_callback,
**kwargs,
)
except (OperationalError, InterfaceError) as e:
@ -666,7 +694,7 @@ class PGCli:
host,
port,
dsn,
notify_callback=notify_callback,
notify_callback,
**kwargs,
)
else:
@ -727,7 +755,16 @@ class PGCli:
try:
if self.destructive_warning:
destroy = confirm = confirm_destructive_query(
if (
self.destructive_statements_require_transaction
and not self.pgexecute.valid_transaction()
and is_destructive(text, self.destructive_warning)
):
click.secho(
"Destructive statements must be run within a transaction."
)
raise KeyboardInterrupt
destroy = confirm_destructive_query(
text, self.destructive_warning, self.dsn_alias
)
if destroy is False:
@ -756,7 +793,7 @@ class PGCli:
click.secho(str(e), err=True, fg="red")
if handle_closed_connection:
self._handle_server_closed_connection(text)
except (PgCliQuitError, EOFError) as e:
except (PgCliQuitError, EOFError):
raise
except Exception as e:
logger.error("sql: %r, error: %r", text, e)
@ -764,7 +801,9 @@ class PGCli:
click.secho(str(e), err=True, fg="red")
else:
try:
if self.output_file and not text.startswith(("\\o ", "\\? ")):
if self.output_file and not text.startswith(
("\\o ", "\\? ", "\\echo ")
):
try:
with open(self.output_file, "a", encoding="utf-8") as f:
click.echo(text, file=f)
@ -785,9 +824,9 @@ class PGCli:
"Time: %0.03fs (%s), executed in: %0.03fs (%s)"
% (
query.total_time,
pendulum.Duration(seconds=query.total_time).in_words(),
duration_in_words(query.total_time),
query.execution_time,
pendulum.Duration(seconds=query.execution_time).in_words(),
duration_in_words(query.execution_time),
)
)
else:
@ -808,6 +847,34 @@ class PGCli:
logger.debug("Search path: %r", self.completer.search_path)
return query
def _check_ongoing_transaction_and_allow_quitting(self):
"""Return whether we can really quit, possibly by asking the
user to confirm so if there is an ongoing transaction.
"""
if not self.pgexecute.valid_transaction():
return True
while 1:
try:
choice = click.prompt(
"A transaction is ongoing. Choose `c` to COMMIT, `r` to ROLLBACK, `a` to abort exit.",
default="a",
)
except click.Abort:
# Print newline if user aborts with `^C`, otherwise
# pgcli's prompt will be printed on the same line
# (just after the confirmation prompt).
click.echo(None, err=False)
choice = "a"
choice = choice.lower()
if choice == "a":
return False # do not quit
if choice == "c":
query = self.execute_command("commit")
return query.successful # quit only if query is successful
if choice == "r":
query = self.execute_command("rollback")
return query.successful # quit only if query is successful
def run_cli(self):
logger = self.logger
@ -830,6 +897,10 @@ class PGCli:
text = self.prompt_app.prompt()
except KeyboardInterrupt:
continue
except EOFError:
if not self._check_ongoing_transaction_and_allow_quitting():
continue
raise
try:
text = self.handle_editor_command(text)
@ -839,7 +910,12 @@ class PGCli:
click.secho(str(e), err=True, fg="red")
continue
self.handle_watch_command(text)
try:
self.handle_watch_command(text)
except PgCliQuitError:
if not self._check_ongoing_transaction_and_allow_quitting():
continue
raise
self.now = dt.datetime.today()
@ -1288,6 +1364,12 @@ class PGCli:
type=click.INT,
help="Set threshold for row limit prompt. Use 0 to disable prompt.",
)
@click.option(
"--application-name",
default="pgcli",
envvar="PGAPPNAME",
help="Application name for the connection.",
)
@click.option(
"--less-chatty",
"less_chatty",
@ -1338,6 +1420,7 @@ def cli(
pgclirc,
dsn,
row_limit,
application_name,
less_chatty,
prompt,
prompt_dsn,
@ -1396,6 +1479,7 @@ def cli(
never_prompt,
pgclirc_file=pgclirc,
row_limit=row_limit,
application_name=application_name,
single_connection=single_connection,
less_chatty=less_chatty,
prompt=prompt,
@ -1623,7 +1707,8 @@ def format_output(title, cur, headers, status, settings, explain_mode=False):
first_line = next(formatted)
formatted = itertools.chain([first_line], formatted)
if (
not expanded
not explain_mode
and not expanded
and max_width
and len(strip_ansi(first_line)) > max_width
and headers
@ -1674,5 +1759,28 @@ def parse_service_info(service):
return service_conf, service_file
def duration_in_words(duration_in_seconds: float) -> str:
if not duration_in_seconds:
return "0 seconds"
components = []
hours, remainder = divmod(duration_in_seconds, 3600)
if hours > 1:
components.append(f"{hours} hours")
elif hours == 1:
components.append("1 hour")
minutes, seconds = divmod(remainder, 60)
if minutes > 1:
components.append(f"{minutes} minutes")
elif minutes == 1:
components.append("1 minute")
if seconds >= 2:
components.append(f"{int(seconds)} seconds")
elif seconds >= 1:
components.append("1 second")
elif seconds:
components.append(f"{round(seconds, 3)} second")
return " ".join(components)
if __name__ == "__main__":
cli()

View File

@ -14,10 +14,13 @@ preprocessors = ()
def escape_for_sql_statement(value):
if value is None:
return "NULL"
if isinstance(value, bytes):
return f"X'{value.hex()}'"
else:
return "'{}'".format(value)
return "'{}'".format(value)
def adapter(data, headers, table_format=None, **kwargs):
@ -29,7 +32,7 @@ def adapter(data, headers, table_format=None, **kwargs):
else:
table_name = table[1]
else:
table_name = '"DUAL"'
table_name = "DUAL"
if table_format == "sql-insert":
h = '", "'.join(headers)
yield 'INSERT INTO "{}" ("{}") VALUES'.format(table_name, h)

View File

@ -16,9 +16,9 @@ def confirm_destructive_query(queries, keywords, alias):
if alias:
info += f" in {click.style(alias, fg='red')}"
prompt_text = f"{info}.\nDo you want to proceed? (y/n)"
prompt_text = f"{info}.\nDo you want to proceed?"
if is_destructive(queries, keywords) and sys.stdin.isatty():
return prompt(prompt_text, type=bool)
return confirm(prompt_text)
def confirm(*args, **kwargs):

View File

@ -290,7 +290,6 @@ def suggest_special(text):
def suggest_based_on_last_token(token, stmt):
if isinstance(token, str):
token_v = token.lower()
elif isinstance(token, Comparison):
@ -399,7 +398,6 @@ def suggest_based_on_last_token(token, stmt):
elif (token_v.endswith("join") and token.is_keyword) or (
token_v in ("copy", "from", "update", "into", "describe", "truncate")
):
schema = stmt.get_identifier_schema()
tables = extract_tables(stmt.text_before_cursor)
is_join = token_v.endswith("join") and token.is_keyword
@ -436,7 +434,6 @@ def suggest_based_on_last_token(token, stmt):
try:
prev = stmt.get_previous_token(token).value.lower()
if prev in ("drop", "alter", "create", "create or replace"):
# Suggest functions from either the currently-selected schema or the
# public schema if no schema has been specified
suggest = []

View File

@ -9,6 +9,10 @@ smart_completion = True
# visible.)
wider_completion_menu = False
# Do not create new connections for refreshing completions; Equivalent to
# always running with the --single-connection flag.
always_use_single_connection = False
# Multi-line mode allows breaking up the sql statements into multiple lines. If
# this is set to True, then the end of the statements must have a semi-colon.
# If this is set to False then sql statements can't be split into multiple
@ -24,17 +28,22 @@ multi_line_mode = psql
# Destructive warning will alert you before executing a sql statement
# that may cause harm to the database such as "drop table", "drop database",
# "shutdown", "delete", or "update".
# "shutdown", "delete", or "update".
# You can pass a list of destructive commands or leave it empty if you want to skip all warnings.
# "unconditional_update" will warn you of update statements that don't have a where clause
destructive_warning = drop, shutdown, delete, truncate, alter, update, unconditional_update
# Destructive warning can restart the connection if this is enabled and the
# user declines. This means that any current uncommitted transaction can be
# aborted if the user doesn't want to proceed with a destructive_warning
# statement.
# When `destructive_warning` is on and the user declines to proceed with a
# destructive statement, the current transaction (if any) is left untouched,
# by default. When setting `destructive_warning_restarts_connection` to
# "True", the connection to the server is restarted. In that case, the
# transaction (if any) is rolled back.
destructive_warning_restarts_connection = False
# When this option is on (and if `destructive_warning` is not empty),
# destructive statements are not executed when outside of a transaction.
destructive_statements_require_transaction = False
# Enables expand mode, which is similar to `\x` in psql.
expand = False
@ -48,6 +57,14 @@ auto_retry_closed_connection = True
# If set to True, table suggestions will include a table alias
generate_aliases = False
# Path to a json file that specifies specific table aliases to use when generate_aliases is set to True
# the format for this file should be:
# {
# "some_table_name": "desired_alias",
# "some_other_table_name": "another_alias"
# }
alias_map_file =
# log_file location.
# In Unix/Linux: ~/.config/pgcli/log
# In Windows: %USERPROFILE%\AppData\Local\dbcli\pgcli\log
@ -91,9 +108,10 @@ qualify_columns = if_more_than_one_table
# When no schema is entered, only suggest objects in search_path
search_path_filter = False
# Default pager.
# By default 'PAGER' environment variable is used
# pager = less -SRXF
# Default pager. See https://www.pgcli.com/pager for more information on settings.
# By default 'PAGER' environment variable is used. If the pager is less, and the 'LESS'
# environment variable is not set, then LESS='-SRXF' will be automatically set.
# pager = less
# Timing of sql statements and table rendering.
timing = True

View File

@ -1,3 +1,4 @@
import json
import logging
import re
from itertools import count, repeat, chain
@ -61,18 +62,38 @@ arg_default_type_strip_regex = re.compile(r"::[\w\.]+(\[\])?$")
normalize_ref = lambda ref: ref if ref[0] == '"' else '"' + ref.lower() + '"'
def generate_alias(tbl):
def generate_alias(tbl, alias_map=None):
"""Generate a table alias, consisting of all upper-case letters in
the table name, or, if there are no upper-case letters, the first letter +
all letters preceded by _
param tbl - unescaped name of the table to alias
"""
if alias_map and tbl in alias_map:
return alias_map[tbl]
return "".join(
[l for l in tbl if l.isupper()]
or [l for l, prev in zip(tbl, "_" + tbl) if prev == "_" and l != "_"]
)
class InvalidMapFile(ValueError):
pass
def load_alias_map_file(path):
try:
with open(path) as fo:
alias_map = json.load(fo)
except FileNotFoundError as err:
raise InvalidMapFile(
f"Cannot read alias_map_file - {err.filename} does not exist"
)
except json.JSONDecodeError:
raise InvalidMapFile(f"Cannot read alias_map_file - {path} is not valid json")
else:
return alias_map
class PGCompleter(Completer):
# keywords_tree: A dict mapping keywords to well known following keywords.
# e.g. 'CREATE': ['TABLE', 'USER', ...],
@ -100,6 +121,11 @@ class PGCompleter(Completer):
self.call_arg_oneliner_max = settings.get("call_arg_oneliner_max", 2)
self.search_path_filter = settings.get("search_path_filter")
self.generate_aliases = settings.get("generate_aliases")
alias_map_file = settings.get("alias_map_file")
if alias_map_file is not None:
self.alias_map = load_alias_map_file(alias_map_file)
else:
self.alias_map = None
self.casing_file = settings.get("casing_file")
self.insert_col_skip_patterns = [
re.compile(pattern)
@ -157,7 +183,6 @@ class PGCompleter(Completer):
self.all_completions.update(additional_keywords)
def extend_schemata(self, schemata):
# schemata is a list of schema names
schemata = self.escaped_names(schemata)
metadata = self.dbmetadata["tables"]
@ -226,7 +251,6 @@ class PGCompleter(Completer):
self.all_completions.add(colname)
def extend_functions(self, func_data):
# func_data is a list of function metadata namedtuples
# dbmetadata['schema_name']['functions']['function_name'] should return
@ -260,7 +284,6 @@ class PGCompleter(Completer):
}
def extend_foreignkeys(self, fk_data):
# fk_data is a list of ForeignKey namedtuples, with fields
# parentschema, childschema, parenttable, childtable,
# parentcolumns, childcolumns
@ -283,7 +306,6 @@ class PGCompleter(Completer):
parcolmeta.foreignkeys.append(fk)
def extend_datatypes(self, type_data):
# dbmetadata['datatypes'][schema_name][type_name] should store type
# metadata, such as composite type field names. Currently, we're not
# storing any metadata beyond typename, so just store None
@ -697,7 +719,6 @@ class PGCompleter(Completer):
return self.find_matches(word_before_cursor, conds, meta="join")
def get_function_matches(self, suggestion, word_before_cursor, alias=False):
if suggestion.usage == "from":
# Only suggest functions allowed in FROM clause

View File

@ -1,7 +1,8 @@
import ipaddress
import logging
import traceback
from collections import namedtuple
import re
import pgspecial as special
import psycopg
import psycopg.sql
@ -17,6 +18,27 @@ ViewDef = namedtuple(
)
# we added this funcion to strip beginning comments
# because sqlparse didn't handle tem well. It won't be needed if sqlparse
# does parsing of this situation better
def remove_beginning_comments(command):
# Regular expression pattern to match comments
pattern = r"^(/\*.*?\*/|--.*?)(?:\n|$)"
# Find and remove all comments from the beginning
cleaned_command = command
comments = []
match = re.match(pattern, cleaned_command, re.DOTALL)
while match:
comments.append(match.group())
cleaned_command = cleaned_command[len(match.group()) :].lstrip()
match = re.match(pattern, cleaned_command, re.DOTALL)
return [cleaned_command, comments]
def register_typecasters(connection):
"""Casts date and timestamp values to string, resolves issues with out-of-range
dates (e.g. BC) which psycopg can't handle"""
@ -76,7 +98,6 @@ class ProtocolSafeCursor(psycopg.Cursor):
class PGExecute:
# The boolean argument to the current_schemas function indicates whether
# implicit schemas, e.g. pg_catalog
search_path_query = """
@ -182,7 +203,6 @@ class PGExecute:
dsn=None,
**kwargs,
):
conn_params = self._conn_params.copy()
new_params = {
@ -205,7 +225,11 @@ class PGExecute:
conn_params.update({k: v for k, v in new_params.items() if v})
conn_info = make_conninfo(**conn_params)
if "dsn" in conn_params:
other_params = {k: v for k, v in conn_params.items() if k != "dsn"}
conn_info = make_conninfo(conn_params["dsn"], **other_params)
else:
conn_info = make_conninfo(**conn_params)
conn = psycopg.connect(conn_info)
conn.cursor_factory = ProtocolSafeCursor
@ -255,6 +279,11 @@ class PGExecute:
@property
def short_host(self):
try:
ipaddress.ip_address(self.host)
return self.host
except ValueError:
pass
if "," in self.host:
host, _, _ = self.host.partition(",")
else:
@ -314,21 +343,20 @@ class PGExecute:
# sql parse doesn't split on a comment first + special
# so we're going to do it
sqltemp = []
removed_comments = []
sqlarr = []
cleaned_command = ""
if statement.startswith("--"):
sqltemp = statement.split("\n")
sqlarr.append(sqltemp[0])
for i in sqlparse.split(sqltemp[1]):
sqlarr.append(i)
elif statement.startswith("/*"):
sqltemp = statement.split("*/")
sqltemp[0] = sqltemp[0] + "*/"
for i in sqlparse.split(sqltemp[1]):
sqlarr.append(i)
else:
sqlarr = sqlparse.split(statement)
# could skip if statement doesn't match ^-- or ^/*
cleaned_command, removed_comments = remove_beginning_comments(statement)
sqlarr = sqlparse.split(cleaned_command)
# now re-add the beginning comments if there are any, so that they show up in
# log files etc when running these commands
if len(removed_comments) > 0:
sqlarr = removed_comments + sqlarr
# run each sql query
for sql in sqlarr:
@ -414,7 +442,11 @@ class PGExecute:
def handle_notices(n):
nonlocal title
title = f"{n.message_primary}\n{n.message_detail}\n{title}"
title = f"{title}"
if n.message_primary is not None:
title = f"{title}\n{n.message_primary}"
if n.message_detail is not None:
title = f"{title}\n{n.message_detail}"
self.conn.add_notice_handler(handle_notices)

View File

@ -1,18 +1,14 @@
from pkg_resources import packaging
import prompt_toolkit
from prompt_toolkit.key_binding.vi_state import InputMode
from prompt_toolkit.application import get_app
parse_version = packaging.version.parse
vi_modes = {
InputMode.INSERT: "I",
InputMode.NAVIGATION: "N",
InputMode.REPLACE: "R",
InputMode.INSERT_MULTIPLE: "M",
}
if parse_version(prompt_toolkit.__version__) >= parse_version("3.0.6"):
# REPLACE_SINGLE is available in prompt_toolkit >= 3.0.6
if "REPLACE_SINGLE" in {e.name for e in InputMode}:
vi_modes[InputMode.REPLACE_SINGLE] = "R"

View File

@ -146,7 +146,7 @@ class Visualizer:
elif self.explain.get("Max Rows") < plan["Actual Rows"]:
self.explain["Max Rows"] = plan["Actual Rows"]
if not self.explain.get("MaxCost"):
if not self.explain.get("Max Cost"):
self.explain["Max Cost"] = plan["Actual Cost"]
elif self.explain.get("Max Cost") < plan["Actual Cost"]:
self.explain["Max Cost"] = plan["Actual Cost"]
@ -171,7 +171,7 @@ class Visualizer:
return self.warning_format("%.2f ms" % value)
elif value < 60000:
return self.critical_format(
"%.2f s" % (value / 2000.0),
"%.2f s" % (value / 1000.0),
)
else:
return self.critical_format(

View File

@ -1,6 +1,6 @@
[tool.black]
line-length = 88
target-version = ['py36']
target-version = ['py38']
include = '\.pyi?$'
exclude = '''
/(
@ -19,4 +19,3 @@ exclude = '''
| tests/data
)/
'''

View File

@ -57,7 +57,7 @@ def version(version_file):
def commit_for_release(version_file, ver):
run_step("git", "reset")
run_step("git", "add", version_file)
run_step("git", "add", "-u")
run_step("git", "commit", "--message", "Releasing version {}".format(ver))

View File

@ -1,7 +1,7 @@
pytest>=2.7.0
tox>=1.9.2
behave>=1.2.4
black>=22.3.0
black>=23.3.0
pexpect==3.3; platform_system != "Windows"
pre-commit>=1.16.0
coverage>=5.0.4
@ -10,4 +10,4 @@ docutils>=0.13.1
autopep8>=1.3.3
twine>=1.11.0
wheel>=0.33.6
sshtunnel>=0.4.0
sshtunnel>=0.4.0

View File

@ -12,10 +12,10 @@ install_requirements = [
# We still need to use pt-2 unless pt-3 released on Fedora32
# see: https://github.com/dbcli/pgcli/pull/1197
"prompt_toolkit>=2.0.6,<4.0.0",
"psycopg >= 3.0.14",
"psycopg >= 3.0.14; sys_platform != 'win32'",
"psycopg-binary >= 3.0.14; sys_platform == 'win32'",
"sqlparse >=0.3.0,<0.5",
"configobj >= 5.0.6",
"pendulum>=2.1.0",
"cli_helpers[styles] >= 2.2.1",
]
@ -27,11 +27,6 @@ install_requirements = [
if platform.system() != "Windows" and not platform.system().startswith("CYGWIN"):
install_requirements.append("setproctitle >= 1.1.9")
# Windows will require the binary psycopg to run pgcli
if platform.system() == "Windows":
install_requirements.append("psycopg-binary >= 3.0.14")
setup(
name="pgcli",
author="Pgcli Core Team",
@ -51,7 +46,7 @@ setup(
"keyring": ["keyring >= 12.2.0"],
"sshtunnel": ["sshtunnel >= 0.4.0"],
},
python_requires=">=3.7",
python_requires=">=3.8",
entry_points="""
[console_scripts]
pgcli=pgcli.main:cli
@ -62,11 +57,11 @@ setup(
"Operating System :: Unix",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: SQL",
"Topic :: Database",
"Topic :: Database :: Front-Ends",

View File

@ -9,6 +9,7 @@ from utils import (
db_connection,
drop_tables,
)
import pgcli.main
import pgcli.pgexecute
@ -37,6 +38,7 @@ def executor(connection):
password=POSTGRES_PASSWORD,
port=POSTGRES_PORT,
dsn=None,
notify_callback=pgcli.main.notify_callback,
)

View File

@ -23,6 +23,23 @@ Feature: run the cli,
When we send "ctrl + d"
then dbcli exits
Scenario: confirm exit when a transaction is ongoing
When we begin transaction
and we try to send "ctrl + d"
then we see ongoing transaction message
when we send "c"
then dbcli exits
Scenario: cancel exit when a transaction is ongoing
When we begin transaction
and we try to send "ctrl + d"
then we see ongoing transaction message
when we send "a"
then we see dbcli prompt
when we rollback transaction
when we send "ctrl + d"
then dbcli exits
Scenario: interrupt current query via "ctrl + c"
When we send sleep query
and we send "ctrl + c"

View File

@ -164,10 +164,24 @@ def before_step(context, _):
context.atprompt = False
def is_known_problem(scenario):
"""TODO: why is this not working in 3.12?"""
if sys.version_info >= (3, 12):
return scenario.name in (
'interrupt current query via "ctrl + c"',
"run the cli with --username",
"run the cli with --user",
"run the cli with --port",
)
return False
def before_scenario(context, scenario):
if scenario.name == "list databases":
# not using the cli for that
return
if is_known_problem(scenario):
scenario.skip()
currentdb = None
if "pgbouncer" in scenario.feature.tags:
if context.pgbouncer_available:

View File

@ -64,13 +64,22 @@ def step_ctrl_d(context):
"""
Send Ctrl + D to hopefully exit.
"""
step_try_to_ctrl_d(context)
context.cli.expect(pexpect.EOF, timeout=5)
context.exit_sent = True
@when('we try to send "ctrl + d"')
def step_try_to_ctrl_d(context):
"""
Send Ctrl + D, perhaps exiting, perhaps not (if a transaction is
ongoing).
"""
# turn off pager before exiting
context.cli.sendcontrol("c")
context.cli.sendline(r"\pset pager off")
wrappers.wait_prompt(context)
context.cli.sendcontrol("d")
context.cli.expect(pexpect.EOF, timeout=5)
context.exit_sent = True
@when('we send "ctrl + c"')
@ -87,6 +96,14 @@ def step_see_cancelled_query_warning(context):
wrappers.expect_exact(context, "cancelled query", timeout=2)
@then("we see ongoing transaction message")
def step_see_ongoing_transaction_error(context):
"""
Make sure we receive the warning that a transaction is ongoing.
"""
context.cli.expect("A transaction is ongoing.", timeout=2)
@when("we send sleep query")
def step_send_sleep_15_seconds(context):
"""
@ -189,7 +206,7 @@ def step_resppond_to_destructive_command(context, response):
"""Respond to destructive command."""
wrappers.expect_exact(
context,
"You're about to run a destructive command.\r\nDo you want to proceed? (y/n):",
"You're about to run a destructive command.\r\nDo you want to proceed? [y/N]:",
timeout=2,
)
context.cli.sendline(response.strip())
@ -199,3 +216,16 @@ def step_resppond_to_destructive_command(context, response):
def step_send_password(context):
wrappers.expect_exact(context, "Password for", timeout=5)
context.cli.sendline(context.conf["pass"] or "DOES NOT MATTER")
@when('we send "{text}"')
def step_send_text(context, text):
context.cli.sendline(text)
# Try to detect whether we are exiting. If so, set `exit_sent`
# so that `after_scenario` correctly cleans up.
try:
context.cli.expect(pexpect.EOF, timeout=0.2)
except pexpect.TIMEOUT:
pass
else:
context.exit_sent = True

View File

@ -3,6 +3,7 @@ Steps for behavioral style tests are defined in this module.
Each step is defined by the string decorating it.
This string is used to call the step in "*.feature" file.
"""
import pexpect
from behave import when, then

View File

@ -16,7 +16,7 @@ def step_prepare_data(context):
context.cli.sendline("drop table if exists a;")
wrappers.expect_exact(
context,
"You're about to run a destructive command.\r\nDo you want to proceed? (y/n):",
"You're about to run a destructive command.\r\nDo you want to proceed? [y/N]:",
timeout=2,
)
context.cli.sendline("y")

View File

@ -3,10 +3,7 @@ import pexpect
from pgcli.main import COLOR_CODE_REGEX
import textwrap
try:
from StringIO import StringIO
except ImportError:
from io import StringIO
from io import StringIO
def expect_exact(context, expected, timeout):

View File

@ -34,7 +34,7 @@ def test_output_sql_insert():
"Jackson",
"jackson_test@gmail.com",
"132454789",
"",
None,
"2022-09-09 19:44:32.712343+08",
"2022-09-09 19:44:32.712343+08",
]
@ -58,7 +58,7 @@ def test_output_sql_insert():
output_list = [l for l in output]
expected = [
'INSERT INTO "user" ("id", "name", "email", "phone", "description", "created_at", "updated_at") VALUES',
" ('1', 'Jackson', 'jackson_test@gmail.com', '132454789', '', "
" ('1', 'Jackson', 'jackson_test@gmail.com', '132454789', NULL, "
+ "'2022-09-09 19:44:32.712343+08', '2022-09-09 19:44:32.712343+08')",
";",
]

View File

@ -0,0 +1,17 @@
from unittest.mock import patch
from click.testing import CliRunner
from pgcli.main import cli
from pgcli.pgexecute import PGExecute
def test_application_name_in_env():
runner = CliRunner()
app_name = "wonderful_app"
with patch.object(PGExecute, "__init__") as mock_pgxecute:
runner.invoke(
cli, ["127.0.0.1:5432/hello", "user"], env={"PGAPPNAME": app_name}
)
kwargs = mock_pgxecute.call_args.kwargs
assert kwargs.get("application_name") == app_name

View File

@ -1,5 +1,6 @@
import os
import platform
import re
from unittest import mock
import pytest
@ -11,6 +12,7 @@ except ImportError:
from pgcli.main import (
obfuscate_process_password,
duration_in_words,
format_output,
PGCli,
OutputSettings,
@ -216,7 +218,6 @@ def pset_pager_mocks():
with mock.patch("pgcli.main.click.echo") as mock_echo, mock.patch(
"pgcli.main.click.echo_via_pager"
) as mock_echo_via_pager, mock.patch.object(cli, "prompt_app") as mock_app:
yield cli, mock_echo, mock_echo_via_pager, mock_app
@ -297,6 +298,22 @@ def test_i_works(tmpdir, executor):
run(executor, statement, pgspecial=cli.pgspecial)
@dbtest
def test_echo_works(executor):
cli = PGCli(pgexecute=executor)
statement = r"\echo asdf"
result = run(executor, statement, pgspecial=cli.pgspecial)
assert result == ["asdf"]
@dbtest
def test_qecho_works(executor):
cli = PGCli(pgexecute=executor)
statement = r"\qecho asdf"
result = run(executor, statement, pgspecial=cli.pgspecial)
assert result == ["asdf"]
@dbtest
def test_watch_works(executor):
cli = PGCli(pgexecute=executor)
@ -371,7 +388,6 @@ def test_quoted_db_uri(tmpdir):
def test_pg_service_file(tmpdir):
with mock.patch.object(PGCli, "connect") as mock_connect:
cli = PGCli(pgclirc_file=str(tmpdir.join("rcfile")))
with open(tmpdir.join(".pg_service.conf").strpath, "w") as service_conf:
@ -474,3 +490,48 @@ def test_application_name_db_uri(tmpdir):
mock_pgexecute.assert_called_with(
"bar", "bar", "", "baz.com", "", "", application_name="cow"
)
@pytest.mark.parametrize(
"duration_in_seconds,words",
[
(0, "0 seconds"),
(0.0009, "0.001 second"),
(0.0005, "0.001 second"),
(0.0004, "0.0 second"), # not perfect, but will do
(0.2, "0.2 second"),
(1, "1 second"),
(1.4, "1 second"),
(2, "2 seconds"),
(3.4, "3 seconds"),
(60, "1 minute"),
(61, "1 minute 1 second"),
(123, "2 minutes 3 seconds"),
(3600, "1 hour"),
(7235, "2 hours 35 seconds"),
(9005, "2 hours 30 minutes 5 seconds"),
(86401, "24 hours 1 second"),
],
)
def test_duration_in_words(duration_in_seconds, words):
assert duration_in_words(duration_in_seconds) == words
@dbtest
def test_notifications(executor):
run(executor, "listen chan1")
with mock.patch("pgcli.main.click.secho") as mock_secho:
run(executor, "notify chan1, 'testing1'")
mock_secho.assert_called()
arg = mock_secho.call_args_list[0].args[0]
assert re.match(
r'Notification received on channel "chan1" \(PID \d+\):\ntesting1',
arg,
)
run(executor, "unlisten chan1")
with mock.patch("pgcli.main.click.secho") as mock_secho:
run(executor, "notify chan1, 'testing2'")
mock_secho.assert_not_called()

76
tests/test_pgcompleter.py Normal file
View File

@ -0,0 +1,76 @@
import pytest
from pgcli import pgcompleter
def test_load_alias_map_file_missing_file():
with pytest.raises(
pgcompleter.InvalidMapFile,
match=r"Cannot read alias_map_file - /path/to/non-existent/file.json does not exist$",
):
pgcompleter.load_alias_map_file("/path/to/non-existent/file.json")
def test_load_alias_map_file_invalid_json(tmp_path):
fpath = tmp_path / "foo.json"
fpath.write_text("this is not valid json")
with pytest.raises(pgcompleter.InvalidMapFile, match=r".*is not valid json$"):
pgcompleter.load_alias_map_file(str(fpath))
@pytest.mark.parametrize(
"table_name, alias",
[
("SomE_Table", "SET"),
("SOmeTabLe", "SOTL"),
("someTable", "T"),
],
)
def test_generate_alias_uses_upper_case_letters_from_name(table_name, alias):
assert pgcompleter.generate_alias(table_name) == alias
@pytest.mark.parametrize(
"table_name, alias",
[
("some_tab_le", "stl"),
("s_ome_table", "sot"),
("sometable", "s"),
],
)
def test_generate_alias_uses_first_char_and_every_preceded_by_underscore(
table_name, alias
):
assert pgcompleter.generate_alias(table_name) == alias
@pytest.mark.parametrize(
"table_name, alias_map, alias",
[
("some_table", {"some_table": "my_alias"}, "my_alias"),
],
)
def test_generate_alias_can_use_alias_map(table_name, alias_map, alias):
assert pgcompleter.generate_alias(table_name, alias_map) == alias
@pytest.mark.parametrize(
"table_name, alias_map, alias",
[
("SomeTable", {"SomeTable": "my_alias"}, "my_alias"),
],
)
def test_generate_alias_prefers_alias_over_upper_case_name(
table_name, alias_map, alias
):
assert pgcompleter.generate_alias(table_name, alias_map) == alias
@pytest.mark.parametrize(
"table_name, alias",
[
("Some_tablE", "SE"),
("SomeTab_le", "ST"),
],
)
def test_generate_alias_prefers_upper_case_name_over_underscore_name(table_name, alias):
assert pgcompleter.generate_alias(table_name) == alias

View File

@ -304,9 +304,7 @@ def test_execute_from_commented_file_that_executes_another_file(
@dbtest
def test_execute_commented_first_line_and_special(executor, pgspecial, tmpdir):
# https://github.com/dbcli/pgcli/issues/1362
# just some base caes that should work also
# just some base cases that should work also
statement = "--comment\nselect now();"
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
@ -317,23 +315,43 @@ def test_execute_commented_first_line_and_special(executor, pgspecial, tmpdir):
assert result != None
assert result[1].find("now") >= 0
statement = "/*comment\ncomment line2*/\nselect now();"
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[1].find("now") >= 0
# https://github.com/dbcli/pgcli/issues/1362
statement = "--comment\n\\h"
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[1].find("ALTER") >= 0
assert result[1].find("ABORT") >= 0
statement = "--comment1\n--comment2\n\\h"
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[1].find("ALTER") >= 0
assert result[1].find("ABORT") >= 0
statement = "/*comment*/\n\h;"
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[1].find("ALTER") >= 0
assert result[1].find("ABORT") >= 0
statement = """/*comment1
comment2*/
\h"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[1].find("ALTER") >= 0
assert result[1].find("ABORT") >= 0
statement = """/*comment1
comment2*/
/*comment 3
comment4*/
\\h"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[1].find("ALTER") >= 0
assert result[1].find("ABORT") >= 0
statement = " /*comment*/\n\h;"
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
@ -352,6 +370,126 @@ def test_execute_commented_first_line_and_special(executor, pgspecial, tmpdir):
assert result[1].find("ALTER") >= 0
assert result[1].find("ABORT") >= 0
statement = """\\h /*comment4 */"""
result = run(executor, statement, pgspecial=pgspecial)
print(result)
assert result != None
assert result[0].find("No help") >= 0
# TODO: we probably don't want to do this but sqlparse is not parsing things well
# we relly want it to find help but right now, sqlparse isn't dropping the /*comment*/
# style comments after command
statement = """/*comment1*/
\h
/*comment4 */"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[0].find("No help") >= 0
# TODO: same for this one
statement = """/*comment1
comment3
comment2*/
\\h
/*comment4
comment5
comment6*/"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[0].find("No help") >= 0
@dbtest
def test_execute_commented_first_line_and_normal(executor, pgspecial, tmpdir):
# https://github.com/dbcli/pgcli/issues/1403
# just some base cases that should work also
statement = "--comment\nselect now();"
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[1].find("now") >= 0
statement = "/*comment*/\nselect now();"
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[1].find("now") >= 0
# this simulates the original error (1403) without having to add/drop tables
# since it was just an error on reading input files and not the actual
# command itself
# test that the statement works
statement = """VALUES (1, 'one'), (2, 'two'), (3, 'three');"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[5].find("three") >= 0
# test the statement with a \n in the middle
statement = """VALUES (1, 'one'),\n (2, 'two'), (3, 'three');"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[5].find("three") >= 0
# test the statement with a newline in the middle
statement = """VALUES (1, 'one'),
(2, 'two'), (3, 'three');"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[5].find("three") >= 0
# now add a single comment line
statement = """--comment\nVALUES (1, 'one'), (2, 'two'), (3, 'three');"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[5].find("three") >= 0
# doing without special char \n
statement = """--comment
VALUES (1,'one'),
(2, 'two'), (3, 'three');"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[5].find("three") >= 0
# two comment lines
statement = """--comment\n--comment2\nVALUES (1,'one'), (2, 'two'), (3, 'three');"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[5].find("three") >= 0
# doing without special char \n
statement = """--comment
--comment2
VALUES (1,'one'), (2, 'two'), (3, 'three');
"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[5].find("three") >= 0
# multiline comment + newline in middle of the statement
statement = """/*comment
comment2
comment3*/
VALUES (1,'one'),
(2, 'two'), (3, 'three');"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[5].find("three") >= 0
# multiline comment + newline in middle of the statement
# + comments after the statement
statement = """/*comment
comment2
comment3*/
VALUES (1,'one'),
(2, 'two'), (3, 'three');
--comment4
--comment5"""
result = run(executor, statement, pgspecial=pgspecial)
assert result != None
assert result[5].find("three") >= 0
@dbtest
def test_multiple_queries_same_line(executor):
@ -552,6 +690,38 @@ def test_function_definition(executor):
result = executor.function_definition("the_number_three")
@dbtest
def test_function_notice_order(executor):
run(
executor,
"""
CREATE OR REPLACE FUNCTION demo_order() RETURNS VOID AS
$$
BEGIN
RAISE NOTICE 'first';
RAISE NOTICE 'second';
RAISE NOTICE 'third';
RAISE NOTICE 'fourth';
RAISE NOTICE 'fifth';
RAISE NOTICE 'sixth';
END;
$$
LANGUAGE plpgsql;
""",
)
executor.function_definition("demo_order")
result = run(executor, "select demo_order()")
assert "first\nsecond\nthird\nfourth\nfifth\nsixth" in result[0]
assert "+------------+" in result[1]
assert "| demo_order |" in result[2]
assert "|------------|" in result[3]
assert "| |" in result[4]
assert "+------------+" in result[5]
assert "SELECT 1" in result[6]
@dbtest
def test_view_definition(executor):
run(executor, "create table tbl1 (a text, b numeric)")
@ -583,6 +753,10 @@ def test_short_host(executor):
executor, "host", "localhost1.example.org,localhost2.example.org"
):
assert executor.short_host == "localhost1"
with patch.object(executor, "host", "ec2-11-222-333-444.compute-1.amazonaws.com"):
assert executor.short_host == "ec2-11-222-333-444"
with patch.object(executor, "host", "1.2.3.4"):
assert executor.short_host == "1.2.3.4"
class VirtualCursor:

View File

@ -1,5 +1,5 @@
[tox]
envlist = py37, py38, py39, py310, py311
envlist = py38, py39, py310, py311, py312
[testenv]
deps = pytest>=2.7.0,<=3.0.7
mock>=1.0.1