Skip to content

Commit

Permalink
SNOW-1011772: Merge from main
Browse files Browse the repository at this point in the history
  • Loading branch information
sfc-gh-davwang committed Feb 7, 2024
2 parents bf46e0a + 43a45b5 commit c687233
Show file tree
Hide file tree
Showing 22 changed files with 533 additions and 42 deletions.
70 changes: 70 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,76 @@ git checkout <your-branch>
git rebase sfcli/main
```

## Presenting intermediate output to users

Snowflake CLI enables users to interact with the Snowflake ecosystem using command line. Some commands provide immediate results, while others require some amount of operations to be executed before the result can be presented to the user.

Presenting intermediate output to the user during execution of complex commands can improve users' experience.

Since snowflake-cli is preparing to support additional commands via plugins, it is the right time to introduce a unified mechanism for displaying intermediate output. This will help keep consistent output among cli and plugins. There is no way to restrain usage of any kind of output in plugins developed in external repositories, but providing api may discourage others from introducing custom output.

The proposal is to introduce cli_console object that will provide following helper methods to interact with the output:
step - a method for printing regular output
warning - a method for printing messages that should be
phase - a context manager that will group all output within its scope as distinct unit

Implemented CliConsole class must respect parameter `–silent` and disable any output when requested.

Context manager must allow only one grouping level at a time. All subsequent invocations will result in raising `CliConsoleNestingProhibitedError` derived from `RuntimeError`.

Logging support
All messages handled by CliConsole may be logged regardless of is_silent property.

### Example usage

#### Simple output

```python
from snowflake.cli.api.console import cli_console as cc

def my_command():
cc.step("Some work...")
...
cc.step("Next work...")
```

#### Output

```bash
> snow my_command

Some work...
Next work...
```

#### Grouped output

```python
from snowflake.cli.api.console import cli_console as cc

def my_command():
cc.step("First step...")
with cc.phase("Long sequence of actions"):
cc.step("Phased step 1")
cc.step("Phased step 2")
if something_important:
cc.warning("It's important")
cc.step("Final step")
```

#### Output

```bash
> snow my_command

