diff options
31 files changed, 495 insertions, 120 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..413b749 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: mycli + +on: + pull_request: + paths-ignore: + - '**.md' + +jobs: + linux: + + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: [3.6, 3.7, 3.8, 3.9] + + steps: + + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Start MySQL + run: | + sudo /etc/init.d/mysql start + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + pip install --no-cache-dir -e . + + - name: Wait for MySQL connection + run: | + while ! mysqladmin ping --host=localhost --port=3306 --user=root --password=root --silent; do + sleep 5 + done + + - name: Pytest / behave + env: + PYTEST_PASSWORD: root + run: | + ./setup.py test --pytest-args="--cov-report= --cov=mycli" + + - name: Lint + run: | + ./setup.py lint --branch=HEAD + + - name: Coverage + run: | + coverage combine + coverage report + codecov diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0afb5cc..0000000 --- a/.travis.yml +++ /dev/null @@ -1,33 +0,0 @@ -language: python -python: - - "3.6" - - "3.7" - - "3.8" - -matrix: - include: - - python: 3.7 - dist: xenial - sudo: true - -install: - - pip install -r requirements-dev.txt - - pip install -e . - - sudo rm -f /etc/mysql/conf.d/performance-schema.cnf - - sudo service mysql restart - -script: - - ./setup.py test --pytest-args="--cov-report= --cov=mycli" - - coverage combine - - coverage report - - ./setup.py lint --branch=$TRAVIS_BRANCH - -after_success: - - codecov - -notifications: - webhooks: - urls: - - YOUR_WEBHOOK_URL - on_success: change # options: [always|never|change] default: always - on_failure: always # options: [always|never|change] default: always @@ -63,8 +63,8 @@ $ sudo apt-get install mycli # Only on debian or ubuntu --ssh-password TEXT Password to connect to ssh server. --ssh-key-filename TEXT Private key filename (identify file) for the ssh connection. - --ssh-config-path TEXT Path to ssh configuation. - --ssh-config-host TEXT Host for ssh server in ssh configuations (requires paramiko). + --ssh-config-path TEXT Path to ssh configuration. + --ssh-config-host TEXT Host for ssh server in ssh configurations (requires paramiko). --ssl-ca PATH CA file in PEM format. --ssl-capath TEXT CA directory. --ssl-cert PATH X509 cert in PEM format. @@ -96,6 +96,8 @@ $ sudo apt-get install mycli # Only on debian or ubuntu --local-infile BOOLEAN Enable/disable LOAD DATA LOCAL INFILE. --login-path TEXT Read this path from the login file. -e, --execute TEXT Execute command and quit. + --init-command TEXT SQL statement to execute after connecting. + --charset TEXT Character set for MySQL session. --help Show this message and exit. Features @@ -112,7 +114,7 @@ Features * Support for multiline queries. * Favorite queries with optional positional parameters. Save a query using `\fs alias query` and execute it with `\f alias` whenever you need. -* Timing of sql statments and table rendering. +* Timing of sql statements and table rendering. * Config file is automatically created at ``~/.myclirc`` at first launch. * Log every query and its results to a file (disabled by default). * Pretty prints tabular data (with colors!) diff --git a/changelog.md b/changelog.md index a4fea35..fe6e268 100644 --- a/changelog.md +++ b/changelog.md @@ -1,9 +1,51 @@ +1.23.2 +=== + +Bug Fixes: +---------- +* Ensure `--port` is always an int. + +1.23.1 +=== + +Bug Fixes: +---------- +* Allow `--host` without `--port` to make a TCP connection. + +1.23.0 +=== + +Features: +--------- + +* Add an option `--init-command` to execute SQL after connecting (Thanks: [KITAGAWA Yasutaka]). +* Use InputMode.REPLACE_SINGLE +* Add support for ANSI escape sequences for coloring the prompt. +* Allow customization of Pygments SQL syntax-highlighting styles. +* Add a `\clip` special command to copy queries to the system clipboard. +* Add a special command `\pipe_once` to pipe output to a subprocess. +* Add an option `--charset` to set the default charset when connect database. + +Bug Fixes: +---------- +* Fixed compatibility with sqlparse 0.4 (Thanks: [mtorromeo]). +* Fixed iPython magic (Thanks: [mwcm]). +* Send "Connecting to socket" message to the standard error. +* Respect empty string for prompt_continuation via `prompt_continuation = ''` in `.myclirc` +* Fix \once -o to overwrite output whole, instead of line-by-line. +* Dispatch lines ending with `\e` or `\clip` on return, even in multiline mode. +* Restore working local `--socket=<UDS>` (Thanks: [xeron]). +* Allow backtick quoting around the database argument to the `use` command. +* Avoid opening `/dev/tty` when `--no-warn` is given. +* Fixed some typo errors in `README.md`. + 1.22.2 ====== Bug Fixes: ---------- -* Make the `pwd` module optional. + +* Make the `pwd` module optional. 1.22.1 ====== @@ -18,6 +60,11 @@ Features: * Add an option `--list-ssh-config` to list ssh configurations. * Add an option `--ssh-config-path` to choose ssh configuration path. +Bug Fixes: +---------- + +* Fix specifying empty password with `--password=''` when config file has a password set (Thanks: [Zach DeCook]). + 1.21.1 ====== @@ -28,6 +75,7 @@ Bug Fixes: * Fix broken auto-completion for favorite queries (Thanks: [Amjith]). * Fix undefined variable exception when running with --no-warn (Thanks: [Georgy Frolov]) +* Support setting color for null value (Thanks: [laixintao]) 1.21.0 ====== @@ -768,3 +816,8 @@ Bug Fixes: [François Pietka]: https://github.com/fpietka [Frederic Aoustin]: https://github.com/fraoustin [Georgy Frolov]: https://github.com/pasenor +[Zach DeCook]: https://zachdecook.com +[laixintao]: https://github.com/laixintao +[mtorromeo]: https://github.com/mtorromeo +[mwcm]: https://github.com/mwcm +[xeron]: https://github.com/xeron diff --git a/mycli/AUTHORS b/mycli/AUTHORS index b3636d9..221ce8b 100644 --- a/mycli/AUTHORS +++ b/mycli/AUTHORS @@ -72,6 +72,16 @@ Contributors: * Jakub Boukal * Takeshi D. Itoh * laixintao + * Zach DeCook + * kevinhwang91 + * KITAGAWA Yasutaka + * bitkeen + * Morgan Mitchell + * Massimiliano Torromeo + * Roland Walker + * xeron + * 0xflotus + * Seamile Creator: -------- diff --git a/mycli/__init__.py b/mycli/__init__.py index 53bfe2e..375471f 100644 --- a/mycli/__init__.py +++ b/mycli/__init__.py @@ -1 +1 @@ -__version__ = '1.22.2' +__version__ = '1.23.2' diff --git a/mycli/clibuffer.py b/mycli/clibuffer.py index c9d29d1..c0cb5c1 100644 --- a/mycli/clibuffer.py +++ b/mycli/clibuffer.py @@ -39,6 +39,8 @@ def _multiline_exception(text): text.endswith('\\g') or text.endswith('\\G') or + text.endswith(r'\e') or + text.endswith(r'\clip') or # Exit doesn't need semi-column` (text == 'exit') or diff --git a/mycli/clistyle.py b/mycli/clistyle.py index c94f793..b0ac992 100644 --- a/mycli/clistyle.py +++ b/mycli/clistyle.py @@ -34,6 +34,7 @@ TOKEN_TO_PROMPT_STYLE = { Token.Output.Header: 'output.header', Token.Output.OddRow: 'output.odd-row', Token.Output.EvenRow: 'output.even-row', + Token.Output.Null: 'output.null', Token.Prompt: 'prompt', Token.Continuation: 'continuation', } @@ -43,6 +44,36 @@ PROMPT_STYLE_TO_TOKEN = { v: k for k, v in TOKEN_TO_PROMPT_STYLE.items() } +# all tokens that the Pygments MySQL lexer can produce +OVERRIDE_STYLE_TO_TOKEN = { + 'sql.comment': Token.Comment, + 'sql.comment.multi-line': Token.Comment.Multiline, + 'sql.comment.single-line': Token.Comment.Single, + 'sql.comment.optimizer-hint': Token.Comment.Special, + 'sql.escape': Token.Error, + 'sql.keyword': Token.Keyword, + 'sql.datatype': Token.Keyword.Type, + 'sql.literal': Token.Literal, + 'sql.literal.date': Token.Literal.Date, + 'sql.symbol': Token.Name, + 'sql.quoted-schema-object': Token.Name.Quoted, + 'sql.quoted-schema-object.escape': Token.Name.Quoted.Escape, + 'sql.constant': Token.Name.Constant, + 'sql.function': Token.Name.Function, + 'sql.variable': Token.Name.Variable, + 'sql.number': Token.Number, + 'sql.number.binary': Token.Number.Bin, + 'sql.number.float': Token.Number.Float, + 'sql.number.hex': Token.Number.Hex, + 'sql.number.integer': Token.Number.Integer, + 'sql.operator': Token.Operator, + 'sql.punctuation': Token.Punctuation, + 'sql.string': Token.String, + 'sql.string.double-quouted': Token.String.Double, + 'sql.string.escape': Token.String.Escape, + 'sql.string.single-quoted': Token.String.Single, + 'sql.whitespace': Token.Text, +} def parse_pygments_style(token_name, style_object, style_dict): """Parse token type and style string. @@ -107,6 +138,9 @@ def style_factory_output(name, cli_style): elif token in PROMPT_STYLE_TO_TOKEN: token_type = PROMPT_STYLE_TO_TOKEN[token] style.update({token_type: cli_style[token]}) + elif token in OVERRIDE_STYLE_TO_TOKEN: + token_type = OVERRIDE_STYLE_TO_TOKEN[token] + style.update({token_type: cli_style[token]}) else: # TODO: cli helpers will have to switch to ptk.Style logger.error('Unhandled style / class name: %s', token) diff --git a/mycli/clitoolbar.py b/mycli/clitoolbar.py index e03e182..eec2978 100644 --- a/mycli/clitoolbar.py +++ b/mycli/clitoolbar.py @@ -48,5 +48,6 @@ def _get_vi_mode(): InputMode.INSERT: 'I', InputMode.NAVIGATION: 'N', InputMode.REPLACE: 'R', + InputMode.REPLACE_SINGLE: 'R', InputMode.INSERT_MULTIPLE: 'M', }[get_app().vi_state.input_mode] diff --git a/mycli/magic.py b/mycli/magic.py index 5527f72..b1a3268 100644 --- a/mycli/magic.py +++ b/mycli/magic.py @@ -19,7 +19,7 @@ def load_ipython_extension(ipython): def mycli_line_magic(line): _logger.debug('mycli magic called: %r', line) parsed = sql.parse.parse(line, {}) - conn = sql.connection.Connection.get(parsed['connection']) + conn = sql.connection.Connection(parsed['connection']) try: # A corresponding mycli object already exists diff --git a/mycli/main.py b/mycli/main.py index 03797a0..f2b2fd8 100755 --- a/mycli/main.py +++ b/mycli/main.py @@ -21,13 +21,14 @@ from cli_helpers.tabular_output import preprocessors from cli_helpers.utils import strip_ansi import click import sqlparse -from mycli.packages.parseutils import is_dropping_database +from mycli.packages.parseutils import is_dropping_database, is_destructive from prompt_toolkit.completion import DynamicCompleter from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode from prompt_toolkit.key_binding.bindings.named_commands import register as prompt_register from prompt_toolkit.shortcuts import PromptSession, CompleteStyle from prompt_toolkit.document import Document from prompt_toolkit.filters import HasFocus, IsDone +from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.layout.processors import (HighlightMatchingBracketProcessor, ConditionalProcessor) from prompt_toolkit.lexers import PygmentsLexer @@ -98,7 +99,7 @@ class MyCli(object): xdg_config_home = "~/.config" system_config_files = [ '/etc/myclirc', - os.path.join(xdg_config_home, "mycli", "myclirc") + os.path.join(os.path.expanduser(xdg_config_home), "mycli", "myclirc") ] default_config_file = os.path.join(PACKAGE_ROOT, 'myclirc') @@ -152,7 +153,7 @@ class MyCli(object): c['main'].as_bool('auto_vertical_output') # Write user config if system config wasn't the last config loaded. - if c.filename not in self.system_config_files: + if c.filename not in self.system_config_files and not os.path.exists(myclirc): write_default_config(self.default_config_file, myclirc) # audit log @@ -238,6 +239,9 @@ class MyCli(object): ) return + if arg.startswith('`') and arg.endswith('`'): + arg = re.sub(r'^`(.*)`$', r'\1', arg) + arg = re.sub(r'``', r'`', arg) self.sqlexecute.change_db(arg) yield (None, None, None, 'You are now connected to database "%s" as ' @@ -363,7 +367,7 @@ class MyCli(object): def connect(self, database='', user='', passwd='', host='', port='', socket='', charset='', local_infile='', ssl='', ssh_user='', ssh_host='', ssh_port='', - ssh_password='', ssh_key_filename=''): + ssh_password='', ssh_key_filename='', init_command=''): cnf = {'database': None, 'user': None, @@ -387,16 +391,16 @@ class MyCli(object): database = database or cnf['database'] # Socket interface not supported for SSH connections - if port or host or ssh_host or ssh_port: + if port or (host and host != 'localhost') or (ssh_host and ssh_port): socket = '' else: socket = socket or cnf['socket'] or guess_socket_location() user = user or cnf['user'] or os.getenv('USER') host = host or cnf['host'] - port = port or cnf['port'] + port = int(port or cnf['port'] or 3306) ssl = ssl or {} - passwd = passwd or cnf['password'] + passwd = passwd if isinstance(passwd, str) else cnf['password'] charset = charset or cnf['default-character-set'] or 'utf8' # Favor whichever local_infile option is set. @@ -420,7 +424,7 @@ class MyCli(object): self.sqlexecute = SQLExecute( database, user, passwd, host, port, socket, charset, local_infile, ssl, ssh_user, ssh_host, ssh_port, - ssh_password, ssh_key_filename + ssh_password, ssh_key_filename, init_command ) except OperationalError as e: if ('Access denied for user' in e.args[1]): @@ -429,7 +433,7 @@ class MyCli(object): self.sqlexecute = SQLExecute( database, user, new_passwd, host, port, socket, charset, local_infile, ssl, ssh_user, ssh_host, - ssh_port, ssh_password, ssh_key_filename + ssh_port, ssh_password, ssh_key_filename, init_command ) else: raise e @@ -438,7 +442,7 @@ class MyCli(object): if not WIN and socket: socket_owner = getpwuid(os.stat(socket).st_uid).pw_name self.echo( - f"Connecting to socket {socket}, owned by user {socket_owner}") + f"Connecting to socket {socket}, owned by user {socket_owner}", err=True) try: _connect() except OperationalError as e: @@ -481,7 +485,7 @@ class MyCli(object): exit(1) def handle_editor_command(self, text): - """Editor command is any query that is prefixed or suffixed by a '\e'. + r"""Editor command is any query that is prefixed or suffixed by a '\e'. The reason for a while loop is because a user might edit a query multiple times. For eg: @@ -511,6 +515,24 @@ class MyCli(object): continue return text + def handle_clip_command(self, text): + r"""A clip command is any query that is prefixed or suffixed by a + '\clip'. + + :param text: Document + :return: Boolean + + """ + + if special.clip_command(text): + query = (special.get_clip_query(text) or + self.get_last_query()) + message = special.copy_query_to_clipboard(sql=query) + if message: + raise RuntimeError(message) + return True + return False + def run_cli(self): iterations = 0 sqlexecute = self.sqlexecute @@ -548,10 +570,13 @@ class MyCli(object): prompt = self.get_prompt(self.prompt_format) if self.prompt_format == self.default_prompt and len(prompt) > self.max_len_prompt: prompt = self.get_prompt('\\d> ') - return [('class:prompt', prompt)] + prompt = prompt.replace("\\x1b", "\x1b") + return ANSI(prompt) def get_continuation(width, *_): - if self.multiline_continuation_char: + if self.multiline_continuation_char == '': + continuation = '' + elif self.multiline_continuation_char: left_padding = width - len(self.multiline_continuation_char) continuation = " " * \ max((left_padding - 1), 0) + \ @@ -580,6 +605,15 @@ class MyCli(object): self.echo(str(e), err=True, fg='red') return + try: + if self.handle_clip_command(text): + return + except RuntimeError as e: + logger.error("sql: %r, error: %r", text, e) + logger.error("traceback: %r", traceback.format_exc()) + self.echo(str(e), err=True, fg='red') + return + if not text.strip(): return @@ -654,6 +688,7 @@ class MyCli(object): result_count += 1 mutating = mutating or destroy or is_mutating(status) special.unset_once_if_written() + special.unset_pipe_once_if_written() except EOFError as e: raise e except KeyboardInterrupt: @@ -814,6 +849,7 @@ class MyCli(object): self.log_output(line) special.write_tee(line) special.write_once(line) + special.write_pipe_once(line) if fits or output_via_pager: # buffering @@ -1051,6 +1087,10 @@ class MyCli(object): help='Read this path from the login file.') @click.option('-e', '--execute', type=str, help='Execute command and quit.') +@click.option('--init-command', type=str, + help='SQL statement to execute after connecting.') +@click.option('--charset', type=str, + help='Character set for MySQL session.') @click.argument('database', default='', nargs=1) def cli(database, user, host, port, socket, password, dbname, version, verbose, prompt, logfile, defaults_group_suffix, @@ -1058,7 +1098,8 @@ def cli(database, user, host, port, socket, password, dbname, ssl_ca, ssl_capath, ssl_cert, ssl_key, ssl_cipher, ssl_verify_server_cert, table, csv, warn, execute, myclirc, dsn, list_dsn, ssh_user, ssh_host, ssh_port, ssh_password, - ssh_key_filename, list_ssh_config, ssh_config_path, ssh_config_host): + ssh_key_filename, list_ssh_config, ssh_config_path, ssh_config_host, + init_command, charset): """A MySQL terminal client with auto-completion and syntax highlighting. \b @@ -1182,7 +1223,9 @@ def cli(database, user, host, port, socket, password, dbname, ssh_host=ssh_host, ssh_port=ssh_port, ssh_password=ssh_password, - ssh_key_filename=ssh_key_filename + ssh_key_filename=ssh_key_filename, + init_command=init_command, + charset=charset ) mycli.logger.debug('Launch Params: \n' @@ -1217,14 +1260,15 @@ def cli(database, user, host, port, socket, password, dbname, click.secho('Sorry... :(', err=True, fg='red') exit(1) - try: - sys.stdin = open('/dev/tty') - except (IOError, OSError): - mycli.logger.warning('Unable to open TTY as stdin.') + if mycli.destructive_warning and is_destructive(stdin_text): + try: + sys.stdin = open('/dev/tty') + warn_confirmed = confirm_destructive_query(stdin_text) + except (IOError, OSError): + mycli.logger.warning('Unable to open TTY as stdin.') + if not warn_confirmed: + exit(0) - if (mycli.destructive_warning and - confirm_destructive_query(stdin_text) is False): - exit(0) try: new_line = True @@ -1287,7 +1331,7 @@ def is_select(status): def thanks_picker(files=()): contents = [] for line in fileinput.input(files=files): - m = re.match('^ *\* (.*)', line) + m = re.match(r'^ *\* (.*)', line) if m: contents.append(m.group(1)) return choice(contents) diff --git a/mycli/myclirc b/mycli/myclirc index 534b201..0bde200 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -41,6 +41,7 @@ table_format = ascii # friendly, monokai, paraiso, colorful, murphy, bw, pastie, paraiso, trac, default, # fruity. # Screenshots at http://mycli.net/syntax +# Can be further modified in [colors] syntax_style = default # Keybindings: Possible values: emacs, vi. @@ -65,6 +66,7 @@ wider_completion_menu = False # \t - Product type (Percona, MySQL, MariaDB) # \A - DSN alias name (from the [alias_dsn] section) # \u - Username +# \x1b[...m - insert ANSI escape sequence prompt = '\t \u@\h:\d> ' prompt_continuation = '->' @@ -111,6 +113,36 @@ bottom-toolbar.transaction.failed = 'bg:#222222 #ff005f bold' output.header = "#00ff5f bold" output.odd-row = "" output.even-row = "" +output.null = "#808080" + +# SQL syntax highlighting overrides +# sql.comment = 'italic #408080' +# sql.comment.multi-line = '' +# sql.comment.single-line = '' +# sql.comment.optimizer-hint = '' +# sql.escape = 'border:#FF0000' +# sql.keyword = 'bold #008000' +# sql.datatype = 'nobold #B00040' +# sql.literal = '' +# sql.literal.date = '' +# sql.symbol = '' +# sql.quoted-schema-object = '' +# sql.quoted-schema-object.escape = '' +# sql.constant = '#880000' +# sql.function = '#0000FF' +# sql.variable = '#19177C' +# sql.number = '#666666' +# sql.number.binary = '' +# sql.number.float = '' +# sql.number.hex = '' +# sql.number.integer = '' +# sql.operator = '#666666' +# sql.punctuation = '' +# sql.string = '#BA2121' +# sql.string.double-quouted = '' +# sql.string.escape = 'bold #BB6622' +# sql.string.single-quoted = '' +# sql.whitespace = '' # Favorite queries. [favorite_queries] diff --git a/mycli/packages/completion_engine.py b/mycli/packages/completion_engine.py index 2b19c32..3cff2cc 100644 --- a/mycli/packages/completion_engine.py +++ b/mycli/packages/completion_engine.py @@ -2,7 +2,6 @@ import os import sys import sqlparse from sqlparse.sql import Comparison, Identifier, Where -from sqlparse.compat import text_type from .parseutils import last_word, extract_tables, find_prev_keyword from .special import parse_special_command @@ -55,7 +54,7 @@ def suggest_type(full_text, text_before_cursor): stmt_start, stmt_end = 0, 0 for statement in parsed: - stmt_len = len(text_type(statement)) + stmt_len = len(str(statement)) stmt_start, stmt_end = stmt_end, stmt_end + stmt_len if stmt_end >= current_pos: diff --git a/mycli/packages/parseutils.py b/mycli/packages/parseutils.py index e3b383e..268e04e 100644 --- a/mycli/packages/parseutils.py +++ b/mycli/packages/parseutils.py @@ -11,11 +11,11 @@ cleanup_regex = { # This matches everything except spaces, parens, colon, comma, and period 'most_punctuations': re.compile(r'([^\.():,\s]+)$'), # This matches everything except a space. - 'all_punctuations': re.compile('([^\s]+)$'), + 'all_punctuations': re.compile(r'([^\s]+)$'), } def last_word(text, include='alphanum_underscore'): - """ + r""" Find the last word in a sentence. >>> last_word('abc') diff --git a/mycli/packages/special/iocommands.py b/mycli/packages/special/iocommands.py index 11dca8d..58066b8 100644 --- a/mycli/packages/special/iocommands.py +++ b/mycli/packages/special/iocommands.py @@ -8,6 +8,7 @@ from io import open from time import sleep import click +import pyperclip import sqlparse from . import export @@ -23,6 +24,8 @@ PAGER_ENABLED = True tee_file = None once_file = None written_to_once_file = False +pipe_once_process = None +written_to_pipe_once_process = False delimiter_command = DelimiterCommand() @@ -115,7 +118,7 @@ def get_editor_query(sql): # The reason we can't simply do .strip('\e') is that it strips characters, # not a substring. So it'll strip "e" in the end of the sql also! # Ex: "select * from style\e" -> "select * from styl". - pattern = re.compile('(^\\\e|\\\e$)') + pattern = re.compile(r'(^\\e|\\e$)') while pattern.search(sql): sql = pattern.sub('', sql) @@ -159,6 +162,47 @@ def open_external_editor(filename=None, sql=None): return (query, message) +@export +def clip_command(command): + """Is this a clip command? + + :param command: string + + """ + # It is possible to have `\clip` or `SELECT * FROM \clip`. So we check + # for both conditions. + return command.strip().endswith('\\clip') or command.strip().startswith('\\clip') + + +@export +def get_clip_query(sql): + """Get the query part of a clip command.""" + sql = sql.strip() + + # The reason we can't simply do .strip('\clip') is that it strips characters, + # not a substring. So it'll strip "c" in the end of the sql also! + pattern = re.compile(r'(^\\clip|\\clip$)') + while pattern.search(sql): + sql = pattern.sub('', sql) + + return sql + + +@export +def copy_query_to_clipboard(sql=None): + """Send query to the clipboard.""" + + sql = sql or '' + message = None + + try: + pyperclip.copy(u'{sql}'.format(sql=sql)) + except RuntimeError as e: + message = 'Error clipping query: %s.' % e.strerror + + return message + + @special_command('\\f', '\\f [name [args..]]', 'List or execute favorite queries.', arg_type=PARSED_QUERY, case_sensitive=True) def execute_favorite_query(cur, arg, **_): """Returns (title, rows, headers, status)""" @@ -213,7 +257,7 @@ def subst_favorite_query_args(query, args): query = query.replace(subst_var, val) - match = re.search('\\$\d+', query) + match = re.search(r'\$\d+', query) if match: return[None, 'missing substitution for ' + match.group(0) + ' in query:\n ' + query] @@ -337,7 +381,11 @@ def write_tee(output): def set_once(arg, **_): global once_file, written_to_once_file - once_file = parseargfile(arg) + try: + once_file = open(**parseargfile(arg)) + except (IOError, OSError) as e: + raise OSError("Cannot write to file '{}': {}".format( + e.filename, e.strerror)) written_to_once_file = False return [(None, None, None, "")] @@ -347,26 +395,68 @@ def set_once(arg, **_): def write_once(output): global once_file, written_to_once_file if output and once_file: - try: - f = open(**once_file) - except (IOError, OSError) as e: - once_file = None - raise OSError("Cannot write to file '{}': {}".format( - e.filename, e.strerror)) - with f: - click.echo(output, file=f, nl=False) - click.echo(u"\n", file=f, nl=False) + click.echo(output, file=once_file, nl=False) + click.echo(u"\n", file=once_file, nl=False) + once_file.flush() written_to_once_file = True @export def unset_once_if_written(): """Unset the once file, if it has been written to.""" - global once_file - if written_to_once_file: + global once_file, written_to_once_file + if written_to_once_file and once_file: + once_file.close() once_file = None +@special_command('\\pipe_once', '\\| command', + 'Send next result to a subprocess.', + aliases=('\\|', )) +def set_pipe_once(arg, **_): + global pipe_once_process, written_to_pipe_once_process + pipe_once_cmd = shlex.split(arg) + if len(pipe_once_cmd) == 0: + raise OSError("pipe_once requires a command") + written_to_pipe_once_process = False + pipe_once_process = subprocess.Popen(pipe_once_cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=1, + encoding='UTF-8', + universal_newlines=True) + return [(None, None, None, "")] + + +@export +def write_pipe_once(output): + global pipe_once_process, written_to_pipe_once_process + if output and pipe_once_process: + try: + click.echo(output, file=pipe_once_process.stdin, nl=False) + click.echo(u"\n", file=pipe_once_process.stdin, nl=False) + except (IOError, OSError) as e: + pipe_once_process.terminate() + raise OSError( + "Failed writing to pipe_once subprocess: {}".format(e.strerror)) + written_to_pipe_once_process = True + + +@export +def unset_pipe_once_if_written(): + """Unset the pipe_once cmd, if it has been written to.""" + global pipe_once_process, written_to_pipe_once_process + if written_to_pipe_once_process: + (stdout_data, stderr_data) = pipe_once_process.communicate() + if len(stdout_data) > 0: + print(stdout_data.rstrip(u"\n")) + if len(stderr_data) > 0: + print(stderr_data.rstrip(u"\n")) + pipe_once_process = None + written_to_pipe_once_process = False + + @special_command( 'watch', 'watch [seconds] [-c] query', diff --git a/mycli/packages/special/main.py b/mycli/packages/special/main.py index dddba66..ab04f30 100644 --- a/mycli/packages/special/main.py +++ b/mycli/packages/special/main.py @@ -112,6 +112,8 @@ def quit(*_args): @special_command('\\e', '\\e', 'Edit command with editor (uses $EDITOR).', arg_type=NO_QUERY, case_sensitive=True) +@special_command('\\clip', '\\clip', 'Copy query to the system clipboard.', + arg_type=NO_QUERY, case_sensitive=True) @special_command('\\G', '\\G', 'Display current query results vertically.', arg_type=NO_QUERY, case_sensitive=True) def stub(): diff --git a/mycli/sqlcompleter.py b/mycli/sqlcompleter.py index 20611be..73b9b44 100644 --- a/mycli/sqlcompleter.py +++ b/mycli/sqlcompleter.py @@ -59,7 +59,7 @@ class SQLCompleter(Completer): self.reserved_words = set() for x in self.keywords: self.reserved_words.update(x.split()) - self.name_pattern = compile("^[_a-z][_a-z0-9\$]*$") + self.name_pattern = compile(r"^[_a-z][_a-z0-9\$]*$") self.special_commands = [] self.table_formats = supported_formats diff --git a/mycli/sqlexecute.py b/mycli/sqlexecute.py index c68af0f..7534982 100644 --- a/mycli/sqlexecute.py +++ b/mycli/sqlexecute.py @@ -42,7 +42,7 @@ class SQLExecute(object): def __init__(self, database, user, password, host, port, socket, charset, local_infile, ssl, ssh_user, ssh_host, ssh_port, ssh_password, - ssh_key_filename): + ssh_key_filename, init_command=None): self.dbname = database self.user = user self.password = password @@ -59,12 +59,13 @@ class SQLExecute(object): self.ssh_port = ssh_port self.ssh_password = ssh_password self.ssh_key_filename = ssh_key_filename + self.init_command = init_command self.connect() def connect(self, database=None, user=None, password=None, host=None, port=None, socket=None, charset=None, local_infile=None, ssl=None, ssh_host=None, ssh_port=None, ssh_user=None, - ssh_password=None, ssh_key_filename=None): + ssh_password=None, ssh_key_filename=None, init_command=None): db = (database or self.dbname) user = (user or self.user) password = (password or self.password) @@ -79,6 +80,7 @@ class SQLExecute(object): ssh_port = (ssh_port or self.ssh_port) ssh_password = (ssh_password or self.ssh_password) ssh_key_filename = (ssh_key_filename or self.ssh_key_filename) + init_command = (init_command or self.init_command) _logger.debug( 'Connection DB Params: \n' '\tdatabase: %r' @@ -93,9 +95,11 @@ class SQLExecute(object): '\tssh_host: %r' '\tssh_port: %r' '\tssh_password: %r' - '\tssh_key_filename: %r', + '\tssh_key_filename: %r' + '\tinit_command: %r', db, user, host, port, socket, charset, local_infile, ssl, - ssh_user, ssh_host, ssh_port, ssh_password, ssh_key_filename + ssh_user, ssh_host, ssh_port, ssh_password, ssh_key_filename, + init_command ) conv = conversions.copy() conv.update({ @@ -110,12 +114,16 @@ class SQLExecute(object): if ssh_host: defer_connect = True + client_flag = pymysql.constants.CLIENT.INTERACTIVE + if init_command and len(list(special.split_queries(init_command))) > 1: + client_flag |= pymysql.constants.CLIENT.MULTI_STATEMENTS + conn = pymysql.connect( database=db, user=user, password=password, host=host, port=port, unix_socket=socket, use_unicode=True, charset=charset, - autocommit=True, client_flag=pymysql.constants.CLIENT.INTERACTIVE, + autocommit=True, client_flag=client_flag, local_infile=local_infile, conv=conv, ssl=ssl, program_name="mycli", - defer_connect=defer_connect + defer_connect=defer_connect, init_command=init_command ) if ssh_host: @@ -146,6 +154,7 @@ class SQLExecute(object): self.socket = socket self.charset = charset self.ssl = ssl + self.init_command = init_command # retrieve connection id self.reset_connection_id() diff --git a/requirements-dev.txt b/requirements-dev.txt index 8e206a5..7a38ed5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,8 +3,8 @@ pytest!=3.3.0 pytest-cov==2.4.0 tox twine==1.12.1 -behave -pexpect +behave>=1.2.4 +pexpect==3.3 coverage==5.0.4 codecov==2.0.9 autopep8==1.3.3 @@ -19,12 +19,13 @@ description = 'CLI for MySQL Database. With auto-completion and syntax highlight install_requirements = [ 'click >= 7.0', 'Pygments >= 1.6', - 'prompt_toolkit>=3.0.0,<4.0.0', + 'prompt_toolkit>=3.0.6,<4.0.0', 'PyMySQL >= 0.9.2', 'sqlparse>=0.3.0,<0.4.0', 'configobj >= 5.0.5', 'cryptography >= 1.0.0', - 'cli_helpers[styles] > 1.1.0', + 'cli_helpers[styles] >= 2.0.1', + 'pyperclip >= 1.8.1' ] @@ -65,7 +66,7 @@ class test(TestCommand): def initialize_options(self): TestCommand.initialize_options(self) self.pytest_args = '' - self.behave_args = '' + self.behave_args = '--no-capture' def run_tests(self): unit_test_errno = subprocess.call( @@ -76,6 +77,7 @@ class test(TestCommand): 'behave test/features ' + self.behave_args, shell=True ) + subprocess.run(['git', 'checkout', '--', 'test/myclirc'], check=False) sys.exit(unit_test_errno or cli_errno) diff --git a/test/conftest.py b/test/conftest.py index cf6d721..d7d10ce 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -4,7 +4,7 @@ from .utils import (HOST, USER, PASSWORD, PORT, CHARSET, create_db, import mycli.sqlexecute -@pytest.yield_fixture(scope="function") +@pytest.fixture(scope="function") def connection(): create_db('_test_db') connection = db_connection('_test_db') diff --git a/test/features/crud_database.feature b/test/features/crud_database.feature index 0c298b6..f4a7a7f 100644 --- a/test/features/crud_database.feature +++ b/test/features/crud_database.feature @@ -16,6 +16,10 @@ Feature: manipulate databases: when we connect to dbserver then we see database connected + Scenario: connect and disconnect from quoted test database + When we connect to quoted test database + then we see database connected + Scenario: create and drop default database When we create database then we see database created diff --git a/test/features/db_utils.py b/test/features/db_utils.py index c29dedb..be550e9 100644 --- a/test/features/db_utils.py +++ b/test/features/db_utils.py @@ -1,11 +1,12 @@ import pymysql -def create_db(hostname='localhost', username=None, password=None, - dbname=None): +def create_db(hostname='localhost', port=3306, username=None, + password=None, dbname=None): """Create test database. :param hostname: string + :param port: int :param username: string :param password: string :param dbname: string @@ -14,6 +15,7 @@ def create_db(hostname='localhost', username=None, password=None, """ cn = pymysql.connect( host=hostname, + port=port, user=username, password=password, charset='utf8mb4', @@ -26,14 +28,15 @@ def create_db(hostname='localhost', username=None, password=None, cn.close() - cn = create_cn(hostname, password, username, dbname) + cn = create_cn(hostname, port, password, username, dbname) return cn -def create_cn(hostname, password, username, dbname): +def create_cn(hostname, port, password, username, dbname): """Open connection to database. :param hostname: + :param port: :param password: :param username: :param dbname: string @@ -42,6 +45,7 @@ def create_cn(hostname, password, username, dbname): """ cn = pymysql.connect( host=hostname, + port=port, user=username, password=password, db=dbname, @@ -52,11 +56,12 @@ def create_cn(hostname, password, username, dbname): return cn -def drop_db(hostname='localhost', username=None, password=None, - dbname=None): +def drop_db(hostname='localhost', port=3306, username=None, + password=None, dbname=None): """Drop database. :param hostname: string + :param port: int :param username: string :param password: string :param dbname: string @@ -64,6 +69,7 @@ def drop_db(hostname='localhost', username=None, password=None, """ cn = pymysql.connect( host=hostname, + port=port, user=username, password=password, db=dbname, diff --git a/test/features/environment.py b/test/features/environment.py index 1a49dbe..98c2004 100644 --- a/test/features/environment.py +++ b/test/features/environment.py @@ -16,8 +16,9 @@ def before_all(context): os.environ['LINES'] = "100" os.environ['COLUMNS'] = "100" os.environ['EDITOR'] = 'ex' - os.environ['LC_ALL'] = 'en_US.utf8' + os.environ['LC_ALL'] = 'en_US.UTF-8' os.environ['PROMPT_TOOLKIT_NO_CPR'] = '1' + os.environ['MYCLI_HISTFILE'] = os.devnull test_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) login_path_file = os.path.join(test_dir, 'mylogin.cnf') @@ -42,6 +43,10 @@ def before_all(context): 'my_test_host', os.getenv('PYTEST_HOST', 'localhost') ), + 'port': context.config.userdata.get( + 'my_test_port', + int(os.getenv('PYTEST_PORT', '3306')) + ), 'user': context.config.userdata.get( 'my_test_user', os.getenv('PYTEST_USER', 'root') @@ -72,7 +77,8 @@ def before_all(context): context.conf['myclirc'] = os.path.join(context.package_root, 'test', 'myclirc') - context.cn = dbutils.create_db(context.conf['host'], context.conf['user'], + context.cn = dbutils.create_db(context.conf['host'], context.conf['port'], + context.conf['user'], context.conf['pass'], context.conf['dbname']) @@ -82,8 +88,9 @@ def before_all(context): def after_all(context): """Unset env parameters.""" dbutils.close_cn(context.cn) - dbutils.drop_db(context.conf['host'], context.conf['user'], - context.conf['pass'], context.conf['dbname']) + dbutils.drop_db(context.conf['host'], context.conf['port'], + context.conf['user'], context.conf['pass'], + context.conf['dbname']) # Restore env vars. #for k, v in context.pgenv.items(): @@ -118,11 +125,12 @@ def after_scenario(context, _): host = context.conf['host'] dbname = context.currentdb context.cli.expect_exact( - '{0}@{1}:{2}> '.format( + '{0}@{1}:{2}>'.format( user, host, dbname ), timeout=5 ) + context.cli.sendcontrol('c') context.cli.sendcontrol('d') context.cli.expect_exact(pexpect.EOF, timeout=5) diff --git a/test/features/fixture_data/help_commands.txt b/test/features/fixture_data/help_commands.txt index 657db7d..2c06d5d 100644 --- a/test/features/fixture_data/help_commands.txt +++ b/test/features/fixture_data/help_commands.txt @@ -2,6 +2,7 @@ | Command | Shortcut | Description |
+-------------+----------------------------+------------------------------------------------------------+
| \G | \G | Display current query results vertically. |
+| \clip | \clip | Copy query to the system clipboard. |
| \dt | \dt[+] [table] | List or describe tables. |
| \e | \e | Edit command with editor (uses $EDITOR). |
| \f | \f [name [args..]] | List or execute favorite queries. |
@@ -9,6 +10,7 @@ | \fs | \fs name query | Save a favorite query. |
| \l | \l | List databases. |
| \once | \o [-o] filename | Append next result to an output file (overwrite using -o). |
+| \pipe_once | \| command | Send next result to a subprocess. |
| \timing | \t | Toggle timing of commands. |
| connect | \r | Reconnect to the database. Optional database argument. |
| exit | \q | Exit. |
diff --git a/test/features/steps/crud_database.py b/test/features/steps/crud_database.py index a0bfa53..841f37d 100644 --- a/test/features/steps/crud_database.py +++ b/test/features/steps/crud_database.py @@ -37,6 +37,14 @@ def step_db_connect_test(context): context.cli.sendline('use {0};'.format(db_name)) +@when('we connect to quoted test database') +def step_db_connect_quoted_tmp(context): + """Send connect to database.""" + db_name = context.conf['dbname'] + context.currentdb = db_name + context.cli.sendline('use `{0}`;'.format(db_name)) + + @when('we connect to tmp database') def step_db_connect_tmp(context): """Send connect to database.""" @@ -64,15 +72,13 @@ def step_see_prompt(context): user = context.conf['user'] host = context.conf['host'] dbname = context.currentdb - wrappers.expect_exact(context, '{0}@{1}:{2}> '.format( - user, host, dbname), timeout=5) - context.atprompt = True + wrappers.wait_prompt(context, '{0}@{1}:{2}> '.format(user, host, dbname)) @then('we see help output') def step_see_help(context): for expected_line in context.fixture_data['help_commands.txt']: - wrappers.expect_exact(context, expected_line + '\r\n', timeout=1) + wrappers.expect_exact(context, expected_line, timeout=1) @then('we see database created') @@ -96,10 +102,7 @@ def step_see_db_dropped_no_default(context): context.currentdb = None wrappers.expect_exact(context, 'Query OK, 0 rows affected', timeout=2) - wrappers.expect_exact(context, '{0}@{1}:{2}> '.format( - user, host, database), timeout=5) - - context.atprompt = True + wrappers.wait_prompt(context, '{0}@{1}:{2}>'.format(user, host, database)) @then('we see database connected') diff --git a/test/features/steps/wrappers.py b/test/features/steps/wrappers.py index 565ca59..de833dd 100644 --- a/test/features/steps/wrappers.py +++ b/test/features/steps/wrappers.py @@ -88,7 +88,7 @@ def wait_prompt(context, prompt=None): user = context.conf['user'] host = context.conf['host'] dbname = context.currentdb - prompt = '{0}@{1}:{2}> '.format( + prompt = '{0}@{1}:{2}>'.format( user, host, dbname), expect_exact(context, prompt, timeout=5) context.atprompt = True diff --git a/test/test_main.py b/test/test_main.py index 3f92bd1..707c359 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -492,3 +492,37 @@ def test_ssh_config(monkeypatch): MockMyCli.connect_args["ssh_host"] == "arg_host" and \ MockMyCli.connect_args["ssh_port"] == 3 and \ MockMyCli.connect_args["ssh_key_filename"] == "/path/to/key" + + +@dbtest +def test_init_command_arg(executor): + init_command = "set sql_select_limit=1000" + sql = 'show variables like "sql_select_limit";' + runner = CliRunner() + result = runner.invoke( + cli, args=CLI_ARGS + ["--init-command", init_command], input=sql + ) + + expected = "sql_select_limit\t1000\n" + assert result.exit_code == 0 + assert expected in result.output + + +@dbtest +def test_init_command_multiple_arg(executor): + init_command = 'set sql_select_limit=2000; set max_join_size=20000' + sql = ( + 'show variables like "sql_select_limit";\n' + 'show variables like "max_join_size"' + ) + runner = CliRunner() + result = runner.invoke( + cli, args=CLI_ARGS + ['--init-command', init_command], input=sql + ) + + expected_sql_select_limit = 'sql_select_limit\t2000\n' + expected_max_join_size = 'max_join_size\t20000\n' + + assert result.exit_code == 0 + assert expected_sql_select_limit in result.output + assert expected_max_join_size in result.output diff --git a/test/test_special_iocommands.py b/test/test_special_iocommands.py index b8b3acb..73bfbab 100644 --- a/test/test_special_iocommands.py +++ b/test/test_special_iocommands.py @@ -49,7 +49,8 @@ def test_editor_command(): assert mycli.packages.special.get_filename(r'\e filename') == "filename" os.environ['EDITOR'] = 'true' - mycli.packages.special.open_external_editor(r'select 1') == "select 1" + os.environ['VISUAL'] = 'true' + mycli.packages.special.open_external_editor(sql=r'select 1') == "select 1" def test_tee_command(): @@ -93,9 +94,8 @@ def test_once_command(): with pytest.raises(TypeError): mycli.packages.special.execute(None, u"\\once") - mycli.packages.special.execute(None, u"\\once /proc/access-denied") with pytest.raises(OSError): - mycli.packages.special.write_once(u"hello world") + mycli.packages.special.execute(None, u"\\once /proc/access-denied") mycli.packages.special.write_once(u"hello world") # write without file set with tempfile.NamedTemporaryFile() as f: @@ -104,9 +104,24 @@ def test_once_command(): assert f.read() == b"hello world\n" mycli.packages.special.execute(None, u"\\once -o " + f.name) - mycli.packages.special.write_once(u"hello world") + mycli.packages.special.write_once(u"hello world line 1") + mycli.packages.special.write_once(u"hello world line 2") f.seek(0) - assert f.read() == b"hello world\n" + assert f.read() == b"hello world line 1\nhello world line 2\n" + + +def test_pipe_once_command(): + with pytest.raises(IOError): + mycli.packages.special.execute(None, u"\\pipe_once") + + with pytest.raises(OSError): + mycli.packages.special.execute( + None, u"\\pipe_once /proc/access-denied") + + mycli.packages.special.execute(None, u"\\pipe_once wc") + mycli.packages.special.write_once(u"hello world") + mycli.packages.special.unset_pipe_once_if_written() + # how to assert on wc output? def test_parseargfile(): diff --git a/test/test_sqlexecute.py b/test/test_sqlexecute.py index c2d38be..5168bf6 100644 --- a/test/test_sqlexecute.py +++ b/test/test_sqlexecute.py @@ -166,7 +166,7 @@ def test_favorite_query_expanded_output(executor): results = run(executor, "\\fs test-ae select * from test") assert_result_equal(results, status='Saved.') - results = run(executor, "\\f test-ae \G") + results = run(executor, "\\f test-ae \\G") assert is_expanded_output() is True assert_result_equal(results, title='> select * from test', headers=['a'], rows=[('abc',)], auto_status=False) diff --git a/test/test_tabular_output.py b/test/test_tabular_output.py index 7d7d000..c20c7de 100644 --- a/test/test_tabular_output.py +++ b/test/test_tabular_output.py @@ -16,7 +16,7 @@ from pymysql.constants import FIELD_TYPE @pytest.fixture def mycli(): cli = MyCli() - cli.connect(None, USER, PASSWORD, HOST, PORT, None) + cli.connect(None, USER, PASSWORD, HOST, PORT, None, init_command=None) return cli |