Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ShouldAsk for Echo and Acknowledge #358

Merged
merged 9 commits into from
Sep 2, 2022
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.12.0] - 2022-08-27
plannigan marked this conversation as resolved.
Show resolved Hide resolved

### Added

- Python version `3.10` tested during CI
- Python version `3.10` added to package classifiers
- `should_ask` kwarg for `Echo` and `Acknowledge` interactions ([#356](https://github.com/wayfair-incubator/columbo/issues/356))
mklaber marked this conversation as resolved.
Show resolved Hide resolved

### Removed

Expand Down
90 changes: 77 additions & 13 deletions columbo/_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
Validator,
)

Interaction = Union["Echo", "Acknowledge", "Question"]
Interaction = Union["Displayable", "Question"]


# Used by copy() implementations. Since some arguments can be None, None can't be used as the value to indicate that the
Expand All @@ -39,60 +39,121 @@ def _or_default(value, default: T) -> T:
return default if isinstance(value, _Sentinel) else value


class Echo:
"""Display a message to the user."""
class Displayable(ABC):
plannigan marked this conversation as resolved.
Show resolved Hide resolved
"""
Base class for a message to the user that is displayed.
"""

def __init__(self, message: StaticOrDynamicValue[str]) -> None:
def __init__(
self, message: StaticOrDynamicValue[str], should_ask: Optional[ShouldAsk] = None
) -> None:
"""
Initialize an instance.

:param message: The message to be displayed to the user. If the value is callable, the argument passed in will
be the answers that have been provided this far.
:param should_ask: If `None`, the message is displayed to the user. Otherwise, the callable will be passed the
answers that have been provided this far and should return `True` if the message should be displayed.
"""
self._message = message
self._should_ask = should_ask

@abstractmethod
def display(self, answers: Answers) -> None: # pragma: no cover
pass

def should_ask(self, answers: Answers) -> bool:
"""
Should the user be displayed this message.

:param answers: The answers that have been provided this far.
:return: `True` if this message should be displayed
"""
if self._should_ask is None:
return True
if callable(self._should_ask):
return self._should_ask(answers)
raise ValueError(f"Invalid value for should_ask: {self._should_ask}")
plannigan marked this conversation as resolved.
Show resolved Hide resolved


class Echo(Displayable):
"""Display a message to the user."""

def __init__(
self, message: StaticOrDynamicValue[str], should_ask: Optional[ShouldAsk] = None
) -> None:
"""
Initialize an instance.

:param message: The message to be displayed to the user. If the value is callable, the argument passed in will
be the answers that have been provided this far.
:param should_ask: If `None`, the message is displayed to the user. Otherwise, the callable will be passed the
answers that have been provided this far and should return `True` if the message should be displayed.
"""
super().__init__(message, should_ask)

def display(self, answers: Answers) -> None:
user_io.echo(to_value(self._message, answers, str))

def copy(
self, *, message: Possible[StaticOrDynamicValue[str]] = _NOT_GIVEN
self,
*,
message: Possible[StaticOrDynamicValue[str]] = _NOT_GIVEN,
should_ask: Possible[Optional[ShouldAsk]] = _NOT_GIVEN,
) -> "Echo":
"""
Create a new instance like this one, potentially with different values.

:param message: The message to be displayed to the user. If the value is callable, the argument passed in will
be the answers that have been provided this far.
:param should_ask: If `None`, the message is displayed. Otherwise, the callable will be passed the answers that
have been provided this far and should return `True` if the message should be displayed.
:return: A newly constructed instance with the given values in place of the values of this instance.
"""
return Echo(_or_default(message, self._message))
return Echo(
_or_default(message, self._message),
should_ask=_or_default(should_ask, self._should_ask),
)


class Acknowledge:
class Acknowledge(Displayable):
"""Display a message to the user and require the user to press ENTER to continue."""

def __init__(self, message: StaticOrDynamicValue[str]) -> None:
def __init__(
self, message: StaticOrDynamicValue[str], should_ask: Optional[ShouldAsk] = None
) -> None:
"""
Initialize an instance.

:param message: The message to be displayed to the user. If the value is callable, the argument passed in will
be the answers that have been provided this far.
:param should_ask: If `None`, the message is displayed to the user. Otherwise, the callable will be passed the
answers that have been provided this far and should return `True` if the message should be displayed.
"""
self._message = message
super().__init__(message, should_ask)

def display(self, answers: Answers) -> None:
user_io.acknowledge(to_value(self._message, answers, str))

def copy(
self, *, message: Possible[StaticOrDynamicValue[str]] = _NOT_GIVEN
self,
*,
message: Possible[StaticOrDynamicValue[str]] = _NOT_GIVEN,
should_ask: Possible[Optional[ShouldAsk]] = _NOT_GIVEN,
) -> "Acknowledge":
"""
Create a new instance like this one, potentially with different values.

:param message: The message to be displayed to the user. If the value is callable, the argument passed in will
be the answers that have been provided this far.
:param should_ask: If `None`, the message is displayed. Otherwise, the callable will be passed the answers that
have been provided this far and should return `True` if the message should be displayed.
:return: A newly constructed instance with the given values in place of the values of this instance.
"""
return Acknowledge(_or_default(message, self._message))
return Acknowledge(
_or_default(message, self._message),
should_ask=_or_default(should_ask, self._should_ask),
)


class Question(ABC):
Expand Down Expand Up @@ -140,7 +201,9 @@ def cli_help(self) -> Optional[str]:
return self._cli_help

@abstractmethod
def ask(self, answers: Answers, no_user_input: bool = False) -> Answer:
def ask(
self, answers: Answers, no_user_input: bool = False
) -> Answer: # pragma: no cover
"""
Prompt the user with this question.

Expand Down Expand Up @@ -556,7 +619,8 @@ def get_answers(

for interaction in interactions:
if isinstance(interaction, (Echo, Acknowledge)):
interaction.display(result)
if interaction.should_ask(result):
plannigan marked this conversation as resolved.
Show resolved Hide resolved
interaction.display(result)
elif isinstance(interaction, (Confirm, Choice, BasicQuestion)):
if interaction.should_ask(result):
result[interaction.name] = interaction.ask(result, no_user_input)
Expand Down
14 changes: 10 additions & 4 deletions docs/examples/branching_story.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,24 @@ def outcome(answers: columbo.Answers) -> str:
options=["left", "right"],
default="left",
),
columbo.Echo(
"You step into a short hallway and the door closes behind you, refusing to open again. "
"As you walk down the hallway, there is a small side table with a key on it.",
should_ask=went_left,
),
columbo.Confirm(
"has_key",
"You step into a short hallway and the door closes behind you, refusing to open again. "
"As you walk down the hallway, there is a small side table with a key on it.\n"
"Do you pick up the key before going through the door at the other end?",
should_ask=went_left,
default=True,
),
columbo.Echo(
"You step into smaller room and the door closes behind, refusing to open again. "
"The room has a single door on the opposite side of the room and a work bench with a hammer on it.",
should_ask=went_right,
),
columbo.Confirm(
"has_hammer",
"You step into smaller room and the door closes behind, refusing to open again. "
"The room has a single door on the opposite side of the room and a work bench with a hammer on it.\n"
"Do you pick up the hammer before going through the door at the other side?",
should_ask=went_right,
default=True,
Expand Down
4 changes: 4 additions & 0 deletions docs/examples/optional_questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ def user_has_dog(answers: columbo.Answers) -> bool:

interactions = [
columbo.Confirm("has_dog", "Do you have a dog?", default=True),
columbo.Echo(
"Because you have have a dog, we want to ask you some more questions.",
should_ask=user_has_dog,
),
columbo.BasicQuestion(
"dog_name",
"What is the name of the dog?",
Expand Down
4 changes: 4 additions & 0 deletions docs/examples/optional_questions_with_value_if_not_asked.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ def user_has_dog(answers: columbo.Answers) -> bool:

interactions = [
columbo.Confirm("has_dog", "Do you have a dog?", default=True),
columbo.Echo(
"Because you have have a dog, we want to ask you some more questions.",
should_ask=user_has_dog,
),
columbo.BasicQuestion(
"dog_name",
"What is the name of the dog?",
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* As part of the text of a question
* As part of the text of a default value
* To decide if a question should be skipped
* To decide if a message should be displayed
plannigan marked this conversation as resolved.
Show resolved Hide resolved
* Accept answers from the command line in addition to prompting the user.

## Example
Expand Down
8 changes: 7 additions & 1 deletion docs/usage-guide/interactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@

### Echo & Acknowledge

`Echo` and `Acknowledge` both accept `message` as their only argument. This is the message to be displayed to the user.
`Echo` and `Acknowledge` both accept the following arguments.

* `message`: The message to be displayed to the user.
* `should_ask`: Optional. When given, the argument should be a function that accepts an `Answers` dictionary and returns
`True` or `False`. Returning `True` indicates that the message should be displayed. Returning `False` will skip the
message and not present it to the user. See [Optional Questions & Branching][optional-questions] for more details.


### All Questions

Expand Down
14 changes: 9 additions & 5 deletions docs/usage-guide/optional-questions-and-branching.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
## Only Asking Some Questions
## Only Prompting Some Interactions

There are situations where a question should be asked some times, but not all the time. For example, a program that collects
information about a user's pets should not ask the user for the dog's name and breed if the user said they do not have a
dog. The `should_ask` argument that is present on each question provides a way to achieve this functionality.
There are situations where a question should be asked some times, but not all the time. Or, a message should be displayed some times.
For example, a program that collects information about a user's
pets should not ask the user for the dog's name and breed if the
user said they do not have a dog. If the user has a dog, the program
may want to display a special message. The `should_ask` argument that
is present on each interaction provides a way to achieve this functionality.

Similarly, `should_ask` can be used to provide branching paths to the user. An example of these branching paths is a
[Choose Your Own Adventure][choose-your-own-adventure] story. The story provides the reader with choices during the
Expand All @@ -16,7 +19,8 @@ adventure. These choices introduce diverging paths of interactions that may or m
### Optional Questions

The following is a basic example that has two optional questions that are not asked based on the answer to the first
question.
question. It also has an optional "echo" that is only displayed
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
question. It also has an optional "echo" that is only displayed
question. It also has an optional `Echo` that is only displayed

Minor: I try to use the code formatting when referring to the classes by name.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't referring to a class by name just as "two optional questions" wasn't referring to BasicQuestions. I used quotation marks because it is otherwise awkward to use echo as a noun here. I'll leave it as-is unless you have strong feelings (which it seems you don't).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. Maybe something "It also has an optional message that is only...". That phrasing could remove the confusion I had.

based on the answer to the first question.

```python
{!examples/optional_questions.py!}
Expand Down
46 changes: 40 additions & 6 deletions tests/interaction_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
SOME_OPTIONS,
SOME_OTHER_BOOL,
SOME_STRING,
SampleDisplayable,
SampleQuestion,
some_dynamic_bool,
some_dynamic_default,
Expand Down Expand Up @@ -450,6 +451,25 @@ def test_should_ask__invalid_type__exception():
)


@pytest.mark.parametrize(
"description,should_ask,expected_result",
[("not set", None, True), ("dynamic", some_dynamic_bool, SOME_OTHER_BOOL)],
)
def test_displayable_should_ask__expected_result(
should_ask, expected_result, description
):
result = SampleDisplayable(SOME_STRING, should_ask=should_ask).should_ask(
SOME_ANSWERS
)

assert result == expected_result, description


def test_displayable_should_ask__invalid_type__exception():
with pytest.raises(ValueError):
SampleDisplayable(SOME_STRING, should_ask=object()).should_ask(SOME_ANSWERS)


def test_get_answers__unknown_interaction_provided():
"""
An unsupported interaction type should raise a ValueError
Expand All @@ -461,18 +481,30 @@ def test_get_answers__unknown_interaction_provided():


@pytest.mark.parametrize(
["basic_question_should_ask", "expected_basic_question_ask_call_count"],
[(True, 1), (False, 0)],
[
"basic_question_should_ask",
"expected_basic_question_ask_call_count",
"echo_should_ask",
"expected_echo_display_call_count",
],
[(True, 1, False, 0), (False, 0, True, 1)],
)
def test_get_answers__proper_funcs_called(
mocker, basic_question_should_ask, expected_basic_question_ask_call_count
mocker,
basic_question_should_ask,
expected_basic_question_ask_call_count,
echo_should_ask,
expected_echo_display_call_count,
):
"""
Given a list of interactions of different types, validate the proper funcs should be invoked on each

The parametrization asserts that Question interactions are only 'asked' when they should be asked
The parametrization asserts that Question interactions are only 'asked' when they should be asked and
that Displayable interactions are only 'displayed' when they should be displayed.
"""
echo_interaction_mock = mocker.Mock(spec=Echo)
echo_interaction_mock = mocker.Mock(
spec=Echo, should_ask=mocker.Mock(return_value=echo_should_ask)
)
basic_question_interaction_mock = mocker.Mock(
spec=BasicQuestion,
should_ask=mocker.Mock(return_value=basic_question_should_ask),
Expand All @@ -486,7 +518,9 @@ def test_get_answers__proper_funcs_called(

# functions belonging to interactions should be invoked
# in the case of a question, it either should or shouldn't be asked
echo_interaction_mock.display.assert_called_once()
# in the case of echo, it either should or shouldn't be displayed
echo_interaction_mock.should_ask.assert_called_once()
assert echo_interaction_mock.display.call_count == expected_echo_display_call_count
basic_question_interaction_mock.should_ask.assert_called_once()
assert (
basic_question_interaction_mock.ask.call_count
Expand Down
16 changes: 16 additions & 0 deletions tests/sample_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
BasicQuestion,
Choice,
Confirm,
Displayable,
Question,
canonical_arg_name,
)
Expand Down Expand Up @@ -53,6 +54,21 @@ def ask(self, answers, no_user_input=False):
raise Exception("Don't call")


class SampleDisplayable(Displayable):
"""Displayable for testing base class functionality"""

def __init__(self, message, should_ask=None):
super().__init__(message, should_ask)
self._display_called = False

def display(self, answers):
self._display_called = True

@property
def display_called(self) -> bool:
return self._display_called


# combination of questions that reuse the same name
DUPLICATE_QUESTION_NAME_PARAMS = [
[
Expand Down