Skip to content

Commit 4cde6a8

Browse files
authored
Merge branch 'master' into prompt-ansi-escape-sequences
2 parents f485dc3 + e593aa8 commit 4cde6a8

File tree

10 files changed

+231
-21
lines changed

10 files changed

+231
-21
lines changed

changelog.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,18 @@ Features:
77
* Add an option `--init-command` to execute SQL after connecting (Thanks: [KITAGAWA Yasutaka]).
88
* Use InputMode.REPLACE_SINGLE
99
* Add support for ANSI escape sequences for coloring the prompt.
10+
* Allow customization of Pygments SQL syntax-highlighting styles.
11+
* Add a `\clip` special command to copy queries to the system clipboard.
12+
* Add a special command `\pipe_once` to pipe output to a subprocess.
13+
1014

1115
Bug Fixes:
1216
----------
1317
* Fixed compatibility with sqlparse 0.4 (Thanks: [mtorromeo]).
1418
* Fixed iPython magic (Thanks: [mwcm]).
19+
* Send "Connecting to socket" message to the standard error.
20+
* Respect empty string for prompt_continuation via `prompt_continuation = ''` in `.myclirc`
21+
* Fix \once -o to overwrite output whole, instead of line-by-line.
1522

1623
1.22.2
1724
======

mycli/clistyle.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,36 @@
4444
v: k for k, v in TOKEN_TO_PROMPT_STYLE.items()
4545
}
4646

47+
# all tokens that the Pygments MySQL lexer can produce
48+
OVERRIDE_STYLE_TO_TOKEN = {
49+
'sql.comment': Token.Comment,
50+
'sql.comment.multi-line': Token.Comment.Multiline,
51+
'sql.comment.single-line': Token.Comment.Single,
52+
'sql.comment.optimizer-hint': Token.Comment.Special,
53+
'sql.escape': Token.Error,
54+
'sql.keyword': Token.Keyword,
55+
'sql.datatype': Token.Keyword.Type,
56+
'sql.literal': Token.Literal,
57+
'sql.literal.date': Token.Literal.Date,
58+
'sql.symbol': Token.Name,
59+
'sql.quoted-schema-object': Token.Name.Quoted,
60+
'sql.quoted-schema-object.escape': Token.Name.Quoted.Escape,
61+
'sql.constant': Token.Name.Constant,
62+
'sql.function': Token.Name.Function,
63+
'sql.variable': Token.Name.Variable,
64+
'sql.number': Token.Number,
65+
'sql.number.binary': Token.Number.Bin,
66+
'sql.number.float': Token.Number.Float,
67+
'sql.number.hex': Token.Number.Hex,
68+
'sql.number.integer': Token.Number.Integer,
69+
'sql.operator': Token.Operator,
70+
'sql.punctuation': Token.Punctuation,
71+
'sql.string': Token.String,
72+
'sql.string.double-quouted': Token.String.Double,
73+
'sql.string.escape': Token.String.Escape,
74+
'sql.string.single-quoted': Token.String.Single,
75+
'sql.whitespace': Token.Text,
76+
}
4777

