Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d1c21e8
Add TTY detection to prevent interactive prompts from hanging in non-…
ewels Mar 23, 2026
5139b59
Fix is_interactive() to check stdin/stdout/stderr, not just stderr
ewels Mar 23, 2026
33e8b59
Mock is_interactive in tests that rely on interactive prompts
ewels Mar 23, 2026
210adb9
Add TTY guard for pipelines create TUI app
ewels Mar 23, 2026
183d830
Fix remaining test failures: mock is_interactive in test_cli and test…
ewels Mar 23, 2026
04f96b7
Refactor TTY guards: add require_interactive() helper, standardize ex…
ewels Mar 23, 2026
9e2cd08
Fix test_datasets mock target after is_interactive import was removed
ewels Mar 23, 2026
9a720fa
Replace require_interactive() with no_prompts, auto-set from TTY
ewels Mar 31, 2026
2ba56b2
Merge branch 'dev' into modules-install-no-tty
ewels Mar 31, 2026
1e722be
Address PR review comments: fix message formatting and simplify logic
ewels Mar 31, 2026
c9d5d51
Restore no_prompts snapshot guard and add is_interactive check to sin…
ewels Mar 31, 2026
f939f30
Add tests for nf_core.utils.is_interactive
ewels Mar 31, 2026
527b33b
Replace per-test is_interactive mocks with global autouse fixture
ewels Mar 31, 2026
230aaa3
Fix test_create_app: restore is_interactive mock for Click CliRunner
ewels Mar 31, 2026
30faf6e
simplify tests
mashehu Apr 1, 2026
64cbe5a
add `no_prompt` argument to more commands
mashehu Apr 1, 2026
f220287
fix test_cli test
mashehu Apr 1, 2026
c9716ff
Merge branch 'dev' into modules-install-no-tty
ningyuxin1999 Apr 7, 2026
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
7 changes: 7 additions & 0 deletions nf_core/commands_pipelines.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import rich

import nf_core.utils
from nf_core.pipelines.params_file import ParamsFileBuilder
from nf_core.utils import rich_force_colors

