Skip to content

Commit e73293a

Browse files
sfc-gh-pcichonMirror Bot
authored andcommitted
feat: [SNOW-4138413] add snow sql !spool
### Pre-review checklist * [x] If my changes add or modify user-facing interface (commands, flags, output formats), I consulted maintainers and got sign-off beforehand ([how](https://github.com/snowflakedb/snowflake-cli/blob/main/docs/contributing/adding-commands.md#design-sign-off-before-writing-code)). * [x] I've confirmed that instructions included in README.md are still correct after my changes in the codebase. * [x] I've added or updated unit tests to verify correctness of my new code. * [x] I've added or updated integration tests to verify correctness of my new code. * [x] Manually tested on macOS * [ ] Manually tested on Windows * [x] I've confirmed my changes are up-to-date with the target branch. * [x] I've described my changes in `RELEASE-NOTES.md` (see [when and how](https://github.com/snowflakedb/snowflake-cli/blob/main/docs/contributing/lifecycle.md#release-notes-format)). * [x] I've updated documentation if behavior changed. ### Changes description Adds SnowSQL-compatible `!spool <file>` / `!spool off` ([SNOW-4138413](https://snowflakecomputing.atlassian.net/browse/SNOW-4138413)). `!spool` opens the path for append (UTF-8, `~` expanded). Later `print_result` output is written to that file **and** still printed on the terminal until `!spool off`. Format follows `--format` / `!set output_format`. Works in the REPL, `-q`, and `-f`. The whole remainder is one path (spaces and `=` allowed). `off` is a case-insensitive keyword, so a file named `off` cannot be spooled. Success is silent. `!spool off` when already off is a no-op. The file is the formatted query result only (TABLE / PLAIN / JSON / CSV). Prompts, `!` commands, SQL text, and `Time Elapsed` stay on the terminal. A new spool file is owner-only (`0600`). Appending to an existing file leaves its mode unchanged and warns if others can read or write it. On Windows the same warning uses `file_permissions_are_strict()`. A missing `whoami`/`icacls` does not abort `!spool`. The path is sanitized before the warning and the unwritable-path error. Until `!spool off`, TABLE is a static pipe table on both sinks (no Live refresh, no wrapping to the window). #### SnowSQL 1.5.2 vs `snow sql` Replicated from SnowSQL `!spool`: | | SnowSQL 1.5.2 | This PR | | --- | --- | --- | | What is in the file | Formatted query result only | Same (`print_result` only) | | Still printed on the terminal | Yes | Yes | | Prompts, SQL, `!` commands, timing / row counts in the file | No (default) | No | | Append, not overwrite | Yes | Yes | | REPL, `-q`, `-f` | Yes | Yes | Intentionally different: | | SnowSQL 1.5.2 | This PR | | --- | --- | --- | | New file mode | Usually `0644` (umask) | `0600` | | Existing file broader than owner-only | Silent | Warn; mode unchanged | | TABLE on screen while spooling | Always static tabulate | Static pipe table so it matches the file (CLI otherwise uses Live / wrap on a TTY) | | Format names | Tabulate (`psql`, `csv`, …) | `--format` / `!set` (`table`, `plain`, `json`, …) | | No-args / unwritable path | `The file is not writeable:` | CLI usage / `Cannot write spool file` | | `output_file`, `timing_in_output_file`, `!print` | Exists | Out of scope | #### Errors vs SnowSQL 1.5.2 | Case | SnowSQL 1.5.2 | This PR | | --- | --- | --- | | no args | `The file is not writeable:` (empty path) | `No arguments passed to 'spool' command. Usage: !spool <file> or !spool off` | | unwritable path | `The file is not writeable: <path>` | `Cannot write spool file '<path>': <OS error>` | | `off` when already off | silent | silent | #### Why tee through `print_result`, not `sys.stdout =` Query results are consumed once. Tee at write time via `_result_stream()` to `TeeTextIO` so JSON, CSV, TABLE, and PLAIN all hit both sinks without rendering twice. Do not assign `sys.stdout`. Rich caches `is_terminal` on the stream it first saw (`_stdout_is_terminal` / GH#2725). While spooling, both sinks use the non-TTY layout so the file does not pick up ANSI / Live. #### Single-SELECT print order For one SELECT, Typer prints after `execute_sql` returns. Draining the statement generator first would run `!spool off` before `print_result`, so the file would be empty. The one-SELECT path prints `QueryResult` from `next()`, drains the rest (including `!spool off`), then returns `EmptyResult()`. A trailing `!set` after a single SELECT does not rewrite that result. #### Why not public [PR #2721](#2721) REPL-only; truncates; `sys.stdout =`; extra `-- Query:` banner. Wrong contract for this ticket. Out of scope: SnowSQL `output_file` / `timing_in_output_file`, overwrite flag, spooling prompts or Time Elapsed. ## Test plan - [x] Unit: spool create `0600`; warn on existing `0644` / `0640` / `0666` / `0622`; no warn on existing `0600`; trailing `!set` after a single SELECT stays TABLE - [x] Unit: Windows owner-only warning (not skipped); `CliError` from the permission check does not abort; ANSI stripped from the warning and `Cannot write spool file` - [x] Integration: `tests_integration/sql/test_snowsql_commands.py` spool cases - [x] Manual, macOS, PR worktree `hatch run snow sql`: `select repeat('x', 400)` wraps; after `!spool` the same query is one long line on screen and in the file; after `!spool off` wrap returns - [x] Manual, SnowSQL 1.5.2: REPL `!spool /tmp/snowsql-spool-contents.txt` then `select 2 as n;` — file is only the table. `-q` with newlines (`!spool` / `select 4` / `!spool off`) — file is only the table. `!spool path; select …` on one line is one `!spool` and creates no file [SNOW-4138413]: https://snowflakecomputing.atlassian.net/browse/SNOW-4138413?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ GitOrigin-RevId: 69c2c2fd10802836d5c4d41f1b5e7967e4dd5532
1 parent 8e18ec1 commit e73293a

