Skip to content

Commit

Permalink
feat: add prompt to emitter
Browse files Browse the repository at this point in the history
Signed-off-by: Dariusz Duda <dariusz.duda@canonical.com>
  • Loading branch information
dariuszd21 committed Dec 19, 2024
1 parent 59be025 commit 0da221a
Show file tree
Hide file tree
Showing 4 changed files with 99 additions and 16 deletions.
12 changes: 5 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,15 @@ repos:
- id: check-toml
- id: fix-byte-order-marker
- id: mixed-line-ending
- repo: https://github.com/charliermarsh/ruff-pre-commit
- repo: https://github.com/astral-sh/ruff-pre-commit
# renovate: datasource=pypi;depName=ruff
rev: "v0.0.269"
rev: "v0.8.3"
hooks:
# Run the linter
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/psf/black
# renovate: datasource=pypi;depName=black
rev: "23.3.0"
hooks:
- id: black
# Run the formatter
- id: ruff-format
- repo: https://github.com/adrienverge/yamllint.git
# renovate: datasource=pypi;depName=yamllint
rev: "v1.32.0"
Expand Down
22 changes: 22 additions & 0 deletions craft_cli/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import enum
import functools
import getpass
import logging
import os
import pathlib
Expand Down Expand Up @@ -806,6 +807,27 @@ def confirm(self, prompt: str, *, default: bool = False) -> bool:
return False
return default

@_active_guard()
def prompt(self, prompt: str, *, hide: bool = False) -> str:
"""Prompt user for input.
If stdin is not a tty a CraftError is raised.
:param hide: hide user input if True.
:returns: value that was provided by user.
:raises: CraftError if shell is not interactive or input is empty
"""
if not sys.stdin.isatty():
raise errors.CraftError("prompting not possible without tty")

method: Callable[[str], str] = getpass.getpass if hide else input # type: ignore[assignment]

with self.pause():
val = method(prompt).strip()
if not val:
raise errors.CraftError("input cannot be empty")
return val


# module-level instantiated Emitter; this is the instance all code shall use and Emitter
# shall not be instantiated again for the process' run
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ Changelog
See the `Releases page`_ on GitHub for a complete list of commits that are
included in each version.

2.14.0 (2025-MMM-DD)
--------------------

- Add a ``prompt`` method to the emmiter to ask for user input.

2.13.0 (2024-Dec-16)
--------------------

Expand Down
76 changes: 67 additions & 9 deletions tests/unit/test_messages_emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
import sys
from unittest import mock
from unittest.mock import call, patch
from typing import cast, Callable

import craft_cli
import pytest
import pytest_mock

from craft_cli import messages
from craft_cli.errors import CraftError, CraftCommandError
Expand Down Expand Up @@ -879,7 +881,7 @@ def test_reporterror_simple_message_developer_modes(mode, get_initiated_emitter)

def test_reporterror_detailed_info_quiet_modes(get_initiated_emitter):
"""Report an error having detailed information, in final user modes.
Check that "quiet" is indeed quiet.
"""
emitter = get_initiated_emitter(EmitterMode.QUIET)
Expand Down Expand Up @@ -1251,16 +1253,72 @@ def test_confirm_with_user(get_initiated_emitter, user_input, expected, mock_inp
assert mock_input.mock_calls == [call("prompt [y/N]: ")]


def test_confirm_with_user_pause_emitter(get_initiated_emitter, emitter_mode, mock_isatty, mocker):
@pytest.fixture
def initiated_emitter(get_initiated_emitter, mock_isatty, emitter_mode) -> Emitter:
return cast(Emitter, get_initiated_emitter(emitter_mode))


@pytest.fixture
def fake_input(initiated_emitter: Emitter) -> Callable[[str], Callable[[str], str]]:
def get_fake_input_wrapper(input_val: str) -> Callable[[str], str]:
def _inner(prompt: str) -> str:
assert initiated_emitter._stopped
return input_val

return _inner

return get_fake_input_wrapper


def test_confirm_with_user_pause_emitter(
initiated_emitter: Emitter,
fake_input: Callable[[str], Callable[[str], str]],
mocker,
):
"""The emitter should be paused when using the terminal."""
mock_isatty.return_value = True
mocker.patch("builtins.input", fake_input(""))

initiated_emitter.confirm("prompt")


def test_prompt_returns_user_input(
initiated_emitter: Emitter,
fake_input: Callable[[str], Callable[[str], str]],
mocker: pytest_mock.MockerFixture,
):
"""The emitter should return user input."""
mocker.patch("builtins.input", fake_input("some-input"))

assert initiated_emitter.prompt("prompt") == "some-input"


def test_prompt_returns_secret_input(
initiated_emitter: Emitter,
fake_input: Callable[[str], Callable[[str], str]],
mocker: pytest_mock.MockerFixture,
):
"""The emitter should return user secret input."""
mocker.patch("getpass.getpass", fake_input("some-secret-input"))

assert initiated_emitter.prompt("prompt", hide=True) == "some-secret-input"

def test_prompt_errors_out_without_tty(
get_initiated_emitter, mock_isatty: mock.MagicMock, emitter_mode,
):
"""The emitter should error out if no tty available."""
mock_isatty.return_value = False
emit = get_initiated_emitter(emitter_mode)

def fake_input(_prompt):
"""Check if the Emitter is paused."""
assert emit._stopped
return ""
with pytest.raises(CraftError, match="prompting not possible without tty"):
emit.prompt("no prompting without tty!")

mocker.patch("builtins.input", fake_input)
def test_prompt_does_not_allow_empty_input(
initiated_emitter: Emitter,
fake_input: Callable[[str], Callable[[str], str]],
mocker: pytest_mock.MockerFixture,
):
"""The emitter should not allow empty input."""
mocker.patch("builtins.input", fake_input(""))

emit.confirm("prompt")
with pytest.raises(CraftError, match="input cannot be empty") as error:
initiated_emitter.prompt("prompt")

0 comments on commit 0da221a

Please sign in to comment.