Expand Down Expand Up @@ -53,6 +54,12 @@ def pipelines_create(ctx, name, description, author, version, force, outdir, tem
)
sys.exit(1)
else:
if not nf_core.utils.is_interactive():
log.error(
"No pipeline arguments provided and session is not interactive (no TTY detected). "
"Please provide at least --name, --description, and --author to run non-interactively."
)
sys.exit(1)
log.info("Launching interactive nf-core pipeline creation tool.")
app = PipelineCreateApp()
app.run()
Expand Down
37 changes: 27 additions & 10 deletions nf_core/components/components_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ def check_inputs(self) -> None:
raise UserWarning(
f"{self.component_type[:-1].title()} name not provided and prompts deactivated. Please provide the {self.component_type[:-1]} name{' as TOOL/SUBTOOL or TOOL' if self.component_type == 'modules' else ''}."
)
elif not nf_core.utils.is_interactive():
nf_core.utils.require_interactive(
f"{self.component_type[:-1].title()} name not provided.\n"
f"Please provide the {self.component_type[:-1]} name as a command-line argument."
)
else:
try:
self.component_name = questionary.autocomplete(
Expand All @@ -122,6 +127,7 @@ def check_inputs(self) -> None:
except LookupError:
raise

assert self.component_name is not None # Set above by user input, prompt, or guard
self.component_dir = Path(self.component_type, self.modules_repo.repo_path, *self.component_name.split("/"))

# First, sanity check that the module directory exists
Expand All @@ -139,6 +145,12 @@ def check_inputs(self) -> None:
"To use Singularity set 'export PROFILE=singularity' in your shell before running this command."
)
os.environ["PROFILE"] = "docker"
elif not nf_core.utils.is_interactive():
log.info(
"Setting environment variable '$PROFILE' to Docker as session is not interactive.\n"
"To use Singularity set 'export PROFILE=singularity' in your shell before running this command."
)
os.environ["PROFILE"] = "docker"
Comment thread
ewels marked this conversation as resolved.
Outdated
else:
question = {
"type": "list",
Expand Down Expand Up @@ -175,15 +187,18 @@ def display_nftest_output(self, nftest_out: bytes, nftest_err: bytes) -> None:
log.info("Updating snapshot")
self.update = True
elif self.update is None:
answer = Confirm.ask(
"[bold][blue]?[/] nf-test found differences in the snapshot. Do you want to update it?",
default=True,
)
if answer:
log.info("Updating snapshot")
self.update = True
if not nf_core.utils.is_interactive():
log.warning("Snapshot differences found in non-interactive session. Not updating.")
else:
log.debug("Snapshot not updated")
answer = Confirm.ask(
"[bold][blue]?[/] nf-test found differences in the snapshot. Do you want to update it?",
default=True,
)
if answer:
log.info("Updating snapshot")
self.update = True
else:
log.debug("Snapshot not updated")
if self.update:
# update snapshot using nf-test --update-snapshot
self.generate_snapshot()
Expand Down Expand Up @@ -250,8 +265,10 @@ def check_snapshot_stability(self) -> bool:
else:
if self.obsolete_snapshots:
# ask if the user wants to remove obsolete snapshots using nf-test --clean-snapshot
if self.no_prompts or Confirm.ask(
"nf-test found obsolete snapshots. Do you want to remove them?", default=True
if (
self.no_prompts
or not nf_core.utils.is_interactive()
or Confirm.ask("nf-test found obsolete snapshots. Do you want to remove them?", default=True)
):
profile = self.profile if self.profile else os.environ["PROFILE"]
log.info("Removing obsolete snapshots")
Expand Down
10 changes: 10 additions & 0 deletions nf_core/components/components_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ def get_repo_info(directory: Path, use_prompt: bool | None = True) -> tuple[Path

# If not set, prompt the user
if not repo_type and use_prompt:
nf_core.utils.require_interactive(
f"'repository_type' not defined in '{config_fn.name}'.\n"
f"Please add 'repository_type' to your {config_fn.name} file."
)
log.warning("'repository_type' not defined in %s", config_fn.name)
repo_type = questionary.select(
"Is this repository a pipeline or a modules repository?",
Expand Down Expand Up @@ -71,6 +75,9 @@ def get_repo_info(directory: Path, use_prompt: bool | None = True) -> tuple[Path
if repo_type == "modules":
org = getattr(tools_config, "org_path", "") or ""
if org == "":
nf_core.utils.require_interactive(
f"'org_path' not defined in '{config_fn.name}'.\nPlease add 'org_path' to your {config_fn.name} file."
)
log.warning("Organisation path not defined in %s [key: org_path]", config_fn.name)
org = questionary.text(
"What is the organisation path under which modules and subworkflows are stored?",
Expand Down Expand Up @@ -107,6 +114,9 @@ def prompt_component_version_sha(
Returns:
git_sha (str): The selected version of the module/subworkflow
"""
nf_core.utils.require_interactive(
"Cannot interactively select a version.\nPlease use the '--sha' option to specify a version."
)
older_commits_choice = questionary.Choice(
title=[("fg:ansiyellow", "older commits"), ("class:choice-default", "")], value=""
)
Expand Down
34 changes: 29 additions & 5 deletions nf_core/components/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ def _get_bioconda_tool(self):
log.warning(
f"Could not find Conda dependency using the Anaconda API: '{self.tool_conda_name if self.tool_conda_name else self.component}'"
)
if not nf_core.utils.is_interactive():
log.warning(
f"{e}\nBioconda package not found and session is not interactive. "
"Building module without tool software and meta."
)
break
if rich.prompt.Confirm.ask("[violet]Do you want to enter a different Bioconda package name?"):
self.tool_conda_name = rich.prompt.Prompt.ask("[violet]Name of Bioconda package").strip()
continue
Expand Down Expand Up @@ -272,6 +278,9 @@ def _get_module_structure_components(self):
"For example: {}".format(", ".join(process_label_defaults))
)
while self.process_label is None:
nf_core.utils.require_interactive(
"Process label not provided.\nPlease provide the '--process-label' option."
)
self.process_label = questionary.autocomplete(
"Process resource label:",
choices=process_label_defaults,
Expand All @@ -287,6 +296,9 @@ def _get_module_structure_components(self):
"[link=https://github.com/nf-core/modules/blob/master/modules/nf-core/bwa/index/main.nf]indexing reference genome files[/link]."
)
while self.has_meta is None:
nf_core.utils.require_interactive(
"Meta map requirement not specified.\nPlease provide the '--has-meta' or '--no-meta' option."
)
self.has_meta = rich.prompt.Confirm.ask(
"[violet]Will the module require a meta map of sample information?",
default=True,
Expand Down Expand Up @@ -346,7 +358,9 @@ def _collect_name_prompt(self):
elif self.component_type == "subworkflows":
log.warning("Subworkflow name must be lower-case letters only, with no punctuation")
name_clean = re.sub(r"[^a-z\d/]", "", self.component.lower())
if rich.prompt.Confirm.ask(f"[violet]Change '{self.component}' to '{name_clean}'?"):
if not nf_core.utils.is_interactive():
self.component = name_clean
elif rich.prompt.Confirm.ask(f"[violet]Change '{self.component}' to '{name_clean}'?"):
self.component = name_clean
else:
self.component = ""
Expand All @@ -363,6 +377,10 @@ def _collect_name_prompt(self):

# Prompt for new entry if we reset
if self.component == "":
nf_core.utils.require_interactive(
f"No {self.component_type[:-1]} name provided.\n"
f"Please provide the {self.component_type[:-1]} name as a command-line argument."
)
if self.component_type == "modules":
self.component = rich.prompt.Prompt.ask("[violet]Name of tool/subtool").strip()
elif self.component_type == "subworkflows":
Expand Down Expand Up @@ -435,6 +453,7 @@ def _get_username(self):
while self.author is None or not github_username_regex.match(self.author):
if self.author is not None and not github_username_regex.match(self.author):
log.warning("Does not look like a valid GitHub username (must start with an '@')!")
nf_core.utils.require_interactive("GitHub username not provided.\nPlease provide the '--author' option.")
self.author = rich.prompt.Prompt.ask(
f"[violet]GitHub Username:[/]{' (@author)' if author_default is None else ''}",
default=author_default,
Expand Down Expand Up @@ -486,10 +505,15 @@ def _copy_old_files(self, component_old_path):
def _print_and_delete_pytest_files(self):
"""Prompt if pytest files should be deleted and printed to stdout"""
pytest_dir = Path(self.directory, "tests", self.component_type, self.org, self.component_dir)
if rich.prompt.Confirm.ask(
"[violet]Do you want to delete the pytest files?[/]\nPytest file 'main.nf' will be printed to standard output to allow migrating the tests manually to 'main.nf.test'.",
default=False,
):
if not nf_core.utils.is_interactive():
log.info("Non-interactive session: skipping pytest file deletion prompt.")
delete_pytest = False
else:
delete_pytest = rich.prompt.Confirm.ask(
"[violet]Do you want to delete the pytest files?[/]\nPytest file 'main.nf' will be printed to standard output to allow migrating the tests manually to 'main.nf.test'.",
default=False,
)
if delete_pytest:
with open(pytest_dir / "main.nf") as fh:
log.info(fh.read())
if pytest_dir.is_symlink():
Expand Down
4 changes: 4 additions & 0 deletions nf_core/components/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ def init_mod_name(self, component: str | None) -> str:
module: str: Module name to check
"""
if component is None:
nf_core.utils.require_interactive(
f"No {self.component_type[:-1]} name provided.\n"
f"Please provide the {self.component_type[:-1]} name as a command-line argument."
)
self.local = questionary.confirm(
f"Is the {self.component_type[:-1]} locally installed?", style=nf_core.utils.nfcore_question_style
).unsafe_ask()
Expand Down
15 changes: 15 additions & 0 deletions nf_core/components/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ def collect_and_verify_name(
Check that the supplied name is an available module/subworkflow.
"""
if component is None:
nf_core.utils.require_interactive(
f"No {self.component_type[:-1]} name provided.\n"
f"Please provide the {self.component_type[:-1]} name as a command-line argument:\n"
f" nf-core {self.component_type} install <name>"
Comment thread
ewels marked this conversation as resolved.
Outdated
)
component = questionary.autocomplete(
f"{'Tool' if self.component_type == 'modules' else 'Subworkflow'} name:",
choices=sorted(modules_repo.get_avail_components(self.component_type, commit=self.current_sha)),
Expand Down Expand Up @@ -274,6 +279,11 @@ def check_component_installed(self, component, current_version, component_dir, m
log.info(f"{self.component_type[:-1].title()} '{component}' is already installed.")

if prompt:
nf_core.utils.require_interactive(
f"{self.component_type[:-1].title()} '{component}' is already installed and '--prompt' "
"cannot be used.\n"
"Use '--force' to force reinstallation in headless environments."
)
message = (
"?" if self.component_type == "modules" else " of this subworkflow and all it's imported modules?"
)
Expand Down Expand Up @@ -304,6 +314,11 @@ def get_version(self, component, sha, prompt, current_version, modules_repo):
if sha:
version = sha
elif prompt:
nf_core.utils.require_interactive(
f"Cannot interactively select a version for '{component}'.\n"
"Please specify the version using the '--sha' option:\n"
f" nf-core {self.component_type} install --sha <commit_sha> {component}"
)
try:
version = prompt_component_version_sha(
component,
Expand Down
14 changes: 14 additions & 0 deletions nf_core/components/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ def patch(self, component=None):
components = self.modules_json.get_all_components(self.component_type).get(self.modules_repo.remote_url)

if component is None:
nf_core.utils.require_interactive(
f"No {self.component_type[:-1]} name provided.\n"
f"Please provide the {self.component_type[:-1]} name as a command-line argument."
)
choices = [
component if directory == self.modules_repo.repo_path else f"{directory}/{component}"
for directory, component in components
Expand Down Expand Up @@ -94,6 +98,9 @@ def patch(self, component=None):
patch_path = Path(self.directory, patch_relpath)

if patch_path.exists():
nf_core.utils.require_interactive(
f"Patch already exists for '{component_fullname}'.\nPlease remove the existing patch file first."
)
remove = questionary.confirm(
f"Patch exists for {self.component_type[:-1]} '{component_fullname}'. Do you want to regenerate it?",
style=nf_core.utils.nfcore_question_style,
Expand Down Expand Up @@ -157,6 +164,10 @@ def remove(self, component):
components = self.modules_json.get_all_components(self.component_type).get(self.modules_repo.remote_url)

if component is None:
nf_core.utils.require_interactive(
f"No {self.component_type[:-1]} name provided.\n"
f"Please provide the {self.component_type[:-1]} name as a command-line argument."
)
choices = [
component if directory == self.modules_repo.repo_path else f"{directory}/{component}"
for directory, component in components
Expand Down Expand Up @@ -199,6 +210,9 @@ def remove(self, component):
component_path = Path(self.directory, component_relpath)

if patch_path.exists():
nf_core.utils.require_interactive(
f"Patch exists for '{component_fullname}'.\nPlease remove the existing patch file first."
)
remove = questionary.confirm(
f"Patch exists for {self.component_type[:-1]} '{component_fullname}'. Are you sure you want to remove?",
style=nf_core.utils.nfcore_question_style,
Expand Down
8 changes: 8 additions & 0 deletions nf_core/components/remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ def remove(self, component, repo_url=None, repo_path=None, removed_by=None, remo
if repo_url is None:
repo_url = self.modules_repo.remote_url
if component is None:
nf_core.utils.require_interactive(
f"No {self.component_type[:-1]} name provided.\n"
f"Please provide the {self.component_type[:-1]} name as a command-line argument."
)
component = questionary.autocomplete(
f"{self.component_type[:-1]} name:",
choices=self.components_from_repo(repo_path),
Expand Down Expand Up @@ -130,6 +134,10 @@ def remove(self, component, repo_url=None, repo_path=None, removed_by=None, remo
)
# ask the user if they still want to remove the component, add it back otherwise
if not force:
nf_core.utils.require_interactive(
f"{self.component_type[:-1].title()} '{component}' is still included in workflow files.\n"
"Use '--force' to force removal."
)
if not questionary.confirm(
f"Do you still want to remove the {self.component_type[:-1]} '{component}'?",
style=nf_core.utils.nfcore_question_style,
Expand Down
Loading
Loading