Skip to content

Commit 46faaee

Browse files
authored
Merge branch 'master' into sql-syntax-highlighting
2 parents fb32555 + f9a4d94 commit 46faaee

File tree

8 files changed

+167
-21
lines changed

8 files changed

+167
-21
lines changed

changelog.md

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

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

1622
1.22.2
1723
======

mycli/main.py

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

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

553571
def get_continuation(width, *_):
554-
if self.multiline_continuation_char:
572+
if self.multiline_continuation_char == '':
573+
continuation = ''
574+
elif self.multiline_continuation_char:
555575
left_padding = width - len(self.multiline_continuation_char)
556576
continuation = " " * \
557577
max((left_padding - 1), 0) + \
@@ -580,6 +600,15 @@ def one_iteration(text=None):
580600
self.echo(str(e), err=True, fg='red')
581601
return
582602

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

@@ -654,6 +683,7 @@ def one_iteration(text=None):
654683
result_count += 1
655684
mutating = mutating or destroy or is_mutating(status)
656685
special.unset_once_if_written()
686+
special.unset_pipe_once_if_written()
657687
except EOFError as e:
658688
raise e
659689
except KeyboardInterrupt:
@@ -814,6 +844,7 @@ def output(self, output, status=None):
814844
self.log_output(line)
815845
special.write_tee(line)
816846
special.write_once(line)
847+
special.write_pipe_once(line)
817848

818849
if fits or output_via_pager:
819850
# buffering
@@ -1291,7 +1322,7 @@ def is_select(status):
12911322
def thanks_picker(files=()):
12921323
contents = []
12931324
for line in fileinput.input(files=files):
1294-
m = re.match('^ *\* (.*)', line)
1325+
m = re.match(r'^ *\* (.*)', line)
12951326
if m:
12961327
contents.append(m.group(1))
12971328
return choice(contents)

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

test/features/fixture_data/help_commands.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
| Command | Shortcut | Description |
33
+-------------+----------------------------+------------------------------------------------------------+
44
| \G | \G | Display current query results vertically. |
5+
| \clip | \clip | Copy query to the system clipboard. |
56
| \dt | \dt[+] [table] | List or describe tables. |
67
| \e | \e | Edit command with editor (uses $EDITOR). |
78
| \f | \f [name [args..]] | List or execute favorite queries. |
89
| \fd | \fd [name] | Delete a favorite query. |
910
| \fs | \fs name query | Save a favorite query. |
1011
| \l | \l | List databases. |
1112
| \once | \o [-o] filename | Append next result to an output file (overwrite using -o). |
13+
| \pipe_once | \| command | Send next result to a subprocess. |
1214
| \timing | \t | Toggle timing of commands. |
1315
| connect | \r | Reconnect to the database. Optional database argument. |
1416
| exit | \q | Exit. |

test/test_special_iocommands.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,8 @@ def test_once_command():
9393
with pytest.raises(TypeError):
9494
mycli.packages.special.execute(None, u"\\once")
9595

96-
mycli.packages.special.execute(None, u"\\once /proc/access-denied")
9796
with pytest.raises(OSError):
98-
mycli.packages.special.write_once(u"hello world")
97+
mycli.packages.special.execute(None, u"\\once /proc/access-denied")
9998

10099
mycli.packages.special.write_once(u"hello world") # write without file set
101100
with tempfile.NamedTemporaryFile() as f:
@@ -104,9 +103,24 @@ def test_once_command():
104103
assert f.read() == b"hello world\n"
105104

106105
mycli.packages.special.execute(None, u"\\once -o " + f.name)
107-
mycli.packages.special.write_once(u"hello world")
106+
mycli.packages.special.write_once(u"hello world line 1")
107+
mycli.packages.special.write_once(u"hello world line 2")
108108
f.seek(0)
109-
assert f.read() == b"hello world\n"
109+
assert f.read() == b"hello world line 1\nhello world line 2\n"
110+
111+
112+
def test_pipe_once_command():
113+
with pytest.raises(IOError):
114+
mycli.packages.special.execute(None, u"\\pipe_once")
115+
116+
with pytest.raises(OSError):
117+
mycli.packages.special.execute(
118+
None, u"\\pipe_once /proc/access-denied")
119+
120+
mycli.packages.special.execute(None, u"\\pipe_once wc")
121+
mycli.packages.special.write_once(u"hello world")
122+
mycli.packages.special.unset_pipe_once_if_written()
123+
# how to assert on wc output?
110124

111125

112126
def test_parseargfile():

0 commit comments

Comments
 (0)