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

Swapped pyinquirer with questionary #799

Merged
merged 18 commits into from
Dec 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,5 @@ ENV/

# Jetbrains IDEs
.idea
pip-wheel-metadata
.vscode
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## v1.13dev

* Swapped PyInquirer with questionary for command line questions in `launch.py` [[#726](https://github.com/nf-core/tools/issues/726)]
* This should fix conda installation issues that some people had been hitting
* The change also allows other improvements to the UI

### Tools helper code

### Template
Expand Down
128 changes: 52 additions & 76 deletions nf_core/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import json
import logging
import os
import PyInquirer
import prompt_toolkit
import questionary
import re
import subprocess
import textwrap
Expand All @@ -20,15 +21,21 @@

log = logging.getLogger(__name__)

#
# NOTE: When PyInquirer 1.0.3 is released we can capture keyboard interruptions
# in a nicer way # with the raise_keyboard_interrupt=True argument in the PyInquirer.prompt() calls
# It also allows list selections to have a default set.
#
# Until then we have workarounds:
# * Default list item is moved to the top of the list
# * We manually raise a KeyboardInterrupt if we get None back from a question
#
# Custom style for questionary
nfcore_question_style = prompt_toolkit.styles.Style(
[
("qmark", "fg:ansiblue bold"), # token in front of the question
("question", "bold"), # question text
("answer", "fg:ansigreen nobold"), # submitted answer text behind the question
("pointer", "fg:ansiyellow bold"), # pointer used in select and checkbox prompts
("highlighted", "fg:ansiblue bold"), # pointed-at choice in select and checkbox prompts
("selected", "fg:ansigreen noreverse"), # style for a selected item of a checkbox
("separator", "fg:ansiblack"), # separator in lists
("instruction", ""), # user instructions for select, rawselect, checkbox
("text", ""), # plain text
("disabled", "fg:gray italic"), # disabled choices for select and checkbox prompts
]
)


class Launch(object):
Expand Down Expand Up @@ -256,11 +263,9 @@ def prompt_web_gui(self):
"name": "use_web_gui",
"message": "Choose launch method",
"choices": ["Web based", "Command line"],
"default": "Web based",
}
answer = PyInquirer.prompt([question])
# TODO: use raise_keyboard_interrupt=True when PyInquirer 1.0.3 is released
if answer == {}:
raise KeyboardInterrupt
answer = questionary.unsafe_prompt([question], style=nfcore_question_style)
return answer["use_web_gui"] == "Web based"

def launch_web_gui(self):
Expand Down Expand Up @@ -347,14 +352,14 @@ def sanitise_web_response(self):
The web builder returns everything as strings.
Use the functions defined in the cli wizard to convert to the correct types.
"""
# Collect pyinquirer objects for each defined input_param
pyinquirer_objects = {}
# Collect questionary objects for each defined input_param
questionary_objects = {}
for param_id, param_obj in self.schema_obj.schema.get("properties", {}).items():
pyinquirer_objects[param_id] = self.single_param_to_pyinquirer(param_id, param_obj, print_help=False)
questionary_objects[param_id] = self.single_param_to_questionary(param_id, param_obj, print_help=False)

for d_key, definition in self.schema_obj.schema.get("definitions", {}).items():
for param_id, param_obj in definition.get("properties", {}).items():
pyinquirer_objects[param_id] = self.single_param_to_pyinquirer(param_id, param_obj, print_help=False)
questionary_objects[param_id] = self.single_param_to_questionary(param_id, param_obj, print_help=False)

# Go through input params and sanitise
for params in [self.nxf_flags, self.schema_obj.input_params]:
Expand All @@ -364,7 +369,7 @@ def sanitise_web_response(self):
del params[param_id]
continue
# Run filter function on value
filter_func = pyinquirer_objects.get(param_id, {}).get("filter")
filter_func = questionary_objects.get(param_id, {}).get("filter")
if filter_func is not None:
params[param_id] = filter_func(params[param_id])

Expand Down Expand Up @@ -396,19 +401,13 @@ def prompt_param(self, param_id, param_obj, is_required, answers):
"""Prompt for a single parameter"""

# Print the question
question = self.single_param_to_pyinquirer(param_id, param_obj, answers)
answer = PyInquirer.prompt([question])
# TODO: use raise_keyboard_interrupt=True when PyInquirer 1.0.3 is released
if answer == {}:
raise KeyboardInterrupt
question = self.single_param_to_questionary(param_id, param_obj, answers)
answer = questionary.unsafe_prompt([question], style=nfcore_question_style)

# If required and got an empty reponse, ask again
while type(answer[param_id]) is str and answer[param_id].strip() == "" and is_required:
log.error("'–-{}' is required".format(param_id))
answer = PyInquirer.prompt([question])
# TODO: use raise_keyboard_interrupt=True when PyInquirer 1.0.3 is released
if answer == {}:
raise KeyboardInterrupt
answer = questionary.unsafe_prompt([question], style=nfcore_question_style)

# Don't return empty answers
if answer[param_id] == "":
Expand All @@ -426,37 +425,39 @@ def prompt_group(self, group_id, group_obj):
Returns:
Dict of param_id:val answers
"""
question = {
"type": "list",
"name": group_id,
"message": group_obj.get("title", group_id),
"choices": ["Continue >>", PyInquirer.Separator()],
}

for param_id, param in group_obj["properties"].items():
if not param.get("hidden", False) or self.show_hidden:
question["choices"].append(param_id)

# Skip if all questions hidden
if len(question["choices"]) == 2:
return {}

while_break = False
answers = {}
while not while_break:
question = {
"type": "list",
"name": group_id,
"message": group_obj.get("title", group_id),
"choices": ["Continue >>", questionary.Separator()],
}

for param_id, param in group_obj["properties"].items():
if not param.get("hidden", False) or self.show_hidden:
q_title = param_id
if param_id in answers:
q_title += " [{}]".format(answers[param_id])
elif "default" in param:
q_title += " [{}]".format(param["default"])
question["choices"].append(questionary.Choice(title=q_title, value=param_id))

# Skip if all questions hidden
if len(question["choices"]) == 2:
return {}

self.print_param_header(group_id, group_obj)
answer = PyInquirer.prompt([question])
# TODO: use raise_keyboard_interrupt=True when PyInquirer 1.0.3 is released
if answer == {}:
raise KeyboardInterrupt
answer = questionary.unsafe_prompt([question], style=nfcore_question_style)
if answer[group_id] == "Continue >>":
while_break = True
# Check if there are any required parameters that don't have answers
for p_required in group_obj.get("required", []):
req_default = self.schema_obj.input_params.get(p_required, "")
req_answer = answers.get(p_required, "")
if req_default == "" and req_answer == "":
log.error("'{}' is required.".format(p_required))
log.error("'--{}' is required.".format(p_required))
while_break = False
else:
param_id = answer[group_id]
Expand All @@ -465,8 +466,8 @@ def prompt_group(self, group_id, group_obj):

return answers

def single_param_to_pyinquirer(self, param_id, param_obj, answers=None, print_help=True):
"""Convert a JSONSchema param to a PyInquirer question
def single_param_to_questionary(self, param_id, param_obj, answers=None, print_help=True):
"""Convert a JSONSchema param to a Questionary question

Args:
param_id: Parameter ID (string)
Expand All @@ -475,7 +476,7 @@ def single_param_to_pyinquirer(self, param_id, param_obj, answers=None, print_he
print_help: If description and help_text should be printed (bool)

Returns:
Single PyInquirer dict, to be appended to questions list
Single Questionary dict, to be appended to questions list
"""
if answers is None:
answers = {}
Expand Down Expand Up @@ -598,16 +599,6 @@ def filter_range(val):
question["type"] = "list"
question["choices"] = param_obj["enum"]

# Validate enum from schema
def validate_enum(val):
if val == "":
return True
if val in param_obj["enum"]:
return True
return "Must be one of: {}".format(", ".join(param_obj["enum"]))

question["validate"] = validate_enum

# Validate pattern from schema
if "pattern" in param_obj:

Expand All @@ -620,21 +611,6 @@ def validate_pattern(val):

question["validate"] = validate_pattern

# WORKAROUND - PyInquirer <1.0.3 cannot have a default position in a list
# For now, move the default option to the top.
# TODO: Delete this code when PyInquirer >=1.0.3 is released.
if question["type"] == "list" and "default" in question:
try:
question["choices"].remove(question["default"])
question["choices"].insert(0, question["default"])
except ValueError:
log.warning(
"Default value `{}` not found in list of choices: {}".format(
question["default"], ", ".join(question["choices"])
)
)
### End of workaround code

return question

def print_param_header(self, param_id, param_obj):
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"GitPython",
"jinja2",
"jsonschema",
"PyInquirer==1.0.2",
"questionary>=1.8.0",
"prompt_toolkit",
"pyyaml",
"requests",
"requests_cache",
Expand Down
39 changes: 18 additions & 21 deletions tests/test_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,21 +93,21 @@ def test_nf_merge_schema(self):
assert self.launcher.schema_obj.schema["allOf"][0] == {"$ref": "#/definitions/coreNextflow"}
assert "-resume" in self.launcher.schema_obj.schema["definitions"]["coreNextflow"]["properties"]

def test_ob_to_pyinquirer_string(self):
def test_ob_to_questionary_string(self):
""" Check converting a python dict to a pyenquirer format - simple strings """
sc_obj = {
"type": "string",
"default": "data/*{1,2}.fastq.gz",
}
result = self.launcher.single_param_to_pyinquirer("input", sc_obj)
result = self.launcher.single_param_to_questionary("input", sc_obj)
assert result == {"type": "input", "name": "input", "message": "input", "default": "data/*{1,2}.fastq.gz"}

@mock.patch("PyInquirer.prompt", side_effect=[{"use_web_gui": "Web based"}])
@mock.patch("questionary.unsafe_prompt", side_effect=[{"use_web_gui": "Web based"}])
def test_prompt_web_gui_true(self, mock_prompt):
""" Check the prompt to launch the web schema or use the cli """
assert self.launcher.prompt_web_gui() == True

@mock.patch("PyInquirer.prompt", side_effect=[{"use_web_gui": "Command line"}])
@mock.patch("questionary.unsafe_prompt", side_effect=[{"use_web_gui": "Command line"}])
def test_prompt_web_gui_false(self, mock_prompt):
""" Check the prompt to launch the web schema or use the cli """
assert self.launcher.prompt_web_gui() == False
Expand Down Expand Up @@ -198,13 +198,13 @@ def test_sanitise_web_response(self):
assert self.launcher.schema_obj.input_params["single_end"] == True
assert self.launcher.schema_obj.input_params["max_cpus"] == 12

def test_ob_to_pyinquirer_bool(self):
def test_ob_to_questionary_bool(self):
""" Check converting a python dict to a pyenquirer format - booleans """
sc_obj = {
"type": "boolean",
"default": "True",
}
result = self.launcher.single_param_to_pyinquirer("single_end", sc_obj)
result = self.launcher.single_param_to_questionary("single_end", sc_obj)
assert result["type"] == "list"
assert result["name"] == "single_end"
assert result["message"] == "single_end"
Expand All @@ -218,10 +218,10 @@ def test_ob_to_pyinquirer_bool(self):
assert result["filter"]("false") == False
assert result["filter"](False) == False

def test_ob_to_pyinquirer_number(self):
def test_ob_to_questionary_number(self):
""" Check converting a python dict to a pyenquirer format - with enum """
sc_obj = {"type": "number", "default": 0.1}
result = self.launcher.single_param_to_pyinquirer("min_reps_consensus", sc_obj)
result = self.launcher.single_param_to_questionary("min_reps_consensus", sc_obj)
assert result["type"] == "input"
assert result["default"] == "0.1"
assert result["validate"]("123") is True
Expand All @@ -232,10 +232,10 @@ def test_ob_to_pyinquirer_number(self):
assert result["filter"]("123.456") == float(123.456)
assert result["filter"]("") == ""

def test_ob_to_pyinquirer_integer(self):
def test_ob_to_questionary_integer(self):
""" Check converting a python dict to a pyenquirer format - with enum """
sc_obj = {"type": "integer", "default": 1}
result = self.launcher.single_param_to_pyinquirer("broad_cutoff", sc_obj)
result = self.launcher.single_param_to_questionary("broad_cutoff", sc_obj)
assert result["type"] == "input"
assert result["default"] == "1"
assert result["validate"]("123") is True
Expand All @@ -246,10 +246,10 @@ def test_ob_to_pyinquirer_integer(self):
assert result["filter"]("123") == int(123)
assert result["filter"]("") == ""

def test_ob_to_pyinquirer_range(self):
def test_ob_to_questionary_range(self):
""" Check converting a python dict to a pyenquirer format - with enum """
sc_obj = {"type": "range", "minimum": "10", "maximum": "20", "default": 15}
result = self.launcher.single_param_to_pyinquirer("broad_cutoff", sc_obj)
result = self.launcher.single_param_to_questionary("broad_cutoff", sc_obj)
assert result["type"] == "input"
assert result["default"] == "15"
assert result["validate"]("20") is True
Expand All @@ -260,21 +260,18 @@ def test_ob_to_pyinquirer_range(self):
assert result["filter"]("20") == float(20)
assert result["filter"]("") == ""

def test_ob_to_pyinquirer_enum(self):
""" Check converting a python dict to a pyenquirer format - with enum """
def test_ob_to_questionary_enum(self):
""" Check converting a python dict to a questionary format - with enum """
sc_obj = {"type": "string", "default": "copy", "enum": ["symlink", "rellink"]}
result = self.launcher.single_param_to_pyinquirer("publish_dir_mode", sc_obj)
result = self.launcher.single_param_to_questionary("publish_dir_mode", sc_obj)
assert result["type"] == "list"
assert result["default"] == "copy"
assert result["choices"] == ["symlink", "rellink"]
assert result["validate"]("symlink") is True
assert result["validate"]("") is True
assert result["validate"]("not_allowed") == "Must be one of: symlink, rellink"

def test_ob_to_pyinquirer_pattern(self):
""" Check converting a python dict to a pyenquirer format - with pattern """
def test_ob_to_questionary_pattern(self):
""" Check converting a python dict to a questionary format - with pattern """
sc_obj = {"type": "string", "pattern": "^([a-zA-Z0-9_\\-\\.]+)@([a-zA-Z0-9_\\-\\.]+)\\.([a-zA-Z]{2,5})$"}
result = self.launcher.single_param_to_pyinquirer("email", sc_obj)
result = self.launcher.single_param_to_questionary("email", sc_obj)
assert result["type"] == "input"
assert result["validate"]("[email protected]") is True
assert result["validate"]("") is True
Expand Down