First step...
Long sequence of actions
Phased step 1
Phased step 2
__It's important__
Final step
```
## Known issues
### `permission denied` during integration tests on Windows
Expand Down
2 changes: 2 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

## New additions
* Added ability to specify scope of the `object list` command with the `--in <scope_type> <scope_name>` option.
* Introduced `snowflake.cli.api.console.cli_console` object with helper methods for intermediate output.
* Added `suspend` and `resume` commands for `spcs compute-pool`.
## Fixes and improvements
* Restricted permissions of automatically created files


# v2.0.0
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ development = [
[project.scripts]
snow = "snowflake.cli.app.__main__:main"

[tool.coverage.report]
exclude_also = ["@(abc\\.)?abstractmethod", "@(abc\\.)?abstractproperty"]

[tool.hatch.version]
path = "src/snowflake/cli/__about__.py"

Expand Down
7 changes: 7 additions & 0 deletions src/snowflake/cli/api/cli_global_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,15 @@ def project_root(self):

@property
def silent(self) -> bool:
if self._should_force_mute_intermediate_output:
return True
return self._manager.silent

@property
def _should_force_mute_intermediate_output(self) -> bool:
"""Computes whether cli_console output should be muted."""
return self._manager.output_format == OutputFormat.JSON


cli_context_manager: _CliGlobalContextManager = _CliGlobalContextManager()
cli_context: _CliGlobalContextAccess = _CliGlobalContextAccess(cli_context_manager)
3 changes: 3 additions & 0 deletions src/snowflake/cli/api/console/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from snowflake.cli.api.console.console import cli_console

__all__ = ("cli_console",)
61 changes: 61 additions & 0 deletions src/snowflake/cli/api/console/abc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from abc import ABC, abstractmethod
from contextlib import contextmanager
from typing import Callable, Iterator, Optional

from rich import print as rich_print
from rich.text import Text
from snowflake.cli.api.cli_global_context import _CliGlobalContextAccess, cli_context


class AbstractConsole(ABC):
"""Interface for cli console implementation.
Each console should have three methods implemented:
- `step` - for more detailed informations on steps
- `warning` - for displaying messages in a style that makes it
visually stand out from other output
- `phase` a context manager for organising steps into logical group
"""

_print_fn: Callable[[str], None]
_cli_context: _CliGlobalContextAccess
_in_phase: bool

def __init__(self):
super().__init__()
self._cli_context = cli_context
self._in_phase = False

@property
def is_silent(self) -> bool:
"""Returns information whether intermediate output is muted."""
return self._cli_context.silent

@property
def in_phase(self) -> bool:
"""Indicated whether output should be grouped."""
return self._in_phase

def _print(self, text: Text):
if self.is_silent:
return
rich_print(text)

@contextmanager
@abstractmethod
def phase(
self,
enter_message: str,
exit_message: Optional[str] = None,
) -> Iterator[Callable[[str], None]]:
"""A context manager for organising steps into logical group."""

@abstractmethod
def step(self, message: str):
"""Displays message to output."""

@abstractmethod
def warning(self, message: str):
"""Displays message in a style that makes it visually stand out from other output.
Intended for diplaying messeges related to important messages."""
85 changes: 85 additions & 0 deletions src/snowflake/cli/api/console/console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from __future__ import annotations

from contextlib import contextmanager
from typing import Optional

from rich.style import Style
from rich.text import Text
from snowflake.cli.api.console.abc import AbstractConsole
from snowflake.cli.api.console.enum import Output

PHASE_STYLE: Style = Style(color="grey93", bold=True)
STEP_STYLE: Style = Style(color="grey89", italic=True)
IMPORTANT_STYLE: Style = Style(color="red", bold=True, italic=True)
INDENTATION_LEVEL: int = 2


class CliConsoleNestingProhibitedError(RuntimeError):
"""CliConsole phase nesting not allowed."""


class CliConsole(AbstractConsole):
"""An utility for displaying intermediate output.
Provides following methods for handling displying messages:
- `step` - for more detailed informations on steps
- `warning` - for displaying messages in a style that makes it
visually stand out from other output
- `phase` a context manager for organising steps into logical group
"""

_indentation_level: int = INDENTATION_LEVEL
_styles: dict = {
"default": "",
Output.PHASE: PHASE_STYLE,
Output.STEP: STEP_STYLE,
Output.IMPORTANT: IMPORTANT_STYLE,
}

def _format_message(self, message: str, output: Output) -> Text:
"""Wraps message in rich Text object and applys formatting."""
style = self._styles.get(output, "default")
text = Text(message, style=style)

if self.in_phase and output in {Output.STEP, Output.IMPORTANT}:
text.pad_left(self._indentation_level)

return text

@contextmanager
def phase(self, enter_message: str, exit_message: Optional[str] = None):
"""A context manager for organising steps into logical group."""
if self.in_phase:
raise CliConsoleNestingProhibitedError("Only one phase allowed at a time.")

self._print(self._format_message(enter_message, Output.PHASE))
self._in_phase = True

yield self.step

self._in_phase = False
if exit_message:
self._print(self._format_message(exit_message, Output.PHASE))

def step(self, message: str):
"""Displays messge to output.
If called within a phase, the output will be indented.
"""
text = self._format_message(message, Output.STEP)
self._print(text)

def warning(self, message: str):
"""Displays message in a style that makes it visually stand out from other output.
Intended for diplaying messeges related to important messages."""
text = self._format_message(message, Output.IMPORTANT)
self._print(text)


def get_cli_console() -> AbstractConsole:
console = CliConsole()
return console


cli_console = get_cli_console()
3 changes: 3 additions & 0 deletions src/snowflake/cli/api/console/enum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from enum import Enum

Output = Enum("Output", ("PHASE", "STEP", "IMPORTANT"))
4 changes: 4 additions & 0 deletions src/snowflake/cli/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import logging
import os

# Suppress logging from Snowflake connector
logging.getLogger("snowflake").setLevel(logging.ERROR)

# Restrict permissions of all created files
os.umask(0o077)
Empty file added tests/api/console/__init__.py
Empty file.
66 changes: 66 additions & 0 deletions tests/api/console/test_cli_console_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from __future__ import annotations

from typing import Generator

import pytest
from snowflake.cli.api.console.console import (
CliConsole,
CliConsoleNestingProhibitedError,
)


@pytest.fixture(name="cli_console")
def make_cli_console() -> Generator[CliConsole, None, None]:
console = CliConsole()
yield console


def assert_output_matches(expected: str, capsys):
out, _ = capsys.readouterr()
assert out == expected


def test_phase_alone_produces_no_output(cli_console, capsys):
cli_console.phase("42")
assert_output_matches("", capsys)


def test_only_step_no_indent(cli_console, capsys):
cli_console.step("73")
assert_output_matches("73\n", capsys)


def test_step_indented_in_phase(cli_console, capsys):
with cli_console.phase("42"):
cli_console.step("73")
assert_output_matches("42\n 73\n", capsys)


def test_multi_step_indented(cli_console, capsys):
with cli_console.phase("42"):
cli_console.step("73.1")
cli_console.step("73.2")
assert_output_matches("42\n 73.1\n 73.2\n", capsys)


def test_phase_after_step_not_indented(cli_console, capsys):
with cli_console.phase("42"):
cli_console.step("73")
cli_console.step("42")
assert_output_matches("42\n 73\n42\n", capsys)


def test_error_messages(cli_console, capsys):
with cli_console.phase("42"):
cli_console.step("73")
cli_console.warning("ops")
cli_console.warning("OPS")

assert_output_matches("42\n 73\n ops\nOPS\n", capsys)


def test_phase_nesting_not_allowed(cli_console):
with cli_console.phase("Enter 1"):
with pytest.raises(CliConsoleNestingProhibitedError):
with cli_console.phase("Enter 2"):
pass
28 changes: 28 additions & 0 deletions tests/api/console/test_console_abc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from contextlib import contextmanager

from snowflake.cli.api.console.abc import AbstractConsole


def test_console_base_class(capsys):
class TConsole(AbstractConsole):
@contextmanager
def phase(self, enter_message: str, exit_message: str):
print(enter_message)
yield self.step
print(exit_message)

def step(self, message: str):
print(message)

def warning(self, message: str):
print(message)

console = TConsole()
assert not console.is_silent

with console.phase("Enter", "Exit"):
console.step("b")
console.warning("c")

out, _ = capsys.readouterr()
assert out == "Enter\nb\nc\nExit\n"
Loading

0 comments on commit c687233

Please sign in to comment.