4878
def parse_pygments_style(token_name, style_object, style_dict):
4979
"""Parse token type and style string.
@@ -108,6 +138,9 @@ def style_factory_output(name, cli_style):
108138
elif token in PROMPT_STYLE_TO_TOKEN:
109139
token_type = PROMPT_STYLE_TO_TOKEN[token]
110140
style.update({token_type: cli_style[token]})
141+
elif token in OVERRIDE_STYLE_TO_TOKEN:
142+
token_type = OVERRIDE_STYLE_TO_TOKEN[token]
143+
style.update({token_type: cli_style[token]})
111144
else:
112145
# TODO: cli helpers will have to switch to ptk.Style
113146
logger.error('Unhandled style / class name: %s', token)

mycli/main.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ def _connect():
439439
if not WIN and socket:
440440
socket_owner = getpwuid(os.stat(socket).st_uid).pw_name
441441
self.echo(
442-
f"Connecting to socket {socket}, owned by user {socket_owner}")
442+
f"Connecting to socket {socket}, owned by user {socket_owner}", err=True)
443443
try:
444444
_connect()
445445
except OperationalError as e:
@@ -512,6 +512,24 @@ def handle_editor_command(self, text):
512512
continue
513513
return text
514514

515+
def handle_clip_command(self, text):
516+
"""A clip command is any query that is prefixed or suffixed by a
517+
'\clip'.
518+
519+
:param text: Document
520+
:return: Boolean
521+
522+
"""
523+
524+
if special.clip_command(text):
525+
query = (special.get_clip_query(text) or
526+
self.get_last_query())
527+
message = special.copy_query_to_clipboard(sql=query)
528+
if message:
529+
raise RuntimeError(message)
530+
return True
531+
return False
532+
515533
def run_cli(self):
516534
iterations = 0
517535
sqlexecute = self.sqlexecute
@@ -553,7 +571,9 @@ def get_message():
553571
return ANSI(prompt)
554572

555573
def get_continuation(width, *_):
556-
if self.multiline_continuation_char:
574+
if self.multiline_continuation_char == '':
575+
continuation = ''
576+
elif self.multiline_continuation_char:
557577
left_padding = width - len(self.multiline_continuation_char)
558578
continuation = " " * \
559579
max((left_padding - 1), 0) + \
@@ -582,6 +602,15 @@ def one_iteration(text=None):
582602
self.echo(str(e), err=True, fg='red')
583603
return
584604

605+
try:
606+
if self.handle_clip_command(text):
607+
return
608+
except RuntimeError as e:
609+
logger.error("sql: %r, error: %r", text, e)
610+
logger.error("traceback: %r", traceback.format_exc())
611+
self.echo(str(e), err=True, fg='red')
612+
return
613+
585614
if not text.strip():
586615
return
587616

@@ -656,6 +685,7 @@ def one_iteration(text=None):
656685
result_count += 1
657686
mutating = mutating or destroy or is_mutating(status)
658687
special.unset_once_if_written()
688+
special.unset_pipe_once_if_written()
659689
except EOFError as e:
660690
raise e
661691
except KeyboardInterrupt:
@@ -816,6 +846,7 @@ def output(self, output, status=None):
816846
self.log_output(line)
817847
special.write_tee(line)
818848
special.write_once(line)
849+
special.write_pipe_once(line)
819850

820851
if fits or output_via_pager:
821852
# buffering
@@ -1293,7 +1324,7 @@ def is_select(status):
12931324
def thanks_picker(files=()):
12941325
contents = []
12951326
for line in fileinput.input(files=files):
1296-
m = re.match('^ *\* (.*)', line)
1327+
m = re.match(r'^ *\* (.*)', line)
12971328
if m:
12981329
contents.append(m.group(1))
12991330
return choice(contents)

mycli/myclirc

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ table_format = ascii
4141
# friendly, monokai, paraiso, colorful, murphy, bw, pastie, paraiso, trac, default,
4242
# fruity.
4343
# Screenshots at http://mycli.net/syntax
44+
# Can be further modified in [colors]
4445
syntax_style = default
4546

4647
# Keybindings: Possible values: emacs, vi.
@@ -114,6 +115,35 @@ output.odd-row = ""
114115
output.even-row = ""
115116
output.null = "#808080"
116117

118+
# SQL syntax highlighting overrides
119+
# sql.comment = 'italic #408080'
120+
# sql.comment.multi-line = ''
121+
# sql.comment.single-line = ''
122+
# sql.comment.optimizer-hint = ''
123+
# sql.escape = 'border:#FF0000'
124+
# sql.keyword = 'bold #008000'
125+
# sql.datatype = 'nobold #B00040'
126+
# sql.literal = ''
127+
# sql.literal.date = ''
128+
# sql.symbol = ''
129+
# sql.quoted-schema-object = ''
130+
# sql.quoted-schema-object.escape = ''
131+
# sql.constant = '#880000'
132+
# sql.function = '#0000FF'
133+
# sql.variable = '#19177C'
134+
# sql.number = '#666666'
135+
# sql.number.binary = ''
136+
# sql.number.float = ''
137+
# sql.number.hex = ''
138+
# sql.number.integer = ''
139+
# sql.operator = '#666666'
140+
# sql.punctuation = ''
141+
# sql.string = '#BA2121'
142+
# sql.string.double-quouted = ''
143+
# sql.string.escape = 'bold #BB6622'
144+
# sql.string.single-quoted = ''
145+
# sql.whitespace = ''
146+
117147
# Favorite queries.
118148
[favorite_queries]
119149

mycli/packages/parseutils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# This matches everything except spaces, parens, colon, comma, and period
1212
'most_punctuations': re.compile(r'([^\.():,\s]+)$'),
1313
# This matches everything except a space.
14-
'all_punctuations': re.compile('([^\s]+)$'),
14+
'all_punctuations': re.compile(r'([^\s]+)$'),
1515
}
1616

1717
def last_word(text, include='alphanum_underscore'):

mycli/packages/special/iocommands.py

Lines changed: 103 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from time import sleep
99

1010
import click
11+
import pyperclip
1112
import sqlparse
1213

1314
from . import export
@@ -23,6 +24,8 @@
2324
tee_file = None
2425
once_file = None
2526
written_to_once_file = False
27+
pipe_once_process = None
28+
written_to_pipe_once_process = False
2629
delimiter_command = DelimiterCommand()
2730

2831

@@ -115,7 +118,7 @@ def get_editor_query(sql):
115118
# The reason we can't simply do .strip('\e') is that it strips characters,
116119
# not a substring. So it'll strip "e" in the end of the sql also!
117120
# Ex: "select * from style\e" -> "select * from styl".
118-
pattern = re.compile('(^\\\e|\\\e$)')
121+
pattern = re.compile(r'(^\\e|\\e$)')
119122
while pattern.search(sql):
120123
sql = pattern.sub('', sql)
121124

@@ -159,6 +162,47 @@ def open_external_editor(filename=None, sql=None):
159162
return (query, message)
160163

161164

165+
@export
166+
def clip_command(command):
167+
"""Is this a clip command?
168+
169+
:param command: string
170+
171+
"""
172+
# It is possible to have `\clip` or `SELECT * FROM \clip`. So we check
173+
# for both conditions.
174+
return command.strip().endswith('\\clip') or command.strip().startswith('\\clip')
175+
176+
177+
@export
178+
def get_clip_query(sql):
179+
"""Get the query part of a clip command."""
180+
sql = sql.strip()
181+
182+
# The reason we can't simply do .strip('\clip') is that it strips characters,
183+
# not a substring. So it'll strip "c" in the end of the sql also!
184+
pattern = re.compile('(^\\\clip|\\\clip$)')
185+
while pattern.search(sql):
186+
sql = pattern.sub('', sql)
187+
188+
return sql
189+
190+
191+
@export
192+
def copy_query_to_clipboard(sql=None):
193+
"""Send query to the clipboard."""
194+
195+
sql = sql or ''
196+
message = None
197+
198+
try:
199+
pyperclip.copy(u'{sql}'.format(sql=sql))
200+
except RuntimeError as e:
201+
message = 'Error clipping query: %s.' % e.strerror
202+
203+
return message
204+
205+
162206
@special_command('\\f', '\\f [name [args..]]', 'List or execute favorite queries.', arg_type=PARSED_QUERY, case_sensitive=True)
163207
def execute_favorite_query(cur, arg, **_):
164208
"""Returns (title, rows, headers, status)"""
@@ -337,7 +381,11 @@ def write_tee(output):
337381
def set_once(arg, **_):
338382
global once_file, written_to_once_file
339383

340-
once_file = parseargfile(arg)
384+
try:
385+
once_file = open(**parseargfile(arg))
386+
except (IOError, OSError) as e:
387+
raise OSError("Cannot write to file '{}': {}".format(
388+
e.filename, e.strerror))
341389
written_to_once_file = False
342390

343391
return [(None, None, None, "")]
@@ -347,26 +395,68 @@ def set_once(arg, **_):
347395
def write_once(output):
348396
global once_file, written_to_once_file
349397
if output and once_file:
350-
try:
351-
f = open(**once_file)
352-
except (IOError, OSError) as e:
353-
once_file = None
354-
raise OSError("Cannot write to file '{}': {}".format(
355-
e.filename, e.strerror))
356-
with f:
357-
click.echo(output, file=f, nl=False)
358-
click.echo(u"\n", file=f, nl=False)
398+
click.echo(output, file=once_file, nl=False)
399+
click.echo(u"\n", file=once_file, nl=False)
400+
once_file.flush()
359401
written_to_once_file = True
360402

361403

362404
@export
363405
def unset_once_if_written():
364406
"""Unset the once file, if it has been written to."""
365-
global once_file
366-
if written_to_once_file:
407+
global once_file, written_to_once_file
408+
if written_to_once_file and once_file:
409+
once_file.close()
367410
once_file = None
368411

369412

413+
@special_command('\\pipe_once', '\\| command',
414+
'Send next result to a subprocess.',
415+
aliases=('\\|', ))
416+
def set_pipe_once(arg, **_):
417+
global pipe_once_process, written_to_pipe_once_process
418+
pipe_once_cmd = shlex.split(arg)
419+
if len(pipe_once_cmd) == 0:
420+
raise OSError("pipe_once requires a command")
421+
written_to_pipe_once_process = False
422+
pipe_once_process = subprocess.Popen(pipe_once_cmd,
423+
stdin=subprocess.PIPE,
424+
stdout=subprocess.PIPE,
425+
stderr=subprocess.PIPE,
426+
bufsize=1,
427+
encoding='UTF-8',
428+
universal_newlines=True)
429+
return [(None, None, None, "")]
430+
431+
432+
@export
433+
def write_pipe_once(output):
434+
global pipe_once_process, written_to_pipe_once_process
435+
if output and pipe_once_process:
436+
try:
437+
click.echo(output, file=pipe_once_process.stdin, nl=False)
438+
click.echo(u"\n", file=pipe_once_process.stdin, nl=False)
439+
except (IOError, OSError) as e:
440+
pipe_once_process.terminate()
441+
raise OSError(
442+
"Failed writing to pipe_once subprocess: {}".format(e.strerror))
443+
written_to_pipe_once_process = True
444+
445+
446+
@export
447+
def unset_pipe_once_if_written():
448+
"""Unset the pipe_once cmd, if it has been written to."""
449+
global pipe_once_process, written_to_pipe_once_process
450+
if written_to_pipe_once_process:
451+
(stdout_data, stderr_data) = pipe_once_process.communicate()
452+
if len(stdout_data) > 0:
453+
print(stdout_data.rstrip(u"\n"))
454+
if len(stderr_data) > 0:
455+
print(stderr_data.rstrip(u"\n"))
456+
pipe_once_process = None
457+
written_to_pipe_once_process = False
458+
459+
370460
@special_command(
371461
'watch',
372462
'watch [seconds] [-c] query',

mycli/packages/special/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ def quit(*_args):
112112

113113
@special_command('\\e', '\\e', 'Edit command with editor (uses $EDITOR).',
114114
arg_type=NO_QUERY, case_sensitive=True)
115+
@special_command('\\clip', '\\clip', 'Copy query to the system clipboard.',
116+
arg_type=NO_QUERY, case_sensitive=True)
115117
@special_command('\\G', '\\G', 'Display current query results vertically.',
116118
arg_type=NO_QUERY, case_sensitive=True)
117119
def stub():

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
'configobj >= 5.0.5',
2626
'cryptography >= 1.0.0',
2727
'cli_helpers[styles] >= 2.0.1',
28+
'pyperclip >= 1.8.1'
2829
]
2930

3031

0 commit comments

Comments
 (0)