11 files changed

Lines changed: 760 additions & 45 deletions

File tree

‎RELEASE-NOTES.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
## New additions
2323
* Streamlit entities support `include_project_definition: true` to opt in to uploading the project-root `snowflake.yml` with `snow streamlit deploy`. When that file is uploaded, deploy emits a warning because stage readers can retrieve it.
2424
* `snow upgrade` is now generally available. It upgrades a direct install of Snowflake CLI and is listed in `snow --help` without a feature flag. Other install channels refuse and print the direct-install command.
25+
* `snow sql` accepts `!spool <file>` and `!spool off`. Subsequent query results are appended to the file and still printed on the terminal. Until `!spool off`, TABLE is a static pipe table on both the terminal and the file: no Live refresh and no wrapping to the window. Format follows `--format` / `!set output_format`.
2526

2627
## Fixes and improvements
2728
* `snow streamlit deploy --replace` now applies `main_file` changes to existing versioned apps in place, preserving the app, grants and version history. Files are uploaded before the entrypoint changes; `--prune` runs only after the change succeeds. Accounts that reject the update receive an actionable error.

‎src/snowflake/cli/_app/ascii_table.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,13 +266,16 @@ def __init__(
266266
bold_header: bool,
267267
flush_chunks: bool,
268268
plain: bool = False,
269+
stream: object | None = None,
269270
) -> None:
270271
self._columns = columns
271272
self._widths = widths
272273
self._wrap = wrap
273274
self._bold_header = bold_header
274275
self._plain = plain
275-
self._out = _ChunkWriter(sys.stdout, flush_chunks=flush_chunks)
276+
self._out = _ChunkWriter(
277+
stream if stream is not None else sys.stdout, flush_chunks=flush_chunks
278+
)
276279

