Skip to content

Commit 8e83b22

Browse files
authored
Merge pull request #2038 from dbcli/RW/spaces-in-shell-csv-redirect-target
Let the file target of `$>` redirection be quoted
2 parents 8e9ac2a + 259d7e1 commit 8e83b22

4 files changed

Lines changed: 70 additions & 8 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Upcoming (TBD)
44
Features
55
---------
66
* Subcommand completions for the `/dsn` command.
7+
* Allow file target of `$>` redirection to be quoted.
78

89

910
Bug Fixes

mycli/TIPS

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,8 @@ customize password sources/precedence with "password_sources" in ~/.myclirc!
296296

297297
redirect query output to a shell command with "$| <command>"!
298298

299-
redirect query output to a file with "$> <filename>"!
299+
redirect query output to a CSV file with "$> <filename>"!
300300

301-
append query output to a file with "$>> <filename>"!
301+
append query output to a CSV file with "$>> <filename>"!
302302

303303
run a command after shell redirects with "post_redirect_command" in ~/.myclirc!

mycli/packages/hybrid_redirection.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import functools
22
import logging
3+
import shlex
34

45
import sqlglot
56

@@ -113,9 +114,6 @@ def invalid_shell_part(
113114
file_part: str | None,
114115
command_part: str | None,
115116
) -> bool:
116-
if file_part and ' ' in file_part:
117-
return True
118-
119117
if file_part and '>' in file_part:
120118
return True
121119

@@ -125,6 +123,19 @@ def invalid_shell_part(
125123
return False
126124

127125

126+
def parse_redirect_filename(file_part: str | None) -> str | None:
127+
"""Return one unquoted redirect filename, or None for an invalid operand."""
128+
if file_part is None or not file_part:
129+
return None
130+
if file_part[0] not in ('\'', '"'):
131+
return file_part if not any(character.isspace() for character in file_part) else None
132+
try:
133+
parts = shlex.split(file_part)
134+
except ValueError:
135+
return None
136+
return parts[0] if len(parts) == 1 else None
137+
138+
128139
# todo there are still corner cases combining custom delimiters, caching, and redirection
129140
@functools.lru_cache(maxsize=1)
130141
def get_redirect_components(command: str) -> tuple[str | None, str | None, str | None, str | None]:
@@ -174,7 +185,9 @@ def get_redirect_components(command: str) -> tuple[str | None, str | None, str |
174185
)
175186

176187
if file_part_tokens:
177-
file_part = assemble_tokens(file_part_tokens)
188+
file_part = parse_redirect_filename(assemble_tokens(file_part_tokens))
189+
if file_part is None:
190+
return None, None, None, None
178191
else:
179192
file_part = None
180193

test/pytests/test_hybrid_redirection.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ def test_find_token_indices_tracks_true_dollars_and_operators() -> None:
3030
}
3131

3232

33+
def test_find_token_indices_ignores_non_redirect_dollars() -> None:
34+
tokens = tokenize('select 1 $ $> output.txt')
35+
36+
assert hybrid_redirection.find_token_indices(tokens) == {
37+
'raw_dollar': [2, 3],
38+
'true_dollar': [3],
39+
'angle_bracket': [4],
40+
'pipe': [],
41+
}
42+
43+
3344
# todo there are still corner cases combining custom delimiters and redirection
3445
def test_find_sql_part_handles_valid_parse_custom_delimiter_and_invalid_sql(reset_hybrid_redirection) -> None:
3546
hybrid_redirection.delimiter_command._delimiter = '$$'
@@ -74,10 +85,27 @@ def test_assemble_tokens_quotes_identifier_and_string() -> None:
7485
assert hybrid_redirection.assemble_tokens(string_tokens) == "'printf'"
7586

7687

88+
@pytest.mark.parametrize(
89+
('file_part', 'expected'),
90+
[
91+
(None, None),
92+
('out.txt', 'out.txt'),
93+
(r'C:\Users\alice\output.csv', r'C:\Users\alice\output.csv'),
94+
("'two words.txt'", 'two words.txt'),
95+
('"two words.txt"', 'two words.txt'),
96+
('two words.txt', None),
97+
("'missing quote", None),
98+
('', None),
99+
],
100+
)
101+
def test_parse_redirect_filename(file_part: str | None, expected: str | None) -> None:
102+
assert hybrid_redirection.parse_redirect_filename(file_part) == expected
103+
104+
77105
@pytest.mark.parametrize(
78106
('file_part', 'command_part', 'expected'),
79107
[
80-
('two words.txt', None, True),
108+
('two words.txt', None, False),
81109
('bad>file.txt', None, True),
82110
(None, None, True),
83111
('out.txt', None, False),
@@ -101,6 +129,24 @@ def test_get_redirect_components_valid_paths_and_logging() -> None:
101129
'>',
102130
'out.txt',
103131
)
132+
assert hybrid_redirection.get_redirect_components('select 1 $> "two words.txt"') == (
133+
'select 1',
134+
None,
135+
'>',
136+
'two words.txt',
137+
)
138+
assert hybrid_redirection.get_redirect_components("select 1 $>> 'two words.txt'") == (
139+
'select 1',
140+
None,
141+
'>>',
142+
'two words.txt',
143+
)
144+
assert hybrid_redirection.get_redirect_components(r'select 1 $> C:\Users\alice\output.csv') == (
145+
'select 1',
146+
None,
147+
'>',
148+
r'C:\Users\alice\output.csv',
149+
)
104150

105151

106152
def test_get_redirect_components_returns_none_on_token_error(monkeypatch) -> None:
@@ -116,7 +162,9 @@ def test_get_redirect_components_rejects_invalid_forms() -> None:
116162
assert hybrid_redirection.get_redirect_components('select 1 $> out.txt $> other.txt') == (None, None, None, None)
117163
assert hybrid_redirection.get_redirect_components('select 1 $> out.txt $| cat') == (None, None, None, None)
118164
assert hybrid_redirection.get_redirect_components('select from $> out.txt') == (None, None, None, None)
119-
assert hybrid_redirection.get_redirect_components('select 1 $> "two words.txt"') == (None, None, None, None)
165+
assert hybrid_redirection.get_redirect_components('select 1 $> two words.txt') == (None, None, None, None)
166+
assert hybrid_redirection.get_redirect_components("select 1 $> 'missing quote") == (None, None, None, None)
167+
assert hybrid_redirection.get_redirect_components('select 1 $> "bad>file.txt"') == (None, None, None, None)
120168

121169

122170
def test_get_redirect_components_rejects_multiple_pipes_on_windows(monkeypatch) -> None:

0 commit comments

Comments
 (0)