277280
def write_table(
278281
self,
@@ -378,6 +381,7 @@ def stream_probed_table(
378381
is_tty: bool,
379382
on_success: Callable[[], None],
380383
plain: bool = False,
384+
stream: object | None = None,
381385
) -> None:
382386
preferred = preferred_widths(probed.columns, probed.rows)
383387
if is_tty:
@@ -394,6 +398,7 @@ def stream_probed_table(
394398
bold_header=bold_header,
395399
flush_chunks=is_tty,
396400
plain=plain,
401+
stream=stream,
397402
)
398403
writer.write_table(probed.rows, probed.pending, probed.rest, on_success)
399404

‎src/snowflake/cli/_app/printing.py‎

Lines changed: 116 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,77 @@
4949

5050
NO_ITEMS_FOUND: str = "No data"
5151

52+
# Rich falls back to width=80 when stdout is not a terminal. With many columns
53+
# that squeezes each cell below readability, producing a series of "|"
54+
# separators with no content (see GH#2725). Use an effectively unlimited width
55+
# for non-terminal destinations so Rich picks each column's natural width.
56+
_NON_TERMINAL_RENDER_WIDTH = 1_000_000
57+
5258
# ensure we do not break URLs that wrap lines
5359
get_console().soft_wrap = True
5460

5561
# Disable markup to avoid escaping errors
5662
get_console()._markup = False # noqa: SLF001
5763

5864

65+
class TeeTextIO:
66+
"""Write the same text to stdout and an open spool file. Never a TTY."""
67+
68+
def __init__(self, primary: TextIO, secondary: TextIO) -> None:
69+
self._primary = primary
70+
self._secondary = secondary
71+
72+
def write(self, s: str) -> int:
73+
written = self._primary.write(s)
74+
self._secondary.write(s)
75+
return written
76+
77+
def flush(self) -> None:
78+
self._primary.flush()
79+
self._secondary.flush()
80+
81+
def isatty(self) -> bool:
82+
return False
83+
84+
@property
85+
def encoding(self) -> str:
86+
return getattr(self._primary, "encoding", None) or "utf-8"
87+
88+
@property
89+
def errors(self) -> str | None:
90+
return getattr(self._primary, "errors", None)
91+
92+
93+
def _result_stream() -> TextIO | TeeTextIO:
94+
spool = get_cli_context().spool_stream
95+
if spool is None:
96+
return sys.stdout
97+
return TeeTextIO(sys.stdout, spool)
98+
99+
100+
def _print(*args, **kwargs) -> None:
101+
kwargs.setdefault("file", _result_stream())
102+
print(*args, **kwargs)
103+
104+
105+
def _rich_print(*args, **kwargs) -> None:
106+
stream = _result_stream()
107+
if stream is sys.stdout:
108+
rich_print(*args, **kwargs)
109+
return
110+
flush = kwargs.pop("flush", False)
111+
Console(
112+
file=stream,
113+
force_terminal=False,
114+
markup=False,
115+
soft_wrap=True,
116+
width=_NON_TERMINAL_RENDER_WIDTH,
117+
height=_NON_TERMINAL_RENDER_WIDTH,
118+
).print(*args, **kwargs)
119+
if flush:
120+
stream.flush()
121+
122+
59123
class CustomJSONEncoder(JSONEncoder):
60124
"""Custom JSON encoder handling serialization of non-standard types"""
61125

@@ -107,11 +171,16 @@ def _print_json_item_with_array_indentation(item: Any, indent: int):
107171
indented_lines = indented_output.split("\n")
108172
for i, line in enumerate(indented_lines):
109173
if i == 0:
110-
print(" " * indent + line, end="")
174+
_print(" " * indent + line, end="")
111175
else:
112-
print("\n" + " " * indent + line, end="")
176+
_print("\n" + " " * indent + line, end="")
113177
else:
114-
json.dump(item, sys.stdout, cls=StreamingJSONEncoder, separators=(",", ":"))
178+
json.dump(
179+
item,
180+
_result_stream(),
181+
cls=StreamingJSONEncoder,
182+
separators=(",", ":"),
183+
)
115184

116185

117186
def _stream_collection_as_json(result: CollectionResult, indent: int = 4):
@@ -120,18 +189,18 @@ def _stream_collection_as_json(result: CollectionResult, indent: int = 4):
120189
try:
121190
first_item = next(items)
122191
except StopIteration:
123-
print("[]", end="")
192+
_print("[]", end="")
124193
return
125194

126-
print("[")
195+
_print("[")
127196

128197
_print_json_item_with_array_indentation(first_item, indent)
129198

130199
for item in items:
131-
print(",")
200+
_print(",")
132201
_print_json_item_with_array_indentation(item, indent)
133202

134-
print("\n]", end="")
203+
_print("\n]", end="")
135204

136205

137206
def _stream_collection_as_csv(result: CollectionResult):
@@ -146,7 +215,9 @@ def _stream_collection_as_csv(result: CollectionResult):
146215
if not isinstance(first_item, dict):
147216
raise TypeError("CSV output requires dictionary items")
148217

149-
writer = csv.DictWriter(sys.stdout, fieldnames=fieldnames, lineterminator="\n")
218+
writer = csv.DictWriter(
219+
_result_stream(), fieldnames=fieldnames, lineterminator="\n"
220+
)
150221
writer.writeheader()
151222
_write_csv_row(writer, first_item)
152223

@@ -194,16 +265,13 @@ def _get_table(output_format: OutputFormat | None = None):
194265
return Table(show_header=True, box=box.ASCII)
195266

196267

197-
# Rich falls back to width=80 when stdout is not a terminal. With many columns
198-
# that squeezes each cell below readability, producing a series of "|"
199-
# separators with no content (see GH#2725). Use an effectively unlimited width
200-
# for non-terminal destinations so Rich picks each column's natural width.
201-
_NON_TERMINAL_RENDER_WIDTH = 1_000_000
202-
203-
204268
def _stdout_is_terminal() -> bool:
205269
# Current stdout, not get_console().is_terminal: Rich caches the latter on
206270
# the stream it first saw, and FORCE_COLOR (even "0") makes that True.
271+
# While spooling, both sinks get the non-TTY layout so the file does not
272+
# pick up Rich ANSI / Live (see GH#2725).
273+
if get_cli_context().spool_stream is not None:
274+
return False
207275
isatty = getattr(sys.stdout, "isatty", None)
208276
try:
209277
return False if isatty is None else bool(isatty())
@@ -213,9 +281,11 @@ def _stdout_is_terminal() -> bool:
213281

214282

215283
def _render_console_for_table() -> Console:
284+
stream = _result_stream()
216285
if _stdout_is_terminal():
217286
default_console = get_console()
218287
return Console(
288+
file=stream,
219289
width=default_console.width,
220290
height=default_console.height or 25,
221291
soft_wrap=True,
@@ -227,6 +297,7 @@ def _render_console_for_table() -> Console:
227297
# size falls back to 80x25 and many-column tables collapse. Both dimensions
228298
# are required for the unlimited width to actually apply (GH#2725).
229299
return Console(
300+
file=stream,
230301
width=_NON_TERMINAL_RENDER_WIDTH,
231302
height=_NON_TERMINAL_RENDER_WIDTH,
232303
soft_wrap=True,
@@ -242,7 +313,7 @@ def _print_multiple_table_results(
242313
try:
243314
first_item = next(items)
244315
except StopIteration:
245-
rich_print(NO_ITEMS_FOUND, end="\n\n")
316+
_rich_print(NO_ITEMS_FOUND, end="\n\n")
246317
return
247318
probed = probe_rows(first_item, items)
248319
if probed.use_rich:
@@ -257,15 +328,16 @@ def _trailer() -> None:
257328
sys.stdout.flush()
258329
return
259330
if console.is_terminal:
260-
sys.stdout.write("\n")
261-
rich_print(flush=True)
331+
_result_stream().write("\n")
332+
_rich_print(flush=True)
262333

263334
stream_probed_table(
264335
probed,
265336
console=console,
266337
is_tty=is_tty,
267338
on_success=_trailer,
268339
plain=_is_plain_format(output_format),
340+
stream=_result_stream(),
269341
)
270342

271343

@@ -281,7 +353,7 @@ def _print_rich_table(
281353
with Live(table, console=console, refresh_per_second=4):
282354
for row in rows:
283355
table.add_row(*row)
284-
rich_print(flush=True)
356+
_rich_print(flush=True)
285357

286358

287359
def __to_str(val):
@@ -304,7 +376,7 @@ def print_structured(
304376
if output_format == OutputFormat.CSV:
305377
for command_result in result.result:
306378
_print_csv_result_streaming(command_result)
307-
print(flush=True)
379+
_print(flush=True)
308380
printed_end_line = True
309381
else:
310382
_stream_json(result)
@@ -315,8 +387,8 @@ def print_structured(
315387
if output_format == OutputFormat.CSV:
316388
_print_csv_result_streaming(r)
317389
else:
318-
json.dump(r, sys.stdout, cls=StreamingJSONEncoder)
319-
print(flush=True)
390+
json.dump(r, _result_stream(), cls=StreamingJSONEncoder)
391+
_print(flush=True)
320392
printed_end_line = True
321393
else:
322394
if output_format == OutputFormat.CSV:
@@ -327,17 +399,17 @@ def print_structured(
327399

328400
# Adds empty line at the end
329401
if not printed_end_line:
330-
print(flush=True)
402+
_print(flush=True)
331403

332404

333405
def _print_json_result_streaming(result: CommandResult):
334406
"""Print a single CommandResult as JSON with streaming support"""
335407
if isinstance(result, CollectionResult):
336408
_stream_collection_as_json(result, indent=4)
337409
elif isinstance(result, (ObjectResult, MessageResult)):
338-
json.dump(result, sys.stdout, cls=StreamingJSONEncoder, indent=4)
410+
json.dump(result, _result_stream(), cls=StreamingJSONEncoder, indent=4)
339411
else:
340-
json.dump(result, sys.stdout, cls=StreamingJSONEncoder, indent=4)
412+
json.dump(result, _result_stream(), cls=StreamingJSONEncoder, indent=4)
341413

342414

343415
def _print_object_result_as_csv(result: ObjectResult):
@@ -349,7 +421,7 @@ def _print_object_result_as_csv(result: ObjectResult):
349421
data = result.result
350422
if isinstance(data, dict):
351423
writer = csv.DictWriter(
352-
sys.stdout, fieldnames=list(data.keys()), lineterminator="\n"
424+
_result_stream(), fieldnames=list(data.keys()), lineterminator="\n"
353425
)
354426
writer.writeheader()
355427
_write_csv_row(writer, data)
@@ -361,7 +433,9 @@ def _print_message_result_as_csv(result: MessageResult):
361433
Creates a simple CSV structure with one column named 'message'
362434
containing the sanitized message text.
363435
"""
364-
writer = csv.DictWriter(sys.stdout, fieldnames=["message"], lineterminator="\n")
436+
writer = csv.DictWriter(
437+
_result_stream(), fieldnames=["message"], lineterminator="\n"
438+
)
365439
writer.writeheader()
366440
writer.writerow({"message": sanitize_for_terminal(result.message)})
367441

@@ -381,32 +455,37 @@ def _stream_json(result):
381455
indent_size = 2
382456

383457
class _Indented:
384-
def __init__(self, stream: TextIO):
458+
def __init__(self, stream: TextIO | TeeTextIO):
385459
self._stream = stream
386460

387461
def write(self, text: str):
388462
return self._stream.write(indent(text, " " * indent_size))
389463

390-
print("[")
464+
_print("[")
391465
results = result.result
392466
res = next(results, None)
393467
while res:
394-
json.dump(res, _Indented(sys.stdout), cls=CustomJSONEncoder, indent=indent_size) # type: ignore
468+
json.dump(
469+
res,
470+
_Indented(_result_stream()),
471+
cls=CustomJSONEncoder,
472+
indent=indent_size,
473+
) # type: ignore
395474
if res := next(results, None):
396-
print(",")
397-
print("\n]")
475+
_print(",")
476+
_print("\n]")
398477

399478

400479
def print_unstructured(
401480
obj: CommandResult | None, output_format: OutputFormat | None = None
402481
):
403482
"""Handles outputs like table, plain text and other unstructured types."""
404483
if not obj:
405-
rich_print("Done", flush=True)
484+
_rich_print("Done", flush=True)
406485
elif not obj.result:
407-
rich_print("No data", flush=True)
486+
_rich_print("No data", flush=True)
408487
elif isinstance(obj, MessageResult):
409-
rich_print(sanitize_for_terminal(obj.message), flush=True)
488+
_rich_print(sanitize_for_terminal(obj.message), flush=True)
410489
else:
411490
if isinstance(obj, ObjectResult):
412491
_print_single_table(obj, output_format)
@@ -424,7 +503,7 @@ def _print_single_table(obj, output_format: OutputFormat | None = None):
424503
table.add_row(
425504
sanitize_for_terminal(str(key)), sanitize_for_terminal(__to_str(value))
426505
)
427-
rich_print(table, flush=True)
506+
_rich_print(table, flush=True)
428507

429508

430509
def print_result(cmd_result: CommandResult, output_format: OutputFormat | None = None):
@@ -442,3 +521,4 @@ def print_result(cmd_result: CommandResult, output_format: OutputFormat | None =
442521
print_unstructured(cmd_result, resolved_format)
443522
case _:
444523
raise ValueError(f"Unexpected type {type(cmd_result)}")
524+
_result_stream().flush()

‎src/snowflake/cli/_plugins/sql/commands.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import typer
2424
from click import UsageError
25+
from snowflake.cli._app.printing import print_result
2526
from snowflake.cli._plugins.sql.client_query_span import sql_client_query_span
2627
from snowflake.cli._plugins.sql.manager import SqlManager
2728
from snowflake.cli._plugins.sql.prompt_format import (
@@ -400,8 +401,12 @@ def execute_sql(
400401
list(cursors)
401402
return EmptyResult()
402403
if expected_results_cnt == 1:
403-
results = list(cursors)
404-
if not results:
404+
cursors = iter(cursors)
405+
try:
406+
first = next(cursors)
407+
except StopIteration:
405408
return EmptyResult()
406-
return QueryResult(results[0])
409+
print_result(QueryResult(first))
410+
list(cursors)
411+
return EmptyResult()
407412
return MultipleResults((QueryResult(c) for c in cursors))

0 commit comments

Comments
 (0)