From b38506fdf6b8a63078c7b9d726559860c51fb0a3 Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Wed, 10 Dec 2025 16:34:32 +1100 Subject: [PATCH 01/12] add default process resource screen and field validation --- nf_core/configs/create/__init__.py | 2 + nf_core/configs/create/basicdetails.py | 2 +- nf_core/configs/create/defaultprocessres.py | 82 +++++++++++++++++++++ nf_core/configs/create/utils.py | 21 ++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 nf_core/configs/create/defaultprocessres.py diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index ef8bd8b92f..73f32bda14 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -20,6 +20,7 @@ from nf_core.configs.create.hpcquestion import ChooseHpc from nf_core.configs.create.nfcorequestion import ChooseNfcoreConfig from nf_core.configs.create.welcome import WelcomeScreen +from nf_core.configs.create.defaultprocessres import DefaultProcess ## General utilities from nf_core.utils import LoggingConsole @@ -59,6 +60,7 @@ class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): "final": FinalScreen, "hpc_question": ChooseHpc, "hpc_customisation": HpcCustomisation, + "default_process_resources": DefaultProcess, "final_infra_details": FinalInfraDetails, } diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index d9fb416c72..d5f75c0bfe 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -106,7 +106,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: if self.parent.CONFIG_TYPE == "infrastructure": self.parent.push_screen("hpc_question") elif self.parent.CONFIG_TYPE == "pipeline": - self.parent.push_screen("final") + self.parent.push_screen("default_process_resources") except ValueError: pass diff --git a/nf_core/configs/create/defaultprocessres.py b/nf_core/configs/create/defaultprocessres.py new file mode 100644 index 0000000000..820cbe2676 --- /dev/null +++ b/nf_core/configs/create/defaultprocessres.py @@ -0,0 +1,82 @@ +"""Get information about which process/label the user wants to configure.""" + +from textwrap import dedent + +from textual import on +from textual.app import ComposeResult +from textual.containers import Center, Horizontal +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Input, Markdown + +from nf_core.configs.create.utils import ( + ConfigsCreateConfig, + TextInput, +) ## TODO Move somewhere common? +from nf_core.utils import add_hide_class, remove_hide_class + + +class DefaultProcess(Screen): + """Get default process resource requirements.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown( + dedent( + """ + # Default process resources + """ + ) + ) + yield TextInput( + "default_process_ncpus", + "2", + "Number of CPUs to use by default for all processes.", + "2", + classes="column", + ) + yield TextInput( + "default_process_memgb", + "8", + "Amount of memory in GB to use by default for all processes.", + "8", + classes="column", + ) + yield Markdown("The walltime required by default for all processes.") + with Horizontal(): + yield TextInput( + "default_process_hours", + "1", + "Hours:", + "1", + classes="column", + ) + yield TextInput( + "default_process_minutes", + "0", + "Minutes:", + "0", + classes="column", + ) + yield TextInput( + "default_process_seconds", + "0", + "Seconds:", + "0", + classes="column", + ) + yield Center( + Button("Back", id="back", variant="default"), + Button("Next", id="next", variant="success"), + classes="cta", + ) + + # Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the ConfigsCreateConfig class) with the values from the text inputs + @on(Button.Pressed) + def on_button_pressed(self, event: Button.Pressed) -> None: + """Save fields to the config.""" + try: + if event.button.id == "next": + self.parent.push_screen("final") + except ValueError: + pass diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 3f7dd54e9d..081b673baf 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -52,6 +52,16 @@ class ConfigsCreateConfig(BaseModel): """ Config description """ config_profile_url: Optional[str] = None """ Config institution URL """ + default_process_ncpus: Optional[str] = None + """ Default number of CPUs """ + default_process_memgb: Optional[str] = None + """ Default amount of memory """ + default_process_hours: Optional[str] = None + """ Default walltime - hours """ + default_process_minutes: Optional[str] = None + """ Default walltime - minutes """ + default_process_seconds: Optional[str] = None + """ Default walltime - seconds """ is_nfcore: Optional[bool] = None """ Whether the config is part of the nf-core organisation """ @@ -142,6 +152,17 @@ def url_prefix(cls, v: str, info: ValidationInfo) -> str: ) return v + @field_validator("default_process_ncpus", "default_process_memgb", "default_process_hours", "default_process_minutes", "default_process_seconds") + @classmethod + def integer_valid(cls, v: str, info: ValidationInfo) -> str: + """Check that integer values are non-empty and positive.""" + if v.strip() == "": + raise ValueError("Cannot be left empty.") + try: + int(v.strip()) + except ValueError: + raise ValueError("Must be an integer.") + return v ## TODO Duplicated from pipelines utils - move to common location if possible (validation seems to be context specific so possibly not) class TextInput(Static): From e4dbdfb86e8143d7ea8c2ac35a70d754677b5545 Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Thu, 18 Dec 2025 14:07:59 +1100 Subject: [PATCH 02/12] fix field validation and config creation --- nf_core/configs/create/basicdetails.py | 4 +- nf_core/configs/create/create.py | 4 +- nf_core/configs/create/defaultprocessres.py | 14 +++++ nf_core/configs/create/utils.py | 68 ++++++++++++++++----- 4 files changed, 73 insertions(+), 17 deletions(-) diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index d5f75c0bfe..4d3e65f468 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -12,6 +12,7 @@ from nf_core.configs.create.utils import ( ConfigsCreateConfig, TextInput, + init_context ) ## TODO Move somewhere common? from nf_core.utils import add_hide_class, remove_hide_class @@ -101,7 +102,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: else: text_input.query_one(".validation_msg").update("") try: - self.parent.TEMPLATE_CONFIG = ConfigsCreateConfig(**config) + with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): + self.parent.TEMPLATE_CONFIG = ConfigsCreateConfig(**config) if event.button.id == "next": if self.parent.CONFIG_TYPE == "infrastructure": self.parent.push_screen("hpc_question") diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py index 0d58551089..738acafabd 100644 --- a/nf_core/configs/create/create.py +++ b/nf_core/configs/create/create.py @@ -3,6 +3,7 @@ """ from nf_core.configs.create.utils import ConfigsCreateConfig, generate_config_entry +from re import sub class ConfigCreate: @@ -31,7 +32,8 @@ def construct_params(self, contact, handle, description, url): def write_to_file(self): ## File name option - filename = "_".join(self.template_config.general_config_name) + ".conf" + config_name = str(self.template_config.general_config_name).strip() + filename = sub(r'\s+', '_', config_name) + ".conf" ## Collect all config entries per scope, for later checking scope needs to be written validparams = self.construct_params( diff --git a/nf_core/configs/create/defaultprocessres.py b/nf_core/configs/create/defaultprocessres.py index 820cbe2676..8f49d92db2 100644 --- a/nf_core/configs/create/defaultprocessres.py +++ b/nf_core/configs/create/defaultprocessres.py @@ -11,6 +11,7 @@ from nf_core.configs.create.utils import ( ConfigsCreateConfig, TextInput, + init_context ) ## TODO Move somewhere common? from nf_core.utils import add_hide_class, remove_hide_class @@ -75,7 +76,20 @@ def compose(self) -> ComposeResult: @on(Button.Pressed) def on_button_pressed(self, event: Button.Pressed) -> None: """Save fields to the config.""" + new_config = {} + for text_input in self.query("TextInput"): + this_input = text_input.query_one(Input) + validation_result = this_input.validate(this_input.value) + new_config[text_input.field_id] = this_input.value + if not validation_result.is_valid: + text_input.query_one(".validation_msg").update("\n".join(validation_result.failure_descriptions)) + else: + text_input.query_one(".validation_msg").update("") try: + config = self.parent.TEMPLATE_CONFIG.__dict__ + config.update(new_config) + with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): + self.parent.TEMPLATE_CONFIG = ConfigsCreateConfig(**config) if event.button.id == "next": self.parent.push_screen("final") except ValueError: diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 081b673baf..bf216fba64 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -88,19 +88,37 @@ def notempty(cls, v: str) -> str: def path_valid(cls, v: str, info: ValidationInfo) -> str: """Check that a path is valid.""" context = info.context - if context and not context["is_infrastructure"]: + if context and (not context["is_infrastructure"] and not context["is_nfcore"]): if v.strip() == "": raise ValueError("Cannot be left empty.") if not Path(v).is_dir(): raise ValueError("Must be a valid path.") return v - @field_validator("config_profile_contact", "config_profile_description", "config_pipeline_name") + @field_validator("config_pipeline_name") @classmethod - def notempty_nfcore(cls, v: str, info: ValidationInfo) -> str: - """Check that string values are not empty when the config is nf-core.""" + def nfcore_name_valid(cls, v: str, info: ValidationInfo) -> str: + """Check that an nf-core pipeline name is valid.""" context = info.context - if context and context["is_nfcore"]: + if context and (not context["is_infrastructure"] and context["is_nfcore"]): + if v.strip() == "": + raise ValueError("Cannot be left empty.") + return v + + @field_validator("config_profile_description") + @classmethod + def notempty_description(cls, v: str) -> str: + """Check that description is not empty when.""" + if v.strip() == "": + raise ValueError("Cannot be left empty.") + return v + + @field_validator("config_profile_contact") + @classmethod + def notempty_contact(cls, v: str, info: ValidationInfo) -> str: + """Check that contact values are not empty when the config is infrastructure.""" + context = info.context + if context and context["is_infrastructure"]: if v.strip() == "": raise ValueError("Cannot be left empty.") return v @@ -113,7 +131,7 @@ def handle_prefix(cls, v: str, info: ValidationInfo) -> str: """Check that GitHub handles start with '@'. Make providing a handle mandatory for nf-core configs""" context = info.context - if context and context["is_nfcore"]: + if context and context["is_infrastructure"]: if v.strip() == "": raise ValueError("Cannot be left empty.") elif not re.match( @@ -132,7 +150,7 @@ def handle_prefix(cls, v: str, info: ValidationInfo) -> str: def url_prefix(cls, v: str, info: ValidationInfo) -> str: """Check that institutional web links start with valid URL prefix.""" context = info.context - if context and context["is_nfcore"]: + if context and context["is_infrastructure"]: if v.strip() == "": raise ValueError("Cannot be left empty.") elif not re.match( @@ -152,16 +170,36 @@ def url_prefix(cls, v: str, info: ValidationInfo) -> str: ) return v - @field_validator("default_process_ncpus", "default_process_memgb", "default_process_hours", "default_process_minutes", "default_process_seconds") + @field_validator("default_process_ncpus", "default_process_memgb") @classmethod - def integer_valid(cls, v: str, info: ValidationInfo) -> str: + def pos_integer_valid(cls, v: str, info: ValidationInfo) -> str: """Check that integer values are non-empty and positive.""" - if v.strip() == "": - raise ValueError("Cannot be left empty.") - try: - int(v.strip()) - except ValueError: - raise ValueError("Must be an integer.") + context = info.context + if context and not context["is_infrastructure"]: + if v.strip() == "": + raise ValueError("Cannot be left empty.") + try: + v_int = int(v.strip()) + except ValueError: + raise ValueError("Must be an integer.") + if not v_int > 0: + raise ValueError("Must be a positive integer.") + return v + + @field_validator("default_process_hours", "default_process_minutes", "default_process_seconds") + @classmethod + def non_neg_integer_valid(cls, v: str, info: ValidationInfo) -> str: + """Check that integer values are non-empty and non-negative.""" + context = info.context + if context and not context["is_infrastructure"]: + if v.strip() == "": + raise ValueError("Cannot be left empty.") + try: + v_int = int(v.strip()) + except ValueError: + raise ValueError("Must be an integer.") + if not v_int >= 0: + raise ValueError("Must be a non-negative integer.") return v ## TODO Duplicated from pipelines utils - move to common location if possible (validation seems to be context specific so possibly not) From d1f1cc86f451b2a8a92cd6804d12b34fa8de6164 Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Tue, 6 Jan 2026 17:01:21 +1100 Subject: [PATCH 03/12] WIP: add custom process config screen --- nf_core/configs/create/__init__.py | 2 + nf_core/configs/create/customprocessres.py | 151 ++++++++++++++++++++ nf_core/configs/create/defaultprocessres.py | 9 +- nf_core/configs/create/utils.py | 18 ++- 4 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 nf_core/configs/create/customprocessres.py diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 73f32bda14..9e2bf68d49 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -21,6 +21,7 @@ from nf_core.configs.create.nfcorequestion import ChooseNfcoreConfig from nf_core.configs.create.welcome import WelcomeScreen from nf_core.configs.create.defaultprocessres import DefaultProcess +from nf_core.configs.create.customprocessres import CustomProcess ## General utilities from nf_core.utils import LoggingConsole @@ -61,6 +62,7 @@ class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): "hpc_question": ChooseHpc, "hpc_customisation": HpcCustomisation, "default_process_resources": DefaultProcess, + "custom_process_resources": CustomProcess, "final_infra_details": FinalInfraDetails, } diff --git a/nf_core/configs/create/customprocessres.py b/nf_core/configs/create/customprocessres.py new file mode 100644 index 0000000000..0418baf698 --- /dev/null +++ b/nf_core/configs/create/customprocessres.py @@ -0,0 +1,151 @@ +"""Get information about which process/label the user wants to configure.""" + +from textwrap import dedent + +from textual import on +from textual.app import ComposeResult +from textual.containers import Center, Horizontal +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Input, Markdown, Switch, Label +from enum import Enum +from nf_core.utils import add_hide_class, remove_hide_class + +from nf_core.configs.create.utils import ( + ConfigsCreateConfig, + TextInput, + init_context +) ## TODO Move somewhere common? +from nf_core.utils import add_hide_class, remove_hide_class + + +class CustomProcess(Screen): + """Get default process resource requirements.""" + + def __init__(self) -> None: + super().__init__() + self.select_label = False + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown( + dedent( + """ + # Custom process resources + """ + ) + ) + with Horizontal(): + yield TextInput( + "custom_process_name", + "", + "The name of the process you wish to configure.", + "", + classes="column hide" if self.select_label else "column", + ) + yield TextInput( + "custom_process_label", + "", + "The process label you wish to configure.", + "", + classes="column hide" if not self.select_label else "column", + ) + with Horizontal(): + yield Label( + "Selecting a process by name or label:", + id="toggle_process_name_label_text" + ) + yield Switch( + id="toggle_process_name_label", + value=self.select_label, + ) + yield Label( + "label" if self.select_label else "name", + id="name_or_label_text" + ) + yield TextInput( + "custom_process_ncpus", + "2", + "Number of CPUs to use for the process.", + "2", + classes="column", + ) + yield TextInput( + "custom_process_memgb", + "8", + "Amount of memory in GB to use for the process.", + "8", + classes="column", + ) + yield Markdown("The walltime required for the process.") + with Horizontal(): + yield TextInput( + "custom_process_hours", + "1", + "Hours:", + "1", + classes="column", + ) + yield TextInput( + "custom_process_minutes", + "0", + "Minutes:", + "0", + classes="column", + ) + yield TextInput( + "custom_process_seconds", + "0", + "Seconds:", + "0", + classes="column", + ) + yield Center( + Button("Back", id="back", variant="default"), + Button("Next", id="next", variant="success"), + classes="cta", + ) + + @on(Switch.Changed, "#toggle_process_name_label") + def on_toggle_process_name_label(self, event: Switch.Changed) -> None: + """ Handle toggling the process name/label switch """ + self.select_label = event.value + # Update the input text box and labels + for text_input in self.query("TextInput"): + if text_input.field_id in ["custom_process_name", "custom_process_label"]: + text_input.refresh(repaint=True, layout=True, recompose=True) + if self.select_label: + add_hide_class(self.parent, "custom_process_name") + remove_hide_class(self.parent, "custom_process_label") + else: + add_hide_class(self.parent, "custom_process_label") + remove_hide_class(self.parent, "custom_process_name") + # Update the switch label as well + for label in self.query(Label): + if label.id == "name_or_label_text": + label.update("label" if self.select_label else "name") + + + # Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the ConfigsCreateConfig class) with the values from the text inputs + @on(Button.Pressed) + def on_button_pressed(self, event: Button.Pressed) -> None: + """Save fields to the config.""" + new_config = {} + for text_input in self.query("TextInput"): + this_input = text_input.query_one(Input) + validation_result = this_input.validate(this_input.value) + new_config[text_input.field_id] = this_input.value + if not validation_result.is_valid: + text_input.query_one(".validation_msg").update("\n".join(validation_result.failure_descriptions)) + else: + text_input.query_one(".validation_msg").update("") + try: + with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): + # First, validate the new config data + ConfigsCreateConfig(**new_config) + # If that passes validation, update the existing config + self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) + if event.button.id == "next": + self.parent.push_screen("final") + except ValueError: + pass diff --git a/nf_core/configs/create/defaultprocessres.py b/nf_core/configs/create/defaultprocessres.py index 8f49d92db2..b450a2025b 100644 --- a/nf_core/configs/create/defaultprocessres.py +++ b/nf_core/configs/create/defaultprocessres.py @@ -86,11 +86,12 @@ def on_button_pressed(self, event: Button.Pressed) -> None: else: text_input.query_one(".validation_msg").update("") try: - config = self.parent.TEMPLATE_CONFIG.__dict__ - config.update(new_config) with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): - self.parent.TEMPLATE_CONFIG = ConfigsCreateConfig(**config) + # First, validate the new config data + ConfigsCreateConfig(**new_config) + # If that passes validation, update the existing config + self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) if event.button.id == "next": - self.parent.push_screen("final") + self.parent.push_screen("custom_process_resources") except ValueError: pass diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index bf216fba64..24ff1451b0 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -62,6 +62,20 @@ class ConfigsCreateConfig(BaseModel): """ Default walltime - minutes """ default_process_seconds: Optional[str] = None """ Default walltime - seconds """ + custom_process_ncpus: Optional[str] = None + """ Number of CPUs for process """ + custom_process_memgb: Optional[str] = None + """ Amount of memory for process """ + custom_process_hours: Optional[str] = None + """ Walltime for process - hours """ + custom_process_minutes: Optional[str] = None + """ Walltime for process - minutes """ + custom_process_seconds: Optional[str] = None + """ Walltime for process - seconds """ + named_process_resources: Optional[dict] = None + """ Dictionary containing custom resource requirements for named processes """ + labelled_process_resources: Optional[dict] = None + """ Dictionary containing custom resource requirements for labelled processes """ is_nfcore: Optional[bool] = None """ Whether the config is part of the nf-core organisation """ @@ -170,7 +184,7 @@ def url_prefix(cls, v: str, info: ValidationInfo) -> str: ) return v - @field_validator("default_process_ncpus", "default_process_memgb") + @field_validator("default_process_ncpus", "default_process_memgb", "custom_process_ncpus", "custom_process_memgb") @classmethod def pos_integer_valid(cls, v: str, info: ValidationInfo) -> str: """Check that integer values are non-empty and positive.""" @@ -186,7 +200,7 @@ def pos_integer_valid(cls, v: str, info: ValidationInfo) -> str: raise ValueError("Must be a positive integer.") return v - @field_validator("default_process_hours", "default_process_minutes", "default_process_seconds") + @field_validator("default_process_hours", "default_process_minutes", "default_process_seconds", "custom_process_hours", "custom_process_minutes", "custom_process_seconds") @classmethod def non_neg_integer_valid(cls, v: str, info: ValidationInfo) -> str: """Check that integer values are non-empty and non-negative.""" From 753ac5e0cc61ea6b5f601a8e48f9d4ce525fce82 Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Wed, 7 Jan 2026 17:23:08 +1100 Subject: [PATCH 04/12] WIP: continue work on custom process resource screen --- nf_core/configs/create/customprocessres.py | 101 +++++++++++++-------- 1 file changed, 65 insertions(+), 36 deletions(-) diff --git a/nf_core/configs/create/customprocessres.py b/nf_core/configs/create/customprocessres.py index 0418baf698..41635fca21 100644 --- a/nf_core/configs/create/customprocessres.py +++ b/nf_core/configs/create/customprocessres.py @@ -24,6 +24,8 @@ class CustomProcess(Screen): def __init__(self) -> None: super().__init__() self.select_label = False + self.config_stack = [] + self.current_config = {} def compose(self) -> ComposeResult: yield Header() @@ -41,14 +43,7 @@ def compose(self) -> ComposeResult: "", "The name of the process you wish to configure.", "", - classes="column hide" if self.select_label else "column", - ) - yield TextInput( - "custom_process_label", - "", - "The process label you wish to configure.", - "", - classes="column hide" if not self.select_label else "column", + classes="column", ) with Horizontal(): yield Label( @@ -102,6 +97,7 @@ def compose(self) -> ComposeResult: ) yield Center( Button("Back", id="back", variant="default"), + Button("Configure another process", id="another"), Button("Next", id="next", variant="success"), classes="cta", ) @@ -110,17 +106,7 @@ def compose(self) -> ComposeResult: def on_toggle_process_name_label(self, event: Switch.Changed) -> None: """ Handle toggling the process name/label switch """ self.select_label = event.value - # Update the input text box and labels - for text_input in self.query("TextInput"): - if text_input.field_id in ["custom_process_name", "custom_process_label"]: - text_input.refresh(repaint=True, layout=True, recompose=True) - if self.select_label: - add_hide_class(self.parent, "custom_process_name") - remove_hide_class(self.parent, "custom_process_label") - else: - add_hide_class(self.parent, "custom_process_label") - remove_hide_class(self.parent, "custom_process_name") - # Update the switch label as well + # Update the switch label for label in self.query(Label): if label.id == "name_or_label_text": label.update("label" if self.select_label else "name") @@ -130,22 +116,65 @@ def on_toggle_process_name_label(self, event: Switch.Changed) -> None: @on(Button.Pressed) def on_button_pressed(self, event: Button.Pressed) -> None: """Save fields to the config.""" - new_config = {} + if event.button.id in ["next", "another"]: + tmp_config = {} + for text_input in self.query("TextInput"): + this_input = text_input.query_one(Input) + validation_result = this_input.validate(this_input.value) + tmp_config[text_input.field_id] = this_input.value + if not validation_result.is_valid: + text_input.query_one(".validation_msg").update("\n".join(validation_result.failure_descriptions)) + else: + text_input.query_one(".validation_msg").update("") + # Validate the config + try: + with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): + ConfigsCreateConfig(**tmp_config) + # Add to the config stack + tmp_config['select_label'] = self.select_label + self.config_stack.append(tmp_config) + self.current_config = {} + except ValueError: + pass + elif event.button.id == "back": + try: + self.current_config = self.config_stack.pop() + except IndexError: + self.current_config = {} + + # Reset all input field values for text_input in self.query("TextInput"): this_input = text_input.query_one(Input) - validation_result = this_input.validate(this_input.value) - new_config[text_input.field_id] = this_input.value - if not validation_result.is_valid: - text_input.query_one(".validation_msg").update("\n".join(validation_result.failure_descriptions)) - else: - text_input.query_one(".validation_msg").update("") - try: - with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): - # First, validate the new config data - ConfigsCreateConfig(**new_config) - # If that passes validation, update the existing config - self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) - if event.button.id == "next": - self.parent.push_screen("final") - except ValueError: - pass + this_input.clear() + field_id = text_input.field_id + if field_id in self.current_config: + this_input.insert(self.current_config[field_id], 0) + # Also reset switch + switch_input = self.query_one(Switch) + if 'select_label' in self.current_config: + self.select_label = self.current_config['select_label'] + else: + self.select_label = False + if switch_input.value != self.select_label: + switch_input.toggle() + + if event.button.id == "next": + new_config = {} + for key in ["labelled_process_resources", "named_process_resources"]: + custom_process_resources_dict = self.parent.TEMPLATE_CONFIG.__dict__.get(key) + if custom_process_resources_dict is None: + new_config[key] = {} + else: + new_config[key] = custom_process_resources_dict + for tmp_config in self.config_stack: + select_label = tmp_config.pop('select_label') + process_name_or_label = tmp_config.get('custom_process_name') + if select_label: + key = 'labelled_process_resources' + else: + key = 'named_process_resources' + new_config[key][process_name_or_label] = tmp_config + self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) + self.parent.push_screen("final") + elif event.button.id == "another": + self.parent.push_screen("custom_process_resources") From d15b5305d38a9a3eda7238a0abed1154f658d59c Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Thu, 8 Jan 2026 14:09:01 +1100 Subject: [PATCH 05/12] add nonempty validation to process name --- nf_core/configs/create/customprocessres.py | 12 ++++++++++++ nf_core/configs/create/utils.py | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/nf_core/configs/create/customprocessres.py b/nf_core/configs/create/customprocessres.py index 41635fca21..89ec7fbbea 100644 --- a/nf_core/configs/create/customprocessres.py +++ b/nf_core/configs/create/customprocessres.py @@ -116,6 +116,7 @@ def on_toggle_process_name_label(self, event: Switch.Changed) -> None: @on(Button.Pressed) def on_button_pressed(self, event: Button.Pressed) -> None: """Save fields to the config.""" + proceed = False if event.button.id in ["next", "another"]: tmp_config = {} for text_input in self.query("TextInput"): @@ -130,6 +131,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: try: with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): ConfigsCreateConfig(**tmp_config) + proceed = True # Add to the config stack tmp_config['select_label'] = self.select_label self.config_stack.append(tmp_config) @@ -137,11 +139,17 @@ def on_button_pressed(self, event: Button.Pressed) -> None: except ValueError: pass elif event.button.id == "back": + proceed = True try: self.current_config = self.config_stack.pop() except IndexError: self.current_config = {} + # Only continue if the user clicked 'next' or 'another' and we have a valid config + # or if the user clicked 'back' + if not proceed: + return + # Reset all input field values for text_input in self.query("TextInput"): this_input = text_input.query_one(Input) @@ -149,6 +157,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: field_id = text_input.field_id if field_id in self.current_config: this_input.insert(self.current_config[field_id], 0) + else: + text_input.refresh(repaint=True, layout=True, recompose=True) # Also reset switch switch_input = self.query_one(Switch) if 'select_label' in self.current_config: @@ -159,6 +169,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: switch_input.toggle() if event.button.id == "next": + # If finalising the custom resources, add them all to the config now new_config = {} for key in ["labelled_process_resources", "named_process_resources"]: custom_process_resources_dict = self.parent.TEMPLATE_CONFIG.__dict__.get(key) @@ -177,4 +188,5 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) self.parent.push_screen("final") elif event.button.id == "another": + # If configuring another process, push a new copy of this screen to the stack self.parent.push_screen("custom_process_resources") diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 24ff1451b0..8dbce93f73 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -62,6 +62,8 @@ class ConfigsCreateConfig(BaseModel): """ Default walltime - minutes """ default_process_seconds: Optional[str] = None """ Default walltime - seconds """ + custom_process_name: Optional[str] = None + """" Name or label of a process to configure """ custom_process_ncpus: Optional[str] = None """ Number of CPUs for process """ custom_process_memgb: Optional[str] = None @@ -184,6 +186,16 @@ def url_prefix(cls, v: str, info: ValidationInfo) -> str: ) return v + @field_validator("custom_process_name") + @classmethod + def notempty_process_name(cls, v: str, info: ValidationInfo) -> str: + """Check that the custom process name or label isn't empty.""" + context = info.context + if context and not context["is_infrastructure"]: + if v.strip() == "": + raise ValueError("Cannot be left empty.") + return v + @field_validator("default_process_ncpus", "default_process_memgb", "custom_process_ncpus", "custom_process_memgb") @classmethod def pos_integer_valid(cls, v: str, info: ValidationInfo) -> str: From d6d6e117a619848149af3732624a057f93e5c6d8 Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Thu, 8 Jan 2026 15:05:11 +1100 Subject: [PATCH 06/12] allow reverting from final screen --- nf_core/configs/create/customprocessres.py | 62 +++++++++------------- nf_core/configs/create/final.py | 6 ++- 2 files changed, 30 insertions(+), 38 deletions(-) diff --git a/nf_core/configs/create/customprocessres.py b/nf_core/configs/create/customprocessres.py index 89ec7fbbea..3fd6dc50b5 100644 --- a/nf_core/configs/create/customprocessres.py +++ b/nf_core/configs/create/customprocessres.py @@ -116,7 +116,6 @@ def on_toggle_process_name_label(self, event: Switch.Changed) -> None: @on(Button.Pressed) def on_button_pressed(self, event: Button.Pressed) -> None: """Save fields to the config.""" - proceed = False if event.button.id in ["next", "another"]: tmp_config = {} for text_input in self.query("TextInput"): @@ -131,25 +130,37 @@ def on_button_pressed(self, event: Button.Pressed) -> None: try: with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): ConfigsCreateConfig(**tmp_config) - proceed = True # Add to the config stack tmp_config['select_label'] = self.select_label self.config_stack.append(tmp_config) - self.current_config = {} + if event.button.id == "another": + # If configuring another process, push a blank config to the config stack + # and push a new copy of this screen to the screen stack + self.config_stack.append({}) + self.parent.push_screen("custom_process_resources") + else: + # If finalising the custom resources, add them all to the config now + new_config = {} + for key in ["labelled_process_resources", "named_process_resources"]: + new_config[key] = self.parent.TEMPLATE_CONFIG.__dict__.get(key) + if new_config[key] is None: + new_config[key] = {} + for tmp_config in self.config_stack: + select_label = tmp_config['select_label'] + process_name_or_label = tmp_config.get('custom_process_name') + key = "labelled_process_resources" if select_label else "named_process_resources" + new_config[key][process_name_or_label] = tmp_config + self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) + self.parent.push_screen("final") except ValueError: pass - elif event.button.id == "back": - proceed = True - try: - self.current_config = self.config_stack.pop() - except IndexError: - self.current_config = {} - - # Only continue if the user clicked 'next' or 'another' and we have a valid config - # or if the user clicked 'back' - if not proceed: - return + def on_screen_resume(self): + # Grab the last config in the stack if it exists + try: + self.current_config = self.config_stack.pop() + except IndexError: + self.current_config = {} # Reset all input field values for text_input in self.query("TextInput"): this_input = text_input.query_one(Input) @@ -167,26 +178,3 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.select_label = False if switch_input.value != self.select_label: switch_input.toggle() - - if event.button.id == "next": - # If finalising the custom resources, add them all to the config now - new_config = {} - for key in ["labelled_process_resources", "named_process_resources"]: - custom_process_resources_dict = self.parent.TEMPLATE_CONFIG.__dict__.get(key) - if custom_process_resources_dict is None: - new_config[key] = {} - else: - new_config[key] = custom_process_resources_dict - for tmp_config in self.config_stack: - select_label = tmp_config.pop('select_label') - process_name_or_label = tmp_config.get('custom_process_name') - if select_label: - key = 'labelled_process_resources' - else: - key = 'named_process_resources' - new_config[key][process_name_or_label] = tmp_config - self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) - self.parent.push_screen("final") - elif event.button.id == "another": - # If configuring another process, push a new copy of this screen to the stack - self.parent.push_screen("custom_process_resources") diff --git a/nf_core/configs/create/final.py b/nf_core/configs/create/final.py index 457293dd1c..c995640987 100644 --- a/nf_core/configs/create/final.py +++ b/nf_core/configs/create/final.py @@ -28,7 +28,11 @@ def compose(self) -> ComposeResult: ".", classes="row", ) - yield Center(Button("Save and close!", id="close_app", variant="success"), classes="cta") + yield Center( + Button("Back", id="back", variant="default"), + Button("Save and close!", id="close_app", variant="success"), + classes="cta" + ) def _create_config(self) -> None: """Create the config.""" From 3ab1999966a0ec731de227b51836d10f073278f2 Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Thu, 8 Jan 2026 16:40:23 +1100 Subject: [PATCH 07/12] write basic process config to file --- nf_core/configs/create/create.py | 133 ++++++++++++++++++++++++++----- nf_core/configs/create/final.py | 2 +- 2 files changed, 113 insertions(+), 22 deletions(-) diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py index 738acafabd..d4a0be7c99 100644 --- a/nf_core/configs/create/create.py +++ b/nf_core/configs/create/create.py @@ -7,49 +7,140 @@ class ConfigCreate: - def __init__(self, template_config: ConfigsCreateConfig): + def __init__(self, template_config: ConfigsCreateConfig, config_type: str): self.template_config = template_config + self.config_type = config_type - def construct_params(self, contact, handle, description, url): + def construct_info_params(self): final_params = {} + contact = self.template_config.config_profile_contact + handle = self.template_config.config_profile_handle + description = self.template_config.config_profile_description + url = self.template_config.config_profile_url - if contact is not None: - if handle is not None: + if contact: + if handle: config_contact = contact + " (" + handle + ")" else: config_contact = contact final_params["config_profile_contact"] = config_contact - elif handle is not None: + elif handle: final_params["config_profile_contact"] = handle - if description is not None: + if description: final_params["config_profile_description"] = description - if url is not None: + if url: final_params["config_profile_url"] = url return final_params + def construct_params_str(self): + info_params = self.construct_info_params() + + info_params_str_list = [ + f' {key} = "{value}"' + for key, value in info_params.items() + if value + ] + + params_section = [ + 'params {', + *info_params_str_list, + '}', + ] + + return '\n'.join(params_section) + '\n' + + def get_resource_strings(self, cpus, memory, hours, minutes, seconds, prefix=''): + cpus_int = int(cpus) + cpus_str = f'cpus = {cpus_int}' + + memory_int = int(memory) + memory_str = f'memory = {memory_int}.Gb' + + time_h = int(hours) + time_m = int(minutes) + time_s = int(seconds) + time_str = f"time = '{time_h}h {time_m}m {time_s}s'" + + resources = [cpus_str, memory_str, time_str] + return [ + f'{prefix}{res}' + for res in resources + ] + + def construct_process_config_str(self): + process_config_str_list = [] + + # Construct default resources + default_resources = self.get_resource_strings( + cpus=self.template_config.default_process_ncpus, + memory=self.template_config.default_process_memgb, + hours=self.template_config.default_process_hours, + minutes=self.template_config.default_process_minutes, + seconds=self.template_config.default_process_seconds, + prefix=' ' + ) + + # Construct named process resources + named_resources = [] + if self.template_config.named_process_resources: + for process_name, process_resources in self.template_config.named_process_resources.items(): + named_resources.append( + f" withName: '{process_name}'" + " {" + ) + named_resources.extend(self.get_resource_strings( + cpus=process_resources['custom_process_ncpus'], + memory=process_resources['custom_process_memgb'], + hours=process_resources['custom_process_hours'], + minutes=process_resources['custom_process_minutes'], + seconds=process_resources['custom_process_seconds'], + prefix=' ' + )) + named_resources.append(' }') + + # Construct labelled process resources + labelled_resources = [] + if self.template_config.labelled_process_resources: + for process_label, process_resources in self.template_config.labelled_process_resources.items(): + labelled_resources.append( + f" withLabel: '{process_label}'" + " {" + ) + labelled_resources.extend(self.get_resource_strings( + cpus=process_resources['custom_process_ncpus'], + memory=process_resources['custom_process_memgb'], + hours=process_resources['custom_process_hours'], + minutes=process_resources['custom_process_minutes'], + seconds=process_resources['custom_process_seconds'], + prefix=' ' + )) + labelled_resources.append(' }') + + process_section = [ + 'process {', + *default_resources, + *named_resources, + *labelled_resources, + '}', + ] + + return '\n'.join(process_section) + '\n' + def write_to_file(self): ## File name option config_name = str(self.template_config.general_config_name).strip() filename = sub(r'\s+', '_', config_name) + ".conf" ## Collect all config entries per scope, for later checking scope needs to be written - validparams = self.construct_params( - self.template_config.config_profile_contact, - self.template_config.config_profile_handle, - self.template_config.config_profile_description, - self.template_config.config_profile_url, - ) + params_section_str = self.construct_params_str() + + if self.config_type == 'pipeline': + process_section_str = self.construct_process_config_str() + else: + process_section_str = '' with open(filename, "w+") as file: ## Write params - if any(validparams): - file.write("params {\n") - for entry_key, entry_value in validparams.items(): - if entry_value != "": - file.write(generate_config_entry(self, entry_key, entry_value)) - else: - continue - file.write("}\n") + file.write(params_section_str) + file.write(process_section_str) diff --git a/nf_core/configs/create/final.py b/nf_core/configs/create/final.py index c995640987..b9aeddc3c5 100644 --- a/nf_core/configs/create/final.py +++ b/nf_core/configs/create/final.py @@ -36,7 +36,7 @@ def compose(self) -> ComposeResult: def _create_config(self) -> None: """Create the config.""" - create_obj = ConfigCreate(template_config=self.parent.TEMPLATE_CONFIG) + create_obj = ConfigCreate(template_config=self.parent.TEMPLATE_CONFIG, config_type=self.parent.CONFIG_TYPE) create_obj.write_to_file() @on(Button.Pressed, "#close_app") From 52de1f10f27f02507fe62091d948bc6a30a4b400 Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Wed, 14 Jan 2026 11:38:32 +1100 Subject: [PATCH 08/12] validate final output dir, allow optional custom resources --- nf_core/configs/create/__init__.py | 5 +++-- nf_core/configs/create/create.py | 4 +++- nf_core/configs/create/customprocessres.py | 4 ++-- nf_core/configs/create/defaultprocessres.py | 7 +++++-- nf_core/configs/create/final.py | 20 +++++++++++++++----- nf_core/configs/create/utils.py | 12 ++++++++++++ 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 9e2bf68d49..8522139e14 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -109,11 +109,12 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.button.id == "finish": self.push_screen("final") ## General options - if event.button.id == "close_app": - self.exit(return_code=0) if event.button.id == "back": self.pop_screen() + def close_app(self): + self.exit(return_code=0) + ## User theme options def action_toggle_dark(self) -> None: """An action to toggle dark mode.""" diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py index d4a0be7c99..1fa89beb2c 100644 --- a/nf_core/configs/create/create.py +++ b/nf_core/configs/create/create.py @@ -7,9 +7,10 @@ class ConfigCreate: - def __init__(self, template_config: ConfigsCreateConfig, config_type: str): + def __init__(self, template_config: ConfigsCreateConfig, config_type: str, config_dir:str = '.'): self.template_config = template_config self.config_type = config_type + self.config_dir = sub(r'/$', '', config_dir) def construct_info_params(self): final_params = {} @@ -131,6 +132,7 @@ def write_to_file(self): ## File name option config_name = str(self.template_config.general_config_name).strip() filename = sub(r'\s+', '_', config_name) + ".conf" + filename = f'{self.config_dir}/{filename}' ## Collect all config entries per scope, for later checking scope needs to be written params_section_str = self.construct_params_str() diff --git a/nf_core/configs/create/customprocessres.py b/nf_core/configs/create/customprocessres.py index 3fd6dc50b5..8bfac5cae8 100644 --- a/nf_core/configs/create/customprocessres.py +++ b/nf_core/configs/create/customprocessres.py @@ -98,7 +98,7 @@ def compose(self) -> ComposeResult: yield Center( Button("Back", id="back", variant="default"), Button("Configure another process", id="another"), - Button("Next", id="next", variant="success"), + Button("Finish", id="finish_config", variant="success"), classes="cta", ) @@ -116,7 +116,7 @@ def on_toggle_process_name_label(self, event: Switch.Changed) -> None: @on(Button.Pressed) def on_button_pressed(self, event: Button.Pressed) -> None: """Save fields to the config.""" - if event.button.id in ["next", "another"]: + if event.button.id in ["finish_config", "another"]: tmp_config = {} for text_input in self.query("TextInput"): this_input = text_input.query_one(Input) diff --git a/nf_core/configs/create/defaultprocessres.py b/nf_core/configs/create/defaultprocessres.py index b450a2025b..98205a2b65 100644 --- a/nf_core/configs/create/defaultprocessres.py +++ b/nf_core/configs/create/defaultprocessres.py @@ -68,7 +68,8 @@ def compose(self) -> ComposeResult: ) yield Center( Button("Back", id="back", variant="default"), - Button("Next", id="next", variant="success"), + Button("Configure specific processes", id="config_specific_process", variant="success"), + Button("Finish", id="finish_config", variant="success"), classes="cta", ) @@ -91,7 +92,9 @@ def on_button_pressed(self, event: Button.Pressed) -> None: ConfigsCreateConfig(**new_config) # If that passes validation, update the existing config self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) - if event.button.id == "next": + if event.button.id == "config_specific_process": self.parent.push_screen("custom_process_resources") + elif event.button.id == "finish_config": + self.parent.push_screen("final") except ValueError: pass diff --git a/nf_core/configs/create/final.py b/nf_core/configs/create/final.py index b9aeddc3c5..b11cd770a5 100644 --- a/nf_core/configs/create/final.py +++ b/nf_core/configs/create/final.py @@ -2,12 +2,12 @@ from textual.app import ComposeResult from textual.containers import Center from textual.screen import Screen -from textual.widgets import Button, Footer, Header, Markdown +from textual.widgets import Button, Footer, Header, Markdown, Input from nf_core.configs.create.create import ( ConfigCreate, ) -from nf_core.configs.create.utils import TextInput +from nf_core.configs.create.utils import TextInput, ConfigsCreateConfig, init_context class FinalScreen(Screen): @@ -34,12 +34,22 @@ def compose(self) -> ComposeResult: classes="cta" ) - def _create_config(self) -> None: + def _create_config(self, config_dir='.') -> None: """Create the config.""" - create_obj = ConfigCreate(template_config=self.parent.TEMPLATE_CONFIG, config_type=self.parent.CONFIG_TYPE) + create_obj = ConfigCreate(template_config=self.parent.TEMPLATE_CONFIG, config_type=self.parent.CONFIG_TYPE, config_dir=config_dir) create_obj.write_to_file() @on(Button.Pressed, "#close_app") def on_button_pressed(self, event: Button.Pressed) -> None: """Save fields to the config.""" - self._create_config() + # Validate the save location + save_location = self.query_one("TextInput") + save_location_text = save_location.query_one(Input) + try: + with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): + ConfigsCreateConfig(savelocation=save_location_text.value) + # If validation passes, create the config + self._create_config(config_dir=save_location_text.value) + self.parent.close_app() + except ValueError: + pass diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 8dbce93f73..37425dffd5 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -80,6 +80,8 @@ class ConfigsCreateConfig(BaseModel): """ Dictionary containing custom resource requirements for labelled processes """ is_nfcore: Optional[bool] = None """ Whether the config is part of the nf-core organisation """ + savelocation: Optional[str] = None + """ Final location of the configuration file """ model_config = ConfigDict(extra="allow") @@ -111,6 +113,16 @@ def path_valid(cls, v: str, info: ValidationInfo) -> str: raise ValueError("Must be a valid path.") return v + @field_validator("savelocation") + @classmethod + def final_path_valid(cls, v: str, info: ValidationInfo) -> str: + """Check that the final save directory is valid.""" + if v.strip() == "": + raise ValueError("Cannot be left empty.") + if not Path(v).is_dir(): + raise ValueError("Must be a valid path to a directory.") + return v + @field_validator("config_pipeline_name") @classmethod def nfcore_name_valid(cls, v: str, info: ValidationInfo) -> str: From c8b955a82b1d28306175b7c4e23e4a3c42242b37 Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Wed, 14 Jan 2026 12:54:16 +1100 Subject: [PATCH 09/12] add attributes for infra config to model, clean up context setting --- nf_core/configs/create/__init__.py | 14 +++++++++++ nf_core/configs/create/basicdetails.py | 2 +- nf_core/configs/create/customprocessres.py | 2 +- nf_core/configs/create/defaultprocessres.py | 2 +- nf_core/configs/create/final.py | 2 +- nf_core/configs/create/utils.py | 27 ++++++++++++++++++++- 6 files changed, 44 insertions(+), 5 deletions(-) diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 8522139e14..1049dbcb86 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -72,6 +72,7 @@ class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): # Tracking variables CONFIG_TYPE = None NFCORE_CONFIG = True + INFRA_ISHPC = False # Log handler LOG_HANDLER = rich_log_handler @@ -103,7 +104,13 @@ def on_button_pressed(self, event: Button.Pressed) -> None: utils.NFCORE_CONFIG_GLOBAL = False self.push_screen("basic_details") elif event.button.id == "type_hpc": + self.INFRA_ISHPC = True + utils.INFRA_ISHPC_GLOBAL = True self.push_screen("hpc_customisation") + elif event.button.id == "type_local": + self.INFRA_ISHPC = False + utils.INFRA_ISHPC_GLOBAL = False + self.push_screen("final_infra_details") elif event.button.id == "toconfiguration": self.push_screen("final_infra_details") elif event.button.id == "finish": @@ -119,3 +126,10 @@ def close_app(self): def action_toggle_dark(self) -> None: """An action to toggle dark mode.""" self.theme: str = "textual-dark" if self.theme == "textual-light" else "textual-light" + + def get_context(self): + return { + "is_nfcore": self.NFCORE_CONFIG, + "is_infrastructure": self.CONFIG_TYPE == "infrastructure", + "is_hpc": self.INFRA_ISHPC + } diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index 4d3e65f468..936610ee80 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -102,7 +102,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: else: text_input.query_one(".validation_msg").update("") try: - with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): + with init_context(self.parent.get_context()): self.parent.TEMPLATE_CONFIG = ConfigsCreateConfig(**config) if event.button.id == "next": if self.parent.CONFIG_TYPE == "infrastructure": diff --git a/nf_core/configs/create/customprocessres.py b/nf_core/configs/create/customprocessres.py index 8bfac5cae8..2d02519d6f 100644 --- a/nf_core/configs/create/customprocessres.py +++ b/nf_core/configs/create/customprocessres.py @@ -128,7 +128,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: text_input.query_one(".validation_msg").update("") # Validate the config try: - with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): + with init_context(self.parent.get_context()): ConfigsCreateConfig(**tmp_config) # Add to the config stack tmp_config['select_label'] = self.select_label diff --git a/nf_core/configs/create/defaultprocessres.py b/nf_core/configs/create/defaultprocessres.py index 98205a2b65..6a8c294d8a 100644 --- a/nf_core/configs/create/defaultprocessres.py +++ b/nf_core/configs/create/defaultprocessres.py @@ -87,7 +87,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: else: text_input.query_one(".validation_msg").update("") try: - with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): + with init_context(self.parent.get_context()): # First, validate the new config data ConfigsCreateConfig(**new_config) # If that passes validation, update the existing config diff --git a/nf_core/configs/create/final.py b/nf_core/configs/create/final.py index b11cd770a5..9ba08e56f6 100644 --- a/nf_core/configs/create/final.py +++ b/nf_core/configs/create/final.py @@ -46,7 +46,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: save_location = self.query_one("TextInput") save_location_text = save_location.query_one(Input) try: - with init_context({"is_nfcore": self.parent.NFCORE_CONFIG, "is_infrastructure": self.parent.CONFIG_TYPE == "infrastructure"}): + with init_context(self.parent.get_context()): ConfigsCreateConfig(savelocation=save_location_text.value) # If validation passes, create the config self._create_config(config_dir=save_location_text.value) diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 37425dffd5..53d6ca20db 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -31,6 +31,7 @@ def init_context(value: dict[str, Any]) -> Iterator[None]: # Define a global variable to store the config type CONFIG_ISINFRASTRUCTURE_GLOBAL: bool = True NFCORE_CONFIG_GLOBAL: bool = True +INFRA_ISHPC_GLOBAL: bool = False class ConfigsCreateConfig(BaseModel): @@ -82,6 +83,30 @@ class ConfigsCreateConfig(BaseModel): """ Whether the config is part of the nf-core organisation """ savelocation: Optional[str] = None """ Final location of the configuration file """ + scheduler: Optional[str] = None + """ The scheduler that the HPC uses """ + queue: Optional[str] = None + """ The default queue that the HPC uses """ + module_system: Optional[str] = None + """ Modules to load when running processes """ + container_system: Optional[str] = None + """ The container system the HPC uses """ + memory: Optional[str] = None + """ The maximum memory available to processes """ + cpus: Optional[str] = None + """ The maximum number of CPUs available to processes """ + time: Optional[str] = None + """ The maximum walltime available to processes """ + envvar: Optional[str] = None + """ An environment variable to hold a custom Nextflow container cachedir """ + cachedir: Optional[str] = None + """ An environment variable to hold a custom Nextflow container cachedir """ + igenomes_cachedir: Optional[str] = None + """ A cachedir for iGenomes """ + scratch_dir: Optional[str] = None + """ A scratch directory to use """ + retries: Optional[str] = None + """ Number of retries for failed jobs """ model_config = ConfigDict(extra="allow") @@ -307,7 +332,7 @@ def validate(self, value: str) -> ValidationResult: If it fails, return the error messages.""" try: - with init_context({"is_nfcore": NFCORE_CONFIG_GLOBAL, "is_infrastructure": CONFIG_ISINFRASTRUCTURE_GLOBAL}): + with init_context({"is_nfcore": NFCORE_CONFIG_GLOBAL, "is_infrastructure": CONFIG_ISINFRASTRUCTURE_GLOBAL, "is_hpc": INFRA_ISHPC_GLOBAL}): ConfigsCreateConfig(**{f"{self.key}": value}) return self.success() except ValidationError as e: From 9f4393152a944e4bfd8b982b1f47a6d427245a31 Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Tue, 20 Jan 2026 16:17:44 +1100 Subject: [PATCH 10/12] add separate screens for process names and labels, switches to determine what to configure --- nf_core/configs/create/__init__.py | 10 +- nf_core/configs/create/basicdetails.py | 2 +- nf_core/configs/create/create.py | 91 +++++++----- nf_core/configs/create/defaultprocessres.py | 44 ++---- nf_core/configs/create/labelledprocessres.py | 125 +++++++++++++++++ nf_core/configs/create/namedprocessres.py | 129 ++++++++++++++++++ .../configs/create/pipelineconfigquestion.py | 121 ++++++++++++++++ nf_core/configs/create/utils.py | 24 ++-- 8 files changed, 469 insertions(+), 77 deletions(-) create mode 100644 nf_core/configs/create/labelledprocessres.py create mode 100644 nf_core/configs/create/namedprocessres.py create mode 100644 nf_core/configs/create/pipelineconfigquestion.py diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 1049dbcb86..d1f37790c1 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -20,8 +20,10 @@ from nf_core.configs.create.hpcquestion import ChooseHpc from nf_core.configs.create.nfcorequestion import ChooseNfcoreConfig from nf_core.configs.create.welcome import WelcomeScreen +from nf_core.configs.create.pipelineconfigquestion import PipelineConfigQuestion from nf_core.configs.create.defaultprocessres import DefaultProcess -from nf_core.configs.create.customprocessres import CustomProcess +from nf_core.configs.create.namedprocessres import NamedProcess +from nf_core.configs.create.labelledprocessres import LabelledProcess ## General utilities from nf_core.utils import LoggingConsole @@ -61,8 +63,10 @@ class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): "final": FinalScreen, "hpc_question": ChooseHpc, "hpc_customisation": HpcCustomisation, + "pipelineconfigquestion": PipelineConfigQuestion, "default_process_resources": DefaultProcess, - "custom_process_resources": CustomProcess, + "named_process_resources": NamedProcess, + "labelled_process_resources": LabelledProcess, "final_infra_details": FinalInfraDetails, } @@ -73,6 +77,8 @@ class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): CONFIG_TYPE = None NFCORE_CONFIG = True INFRA_ISHPC = False + PIPE_CONF_NAMED = False + PIPE_CONF_LABELLED = False # Log handler LOG_HANDLER = rich_log_handler diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index 936610ee80..de948aa62e 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -108,7 +108,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: if self.parent.CONFIG_TYPE == "infrastructure": self.parent.push_screen("hpc_question") elif self.parent.CONFIG_TYPE == "pipeline": - self.parent.push_screen("default_process_resources") + self.parent.push_screen("pipelineconfigquestion") except ValueError: pass diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py index 1fa89beb2c..6b84668b3a 100644 --- a/nf_core/configs/create/create.py +++ b/nf_core/configs/create/create.py @@ -47,40 +47,59 @@ def construct_params_str(self): params_section = [ 'params {', + '\n', *info_params_str_list, + '\n', '}', ] - return '\n'.join(params_section) + '\n' + params_section_str = '\n'.join(params_section) + '\n\n' + return sub(r'\n\n\n+', '\n\n', params_section_str) - def get_resource_strings(self, cpus, memory, hours, minutes, seconds, prefix=''): - cpus_int = int(cpus) - cpus_str = f'cpus = {cpus_int}' - - memory_int = int(memory) - memory_str = f'memory = {memory_int}.Gb' - - time_h = int(hours) - time_m = int(minutes) - time_s = int(seconds) - time_str = f"time = '{time_h}h {time_m}m {time_s}s'" + def get_resource_strings(self, cpus, memory, hours, prefix=''): + cpus_str = '' + if cpus: + cpus_int = int(cpus) + cpus_str = f'cpus = {cpus_int}' + + memory_str = '' + if memory: + memory_int = int(memory) + memory_str = f'memory = {memory_int}.GB' + + time_str = '' + if hours: + time_h = None + time_m = None + try: + time_h = int(hours) + except: + try: + time_m = int(float(hours) * 60) + except: + raise ValueError("Non-numeric value supplied for walltime value.") + if time_m is not None and time_m % 60 == 0: + time_h = int(time_m / 60) + if time_h is not None: + time_str = f"time = {time_h}.h" + elif time_m is not None: + time_str = f"time = {time_m}.m" + else: + raise ValueError("Non-numeric value supplied for walltime value.") resources = [cpus_str, memory_str, time_str] return [ f'{prefix}{res}' for res in resources + if res ] def construct_process_config_str(self): - process_config_str_list = [] - # Construct default resources default_resources = self.get_resource_strings( cpus=self.template_config.default_process_ncpus, memory=self.template_config.default_process_memgb, hours=self.template_config.default_process_hours, - minutes=self.template_config.default_process_minutes, - seconds=self.template_config.default_process_seconds, prefix=' ' ) @@ -88,45 +107,55 @@ def construct_process_config_str(self): named_resources = [] if self.template_config.named_process_resources: for process_name, process_resources in self.template_config.named_process_resources.items(): - named_resources.append( - f" withName: '{process_name}'" + " {" - ) - named_resources.extend(self.get_resource_strings( + named_resource_string = self.get_resource_strings( cpus=process_resources['custom_process_ncpus'], memory=process_resources['custom_process_memgb'], hours=process_resources['custom_process_hours'], - minutes=process_resources['custom_process_minutes'], - seconds=process_resources['custom_process_seconds'], prefix=' ' - )) + ) + if not named_resource_string: + continue + named_resources.append( + f" withName: '{process_name}'" + " {" + ) + named_resources.extend(named_resource_string) named_resources.append(' }') + named_resources.append('\n') # Construct labelled process resources labelled_resources = [] if self.template_config.labelled_process_resources: for process_label, process_resources in self.template_config.labelled_process_resources.items(): - labelled_resources.append( - f" withLabel: '{process_label}'" + " {" - ) - labelled_resources.extend(self.get_resource_strings( + labelled_resource_string = self.get_resource_strings( cpus=process_resources['custom_process_ncpus'], memory=process_resources['custom_process_memgb'], hours=process_resources['custom_process_hours'], - minutes=process_resources['custom_process_minutes'], - seconds=process_resources['custom_process_seconds'], prefix=' ' - )) + ) + if not labelled_resource_string: + continue + labelled_resources.append( + f" withLabel: '{process_label}'" + " {" + ) + labelled_resources.extend(labelled_resource_string) labelled_resources.append(' }') + labelled_resources.append('\n') process_section = [ 'process {', + '\n', *default_resources, + '\n', *named_resources, + '\n', *labelled_resources, + '\n', '}', ] - return '\n'.join(process_section) + '\n' + process_section_str = '\n'.join(process_section) + '\n' + + return sub(r'\n\n\n+', '\n\n', process_section_str) def write_to_file(self): ## File name option diff --git a/nf_core/configs/create/defaultprocessres.py b/nf_core/configs/create/defaultprocessres.py index 6a8c294d8a..100873e6fc 100644 --- a/nf_core/configs/create/defaultprocessres.py +++ b/nf_core/configs/create/defaultprocessres.py @@ -43,38 +43,21 @@ def compose(self) -> ComposeResult: "8", classes="column", ) - yield Markdown("The walltime required by default for all processes.") - with Horizontal(): - yield TextInput( - "default_process_hours", - "1", - "Hours:", - "1", - classes="column", - ) - yield TextInput( - "default_process_minutes", - "0", - "Minutes:", - "0", - classes="column", - ) - yield TextInput( - "default_process_seconds", - "0", - "Seconds:", - "0", - classes="column", - ) + yield TextInput( + "default_process_hours", + "1", + "The default number of hours of walltime required for processes:", + "1", + classes="column", + ) yield Center( Button("Back", id="back", variant="default"), - Button("Configure specific processes", id="config_specific_process", variant="success"), - Button("Finish", id="finish_config", variant="success"), + Button("Next", id="next", variant="success"), classes="cta", ) # Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the ConfigsCreateConfig class) with the values from the text inputs - @on(Button.Pressed) + @on(Button.Pressed, "#next") def on_button_pressed(self, event: Button.Pressed) -> None: """Save fields to the config.""" new_config = {} @@ -92,9 +75,12 @@ def on_button_pressed(self, event: Button.Pressed) -> None: ConfigsCreateConfig(**new_config) # If that passes validation, update the existing config self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) - if event.button.id == "config_specific_process": - self.parent.push_screen("custom_process_resources") - elif event.button.id == "finish_config": + # Push the next screen + if self.parent.PIPE_CONF_NAMED: + self.parent.push_screen("named_process_resources") + elif self.parent.PIPE_CONF_LABELLED: + self.parent.push_screen("labelled_process_resources") + else: self.parent.push_screen("final") except ValueError: pass diff --git a/nf_core/configs/create/labelledprocessres.py b/nf_core/configs/create/labelledprocessres.py new file mode 100644 index 0000000000..ce94c72663 --- /dev/null +++ b/nf_core/configs/create/labelledprocessres.py @@ -0,0 +1,125 @@ +"""Get information about which process/label the user wants to configure.""" + +from textwrap import dedent + +from textual import on +from textual.app import ComposeResult +from textual.containers import Center, Horizontal +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Input, Markdown, Switch, Label +from enum import Enum +from nf_core.utils import add_hide_class, remove_hide_class + +from nf_core.configs.create.utils import ( + ConfigsCreateConfig, + TextInput, + init_context +) ## TODO Move somewhere common? +from nf_core.utils import add_hide_class, remove_hide_class + + +class LabelledProcess(Screen): + """Get labelled process resource requirements.""" + + def __init__(self) -> None: + super().__init__() + self.config_stack = [] + self.current_config = {} + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown( + dedent( + """ + # Custom process resources by label + """ + ) + ) + yield TextInput( + "custom_process_name", + "", + "The process label you wish to configure.", + "", + classes="column", + ) + yield TextInput( + "custom_process_ncpus", + "2", + "Number of CPUs to use for the process.", + "2", + classes="column", + ) + yield TextInput( + "custom_process_memgb", + "8", + "Amount of memory in GB to use for the process.", + "8", + classes="column", + ) + yield TextInput( + "custom_process_hours", + "1", + "The number of hours of walltime required for the process:", + "1", + classes="column", + ) + yield Center( + Button("Back", id="back", variant="default"), + Button("Configure another process", id="another"), + Button("Next", id="next", variant="success"), + classes="cta", + ) + + # Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the ConfigsCreateConfig class) with the values from the text inputs + @on(Button.Pressed) + def on_button_pressed(self, event: Button.Pressed) -> None: + """Save fields to the config.""" + if event.button.id in ["next", "another"]: + tmp_config = {} + for text_input in self.query("TextInput"): + this_input = text_input.query_one(Input) + validation_result = this_input.validate(this_input.value) + tmp_config[text_input.field_id] = this_input.value + if not validation_result.is_valid: + text_input.query_one(".validation_msg").update("\n".join(validation_result.failure_descriptions)) + else: + text_input.query_one(".validation_msg").update("") + # Validate the config + try: + with init_context(self.parent.get_context()): + ConfigsCreateConfig(**tmp_config) + # Add to the config stack + self.config_stack.append(tmp_config) + if event.button.id == "another": + # If configuring another process, push a blank config to the config stack + # and push a new copy of this screen to the screen stack + self.config_stack.append({}) + self.parent.push_screen("labelled_process_resources") + else: + # If finalising the custom resources, add them all to the config now + key = "labelled_process_resources" + new_config = {key: {}} + for tmp_config in self.config_stack: + process_label = tmp_config.get('custom_process_name') + new_config[key][process_label] = tmp_config + self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) + self.parent.push_screen("final") + except ValueError: + pass + + def on_screen_resume(self): + # Grab the last config in the stack if it exists + try: + self.current_config = self.config_stack.pop() + except IndexError: + self.current_config = {} + # Reset all input field values + for text_input in self.query("TextInput"): + this_input = text_input.query_one(Input) + this_input.clear() + field_id = text_input.field_id + if field_id in self.current_config: + this_input.insert(self.current_config[field_id], 0) + else: + text_input.refresh(repaint=True, layout=True, recompose=True) diff --git a/nf_core/configs/create/namedprocessres.py b/nf_core/configs/create/namedprocessres.py new file mode 100644 index 0000000000..3cf335afe2 --- /dev/null +++ b/nf_core/configs/create/namedprocessres.py @@ -0,0 +1,129 @@ +"""Get information about which process/label the user wants to configure.""" + +from textwrap import dedent + +from textual import on +from textual.app import ComposeResult +from textual.containers import Center, Horizontal +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Input, Markdown, Switch, Label +from enum import Enum +from nf_core.utils import add_hide_class, remove_hide_class + +from nf_core.configs.create.utils import ( + ConfigsCreateConfig, + TextInput, + init_context +) ## TODO Move somewhere common? +from nf_core.utils import add_hide_class, remove_hide_class + + +class NamedProcess(Screen): + """Get named process resource requirements.""" + + def __init__(self) -> None: + super().__init__() + self.config_stack = [] + self.current_config = {} + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown( + dedent( + """ + # Custom process resources by name + """ + ) + ) + yield TextInput( + "custom_process_name", + "", + "The name of the process you wish to configure.", + "", + classes="column", + ) + yield TextInput( + "custom_process_ncpus", + "2", + "Number of CPUs to use for the process.", + "2", + classes="column", + ) + yield TextInput( + "custom_process_memgb", + "8", + "Amount of memory in GB to use for the process.", + "8", + classes="column", + ) + yield TextInput( + "custom_process_hours", + "1", + "The number of hours of walltime required for the process:", + "1", + classes="column", + ) + yield Center( + Button("Back", id="back", variant="default"), + Button("Configure another process", id="another"), + Button("Next", id="next", variant="success"), + classes="cta", + ) + + # Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the ConfigsCreateConfig class) with the values from the text inputs + @on(Button.Pressed) + def on_button_pressed(self, event: Button.Pressed) -> None: + """Save fields to the config.""" + if event.button.id in ["next", "another"]: + tmp_config = {} + for text_input in self.query("TextInput"): + this_input = text_input.query_one(Input) + validation_result = this_input.validate(this_input.value) + tmp_config[text_input.field_id] = this_input.value + if not validation_result.is_valid: + text_input.query_one(".validation_msg").update("\n".join(validation_result.failure_descriptions)) + else: + text_input.query_one(".validation_msg").update("") + # Validate the config + try: + with init_context(self.parent.get_context()): + ConfigsCreateConfig(**tmp_config) + # Add to the config stack + self.config_stack.append(tmp_config) + if event.button.id == "another": + # If configuring another process, push a blank config to the config stack + # and push a new copy of this screen to the screen stack + self.config_stack.append({}) + self.parent.push_screen("named_process_resources") + else: + # If finalising the custom resources, add them all to the config now + key = "named_process_resources" + new_config = {key: {}} + for tmp_config in self.config_stack: + process_name = tmp_config.get('custom_process_name') + new_config[key][process_name] = tmp_config + self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) + # Push the next screen + if self.parent.PIPE_CONF_LABELLED: + self.parent.push_screen("labelled_process_resources") + else: + self.parent.push_screen("final") + except ValueError: + pass + + def on_screen_resume(self): + # Grab the last config in the stack if it exists + try: + self.current_config = self.config_stack.pop() + except IndexError: + self.current_config = {} + # Reset all input field values + for text_input in self.query("TextInput"): + this_input = text_input.query_one(Input) + this_input.clear() + field_id = text_input.field_id + if field_id in self.current_config: + this_input.insert(self.current_config[field_id], 0) + else: + text_input.refresh(repaint=True, layout=True, recompose=True) diff --git a/nf_core/configs/create/pipelineconfigquestion.py b/nf_core/configs/create/pipelineconfigquestion.py new file mode 100644 index 0000000000..a188e0b443 --- /dev/null +++ b/nf_core/configs/create/pipelineconfigquestion.py @@ -0,0 +1,121 @@ +"""Get information about which process/label the user wants to configure.""" + +from textwrap import dedent + +from textual import on +from textual.app import ComposeResult +from textual.containers import Center, Horizontal +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Input, Markdown, Switch, Label +from enum import Enum +from nf_core.utils import add_hide_class, remove_hide_class + +from nf_core.configs.create.utils import ( + ConfigsCreateConfig, + TextInput, + init_context +) ## TODO Move somewhere common? +from nf_core.utils import add_hide_class, remove_hide_class + + +class PipelineConfigQuestion(Screen): + """Determine whether the user wants to configure the default resources and/or specific process names/labels.""" + + def __init__(self) -> None: + super().__init__() + self.config_defaults = False + self.config_named_processes = False + self.config_labels = False + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown( + dedent( + """ + # What would you like to configure? + """ + ) + ) + with Horizontal(): + yield Label( + "Configure default process resources?", + id="toggle_configure_defaults_label" + ) + yield Switch( + id="toggle_configure_defaults", + value=self.config_defaults, + ) + yield Label( + "Yes" if self.config_defaults else "No", + id="toggle_configure_defaults_state_label" + ) + with Horizontal(): + yield Label( + "Configure specific named processes?", + id="toggle_configure_names_label" + ) + yield Switch( + id="toggle_configure_names", + value=self.config_defaults, + ) + yield Label( + "Yes" if self.config_defaults else "No", + id="toggle_configure_names_state_label" + ) + with Horizontal(): + yield Label( + "Configure labels?", + id="toggle_configure_labels_label" + ) + yield Switch( + id="toggle_configure_labels", + value=self.config_defaults, + ) + yield Label( + "Yes" if self.config_defaults else "No", + id="toggle_configure_labels_state_label" + ) + yield Center( + Button("Back", id="back", variant="default"), + Button("Next", id="next", variant="success"), + classes="cta", + ) + + @on(Switch.Changed) + def on_toggle_switch(self, event: Switch.Changed) -> None: + """ Handle toggling the switches that determine which pipeline resources to configure """ + valid_toggles = { + 'toggle_configure_defaults': 'config_defaults', + 'toggle_configure_names': 'config_named_processes', + 'toggle_configure_labels': 'config_labels', + } + + if event.switch.id not in valid_toggles: + return + + attr = valid_toggles[event.switch.id] + self.__setattr__(attr, event.value) + + # Update the switch label + for label in self.query(Label): + if label.id == f'{event.switch.id}_state_label': + label.update("Yes" if event.value else "No") + + # Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the ConfigsCreateConfig class) with the values from the text inputs + @on(Button.Pressed, "#next") + def on_next_button_pressed(self, event: Button.Pressed) -> None: + """Save configuration options and then move to the next screen.""" + # Update app tracking variables for whether to configure named and/or labelled processes + self.parent.PIPE_CONF_NAMED = self.config_named_processes + self.parent.PIPE_CONF_LABELLED = self.config_labels + + # Proceed to next screen depending on what choices the user has made + if self.config_defaults: + self.parent.push_screen("default_process_resources") + elif self.config_named_processes: + self.parent.push_screen("named_process_resources") + elif self.config_labels: + self.parent.push_screen("labelled_process_resources") + else: + self.parent.push_screen("final") diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 53d6ca20db..f3ccb6efdf 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -71,10 +71,6 @@ class ConfigsCreateConfig(BaseModel): """ Amount of memory for process """ custom_process_hours: Optional[str] = None """ Walltime for process - hours """ - custom_process_minutes: Optional[str] = None - """ Walltime for process - minutes """ - custom_process_seconds: Optional[str] = None - """ Walltime for process - seconds """ named_process_resources: Optional[dict] = None """ Dictionary containing custom resource requirements for named processes """ labelled_process_resources: Optional[dict] = None @@ -236,11 +232,11 @@ def notempty_process_name(cls, v: str, info: ValidationInfo) -> str: @field_validator("default_process_ncpus", "default_process_memgb", "custom_process_ncpus", "custom_process_memgb") @classmethod def pos_integer_valid(cls, v: str, info: ValidationInfo) -> str: - """Check that integer values are non-empty and positive.""" + """Check that integer values are either empty or positive.""" context = info.context if context and not context["is_infrastructure"]: if v.strip() == "": - raise ValueError("Cannot be left empty.") + return v try: v_int = int(v.strip()) except ValueError: @@ -249,20 +245,20 @@ def pos_integer_valid(cls, v: str, info: ValidationInfo) -> str: raise ValueError("Must be a positive integer.") return v - @field_validator("default_process_hours", "default_process_minutes", "default_process_seconds", "custom_process_hours", "custom_process_minutes", "custom_process_seconds") + @field_validator("default_process_hours", "custom_process_hours") @classmethod - def non_neg_integer_valid(cls, v: str, info: ValidationInfo) -> str: - """Check that integer values are non-empty and non-negative.""" + def non_neg_float_valid(cls, v: str, info: ValidationInfo) -> str: + """Check that numeric values are either empty or non-negative.""" context = info.context if context and not context["is_infrastructure"]: if v.strip() == "": - raise ValueError("Cannot be left empty.") + return v try: - v_int = int(v.strip()) + vf = float(v.strip()) except ValueError: - raise ValueError("Must be an integer.") - if not v_int >= 0: - raise ValueError("Must be a non-negative integer.") + raise ValueError("Must be a number.") + if not vf >= 0: + raise ValueError("Must be a non-negative number.") return v ## TODO Duplicated from pipelines utils - move to common location if possible (validation seems to be context specific so possibly not) From 655d90e1b4fbfc06bfcc50a884220dbcdfa4d85f Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Thu, 29 Jan 2026 10:55:06 +1100 Subject: [PATCH 11/12] use pathlib, clean up time resource string, use snakecase for screen ids --- nf_core/configs/create/__init__.py | 2 +- nf_core/configs/create/basicdetails.py | 2 +- nf_core/configs/create/create.py | 35 ++++++++++++-------------- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index d1f37790c1..a0aaefcef1 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -63,7 +63,7 @@ class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): "final": FinalScreen, "hpc_question": ChooseHpc, "hpc_customisation": HpcCustomisation, - "pipelineconfigquestion": PipelineConfigQuestion, + "pipeline_config_question": PipelineConfigQuestion, "default_process_resources": DefaultProcess, "named_process_resources": NamedProcess, "labelled_process_resources": LabelledProcess, diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index de948aa62e..7214e397b1 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -108,7 +108,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: if self.parent.CONFIG_TYPE == "infrastructure": self.parent.push_screen("hpc_question") elif self.parent.CONFIG_TYPE == "pipeline": - self.parent.push_screen("pipelineconfigquestion") + self.parent.push_screen("pipeline_config_question") except ValueError: pass diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py index 6b84668b3a..6c37e42c10 100644 --- a/nf_core/configs/create/create.py +++ b/nf_core/configs/create/create.py @@ -4,13 +4,18 @@ from nf_core.configs.create.utils import ConfigsCreateConfig, generate_config_entry from re import sub +from pathlib import Path class ConfigCreate: - def __init__(self, template_config: ConfigsCreateConfig, config_type: str, config_dir:str = '.'): + def __init__(self, template_config: ConfigsCreateConfig, config_type: str, config_dir: Path | str = Path('.')): self.template_config = template_config self.config_type = config_type - self.config_dir = sub(r'/$', '', config_dir) + config_dir_path = config_dir if isinstance(config_dir, Path) else Path(config_dir) + assert not config_dir_path.is_file(), f'Error: the path "{str(config_dir_path)}" is a file.' + # Create directory if it doesn't already exist + config_dir_path.mkdir(parents=True, exist_ok=True) + self.config_dir = config_dir_path def construct_info_params(self): final_params = {} @@ -69,23 +74,13 @@ def get_resource_strings(self, cpus, memory, hours, prefix=''): time_str = '' if hours: - time_h = None - time_m = None - try: - time_h = int(hours) - except: - try: - time_m = int(float(hours) * 60) - except: - raise ValueError("Non-numeric value supplied for walltime value.") - if time_m is not None and time_m % 60 == 0: - time_h = int(time_m / 60) - if time_h is not None: + time_h = float(hours) + if time_h.is_integer(): + time_h = int(time_h) time_str = f"time = {time_h}.h" - elif time_m is not None: - time_str = f"time = {time_m}.m" else: - raise ValueError("Non-numeric value supplied for walltime value.") + time_m = int(time_h * 60) + time_str = f"time = {time_m}.m" resources = [cpus_str, memory_str, time_str] return [ @@ -160,8 +155,10 @@ def construct_process_config_str(self): def write_to_file(self): ## File name option config_name = str(self.template_config.general_config_name).strip() - filename = sub(r'\s+', '_', config_name) + ".conf" - filename = f'{self.config_dir}/{filename}' + config_name_clean = sub(r'\W+', '_', config_name) + config_name_clean = sub(r'_+$', '', config_name_clean) + filename = f'{config_name_clean}.conf' + filename = self.config_dir / filename ## Collect all config entries per scope, for later checking scope needs to be written params_section_str = self.construct_params_str() From e7deba0e20a0da931e9ba794806918f35fc3f2d6 Mon Sep 17 00:00:00 2001 From: mgeaghan Date: Thu, 29 Jan 2026 14:59:10 +1100 Subject: [PATCH 12/12] refactor process config page into single screen with multiple, removable config widgets --- nf_core/configs/create/__init__.py | 7 +- nf_core/configs/create/customprocessres.py | 180 ------------------ nf_core/configs/create/defaultprocessres.py | 15 +- nf_core/configs/create/labelledprocessres.py | 125 ------------ nf_core/configs/create/multiprocessres.py | 174 +++++++++++++++++ nf_core/configs/create/namedprocessres.py | 129 ------------- .../configs/create/pipelineconfigquestion.py | 4 +- nf_core/configs/create/utils.py | 8 +- 8 files changed, 194 insertions(+), 448 deletions(-) delete mode 100644 nf_core/configs/create/customprocessres.py delete mode 100644 nf_core/configs/create/labelledprocessres.py create mode 100644 nf_core/configs/create/multiprocessres.py delete mode 100644 nf_core/configs/create/namedprocessres.py diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index a0aaefcef1..371364e120 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -22,8 +22,7 @@ from nf_core.configs.create.welcome import WelcomeScreen from nf_core.configs.create.pipelineconfigquestion import PipelineConfigQuestion from nf_core.configs.create.defaultprocessres import DefaultProcess -from nf_core.configs.create.namedprocessres import NamedProcess -from nf_core.configs.create.labelledprocessres import LabelledProcess +from nf_core.configs.create.multiprocessres import MultiNamedProcessConfig, MultiLabelledProcessConfig ## General utilities from nf_core.utils import LoggingConsole @@ -65,8 +64,8 @@ class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): "hpc_customisation": HpcCustomisation, "pipeline_config_question": PipelineConfigQuestion, "default_process_resources": DefaultProcess, - "named_process_resources": NamedProcess, - "labelled_process_resources": LabelledProcess, + "multi_named_process_config": MultiNamedProcessConfig, + "multi_labelled_process_config": MultiLabelledProcessConfig, "final_infra_details": FinalInfraDetails, } diff --git a/nf_core/configs/create/customprocessres.py b/nf_core/configs/create/customprocessres.py deleted file mode 100644 index 2d02519d6f..0000000000 --- a/nf_core/configs/create/customprocessres.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Get information about which process/label the user wants to configure.""" - -from textwrap import dedent - -from textual import on -from textual.app import ComposeResult -from textual.containers import Center, Horizontal -from textual.screen import Screen -from textual.widgets import Button, Footer, Header, Input, Markdown, Switch, Label -from enum import Enum -from nf_core.utils import add_hide_class, remove_hide_class - -from nf_core.configs.create.utils import ( - ConfigsCreateConfig, - TextInput, - init_context -) ## TODO Move somewhere common? -from nf_core.utils import add_hide_class, remove_hide_class - - -class CustomProcess(Screen): - """Get default process resource requirements.""" - - def __init__(self) -> None: - super().__init__() - self.select_label = False - self.config_stack = [] - self.current_config = {} - - def compose(self) -> ComposeResult: - yield Header() - yield Footer() - yield Markdown( - dedent( - """ - # Custom process resources - """ - ) - ) - with Horizontal(): - yield TextInput( - "custom_process_name", - "", - "The name of the process you wish to configure.", - "", - classes="column", - ) - with Horizontal(): - yield Label( - "Selecting a process by name or label:", - id="toggle_process_name_label_text" - ) - yield Switch( - id="toggle_process_name_label", - value=self.select_label, - ) - yield Label( - "label" if self.select_label else "name", - id="name_or_label_text" - ) - yield TextInput( - "custom_process_ncpus", - "2", - "Number of CPUs to use for the process.", - "2", - classes="column", - ) - yield TextInput( - "custom_process_memgb", - "8", - "Amount of memory in GB to use for the process.", - "8", - classes="column", - ) - yield Markdown("The walltime required for the process.") - with Horizontal(): - yield TextInput( - "custom_process_hours", - "1", - "Hours:", - "1", - classes="column", - ) - yield TextInput( - "custom_process_minutes", - "0", - "Minutes:", - "0", - classes="column", - ) - yield TextInput( - "custom_process_seconds", - "0", - "Seconds:", - "0", - classes="column", - ) - yield Center( - Button("Back", id="back", variant="default"), - Button("Configure another process", id="another"), - Button("Finish", id="finish_config", variant="success"), - classes="cta", - ) - - @on(Switch.Changed, "#toggle_process_name_label") - def on_toggle_process_name_label(self, event: Switch.Changed) -> None: - """ Handle toggling the process name/label switch """ - self.select_label = event.value - # Update the switch label - for label in self.query(Label): - if label.id == "name_or_label_text": - label.update("label" if self.select_label else "name") - - - # Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the ConfigsCreateConfig class) with the values from the text inputs - @on(Button.Pressed) - def on_button_pressed(self, event: Button.Pressed) -> None: - """Save fields to the config.""" - if event.button.id in ["finish_config", "another"]: - tmp_config = {} - for text_input in self.query("TextInput"): - this_input = text_input.query_one(Input) - validation_result = this_input.validate(this_input.value) - tmp_config[text_input.field_id] = this_input.value - if not validation_result.is_valid: - text_input.query_one(".validation_msg").update("\n".join(validation_result.failure_descriptions)) - else: - text_input.query_one(".validation_msg").update("") - # Validate the config - try: - with init_context(self.parent.get_context()): - ConfigsCreateConfig(**tmp_config) - # Add to the config stack - tmp_config['select_label'] = self.select_label - self.config_stack.append(tmp_config) - if event.button.id == "another": - # If configuring another process, push a blank config to the config stack - # and push a new copy of this screen to the screen stack - self.config_stack.append({}) - self.parent.push_screen("custom_process_resources") - else: - # If finalising the custom resources, add them all to the config now - new_config = {} - for key in ["labelled_process_resources", "named_process_resources"]: - new_config[key] = self.parent.TEMPLATE_CONFIG.__dict__.get(key) - if new_config[key] is None: - new_config[key] = {} - for tmp_config in self.config_stack: - select_label = tmp_config['select_label'] - process_name_or_label = tmp_config.get('custom_process_name') - key = "labelled_process_resources" if select_label else "named_process_resources" - new_config[key][process_name_or_label] = tmp_config - self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) - self.parent.push_screen("final") - except ValueError: - pass - - def on_screen_resume(self): - # Grab the last config in the stack if it exists - try: - self.current_config = self.config_stack.pop() - except IndexError: - self.current_config = {} - # Reset all input field values - for text_input in self.query("TextInput"): - this_input = text_input.query_one(Input) - this_input.clear() - field_id = text_input.field_id - if field_id in self.current_config: - this_input.insert(self.current_config[field_id], 0) - else: - text_input.refresh(repaint=True, layout=True, recompose=True) - # Also reset switch - switch_input = self.query_one(Switch) - if 'select_label' in self.current_config: - self.select_label = self.current_config['select_label'] - else: - self.select_label = False - if switch_input.value != self.select_label: - switch_input.toggle() diff --git a/nf_core/configs/create/defaultprocessres.py b/nf_core/configs/create/defaultprocessres.py index 100873e6fc..bf149154af 100644 --- a/nf_core/configs/create/defaultprocessres.py +++ b/nf_core/configs/create/defaultprocessres.py @@ -52,10 +52,21 @@ def compose(self) -> ComposeResult: ) yield Center( Button("Back", id="back", variant="default"), + Button("Skip", id="skip", variant="default"), Button("Next", id="next", variant="success"), classes="cta", ) + @on(Button.Pressed, "#skip") + def skip_to_next_screen(self) -> None: + # Skip to the next screen without saving + if self.parent.PIPE_CONF_NAMED: + self.parent.push_screen("multi_named_process_config") + elif self.parent.PIPE_CONF_LABELLED: + self.parent.push_screen("multi_labelled_process_config") + else: + self.parent.push_screen("final") + # Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the ConfigsCreateConfig class) with the values from the text inputs @on(Button.Pressed, "#next") def on_button_pressed(self, event: Button.Pressed) -> None: @@ -77,9 +88,9 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) # Push the next screen if self.parent.PIPE_CONF_NAMED: - self.parent.push_screen("named_process_resources") + self.parent.push_screen("multi_named_process_config") elif self.parent.PIPE_CONF_LABELLED: - self.parent.push_screen("labelled_process_resources") + self.parent.push_screen("multi_labelled_process_config") else: self.parent.push_screen("final") except ValueError: diff --git a/nf_core/configs/create/labelledprocessres.py b/nf_core/configs/create/labelledprocessres.py deleted file mode 100644 index ce94c72663..0000000000 --- a/nf_core/configs/create/labelledprocessres.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Get information about which process/label the user wants to configure.""" - -from textwrap import dedent - -from textual import on -from textual.app import ComposeResult -from textual.containers import Center, Horizontal -from textual.screen import Screen -from textual.widgets import Button, Footer, Header, Input, Markdown, Switch, Label -from enum import Enum -from nf_core.utils import add_hide_class, remove_hide_class - -from nf_core.configs.create.utils import ( - ConfigsCreateConfig, - TextInput, - init_context -) ## TODO Move somewhere common? -from nf_core.utils import add_hide_class, remove_hide_class - - -class LabelledProcess(Screen): - """Get labelled process resource requirements.""" - - def __init__(self) -> None: - super().__init__() - self.config_stack = [] - self.current_config = {} - - def compose(self) -> ComposeResult: - yield Header() - yield Footer() - yield Markdown( - dedent( - """ - # Custom process resources by label - """ - ) - ) - yield TextInput( - "custom_process_name", - "", - "The process label you wish to configure.", - "", - classes="column", - ) - yield TextInput( - "custom_process_ncpus", - "2", - "Number of CPUs to use for the process.", - "2", - classes="column", - ) - yield TextInput( - "custom_process_memgb", - "8", - "Amount of memory in GB to use for the process.", - "8", - classes="column", - ) - yield TextInput( - "custom_process_hours", - "1", - "The number of hours of walltime required for the process:", - "1", - classes="column", - ) - yield Center( - Button("Back", id="back", variant="default"), - Button("Configure another process", id="another"), - Button("Next", id="next", variant="success"), - classes="cta", - ) - - # Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the ConfigsCreateConfig class) with the values from the text inputs - @on(Button.Pressed) - def on_button_pressed(self, event: Button.Pressed) -> None: - """Save fields to the config.""" - if event.button.id in ["next", "another"]: - tmp_config = {} - for text_input in self.query("TextInput"): - this_input = text_input.query_one(Input) - validation_result = this_input.validate(this_input.value) - tmp_config[text_input.field_id] = this_input.value - if not validation_result.is_valid: - text_input.query_one(".validation_msg").update("\n".join(validation_result.failure_descriptions)) - else: - text_input.query_one(".validation_msg").update("") - # Validate the config - try: - with init_context(self.parent.get_context()): - ConfigsCreateConfig(**tmp_config) - # Add to the config stack - self.config_stack.append(tmp_config) - if event.button.id == "another": - # If configuring another process, push a blank config to the config stack - # and push a new copy of this screen to the screen stack - self.config_stack.append({}) - self.parent.push_screen("labelled_process_resources") - else: - # If finalising the custom resources, add them all to the config now - key = "labelled_process_resources" - new_config = {key: {}} - for tmp_config in self.config_stack: - process_label = tmp_config.get('custom_process_name') - new_config[key][process_label] = tmp_config - self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) - self.parent.push_screen("final") - except ValueError: - pass - - def on_screen_resume(self): - # Grab the last config in the stack if it exists - try: - self.current_config = self.config_stack.pop() - except IndexError: - self.current_config = {} - # Reset all input field values - for text_input in self.query("TextInput"): - this_input = text_input.query_one(Input) - this_input.clear() - field_id = text_input.field_id - if field_id in self.current_config: - this_input.insert(self.current_config[field_id], 0) - else: - text_input.refresh(repaint=True, layout=True, recompose=True) diff --git a/nf_core/configs/create/multiprocessres.py b/nf_core/configs/create/multiprocessres.py new file mode 100644 index 0000000000..0e38c81b59 --- /dev/null +++ b/nf_core/configs/create/multiprocessres.py @@ -0,0 +1,174 @@ +"""Get information about which process/label the user wants to configure.""" + +from textwrap import dedent + +from textual import on +from textual.app import ComposeResult +from textual.containers import Center, HorizontalGroup, VerticalScroll +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Input, Markdown, Switch, Label +from textual.events import Mount, ScreenResume +from nf_core.utils import add_hide_class, remove_hide_class + +from nf_core.configs.create.utils import ( + ConfigsCreateConfig, + TextInput, + init_context +) ## TODO Move somewhere common? +from nf_core.utils import add_hide_class, remove_hide_class + + +class ProcessConfig(HorizontalGroup): + """Get resource requirements for a single process.""" + + def __init__(self, selector: str) -> None: + super().__init__() + assert selector in ['name', 'label'] + self.selector = selector + + def compose(self) -> ComposeResult: + yield TextInput( + "custom_process_id", + "", + f"Process {self.selector}:", + "", + classes="column", + ) + yield TextInput( + "custom_process_ncpus", + "2", + "# CPUs:", + "2", + classes="column", + ) + yield TextInput( + "custom_process_memgb", + "8", + "Memory (GB):", + "8", + classes="column", + ) + yield TextInput( + "custom_process_hours", + "1", + "Walltime (hours):", + "1", + classes="column", + ) + yield Button( + "-", + id="remove", + variant="error" + ) + + @on(Button.Pressed, "#remove") + def remove_widget(self) -> None: + self.remove() + + +class MultiProcessConfig(Screen): + """Get resource requirements for multiple processes.""" + + def __init__(self, selector_type: str, config_key: str, title: str) -> None: + super().__init__() + assert isinstance(title, str) and title + self.title = title + assert isinstance(selector_type, str) and selector_type + self.selector_type = selector_type + assert isinstance(config_key, str) and config_key + self.config_key = config_key + + def _set_next_screen(self, next_screen: str) -> None: + assert isinstance(next_screen, str) + self.next_screen = next_screen + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown(f'# {self.title}') + yield VerticalScroll( + ProcessConfig(selector=self.selector_type), + ProcessConfig(selector=self.selector_type), + ProcessConfig(selector=self.selector_type), + id="configs" + ) + yield Center( + Button("Add another process", id="another", variant="success") + ) + yield Center( + Button("Back", id="back", variant="default"), + Button("Skip", id="skip", variant="default"), + Button("Next", id="next", variant="success"), + classes="cta", + ) + + @on(Button.Pressed, "#another") + def add_config(self) -> None: + new_config = ProcessConfig(selector='name') + self.query_one("#configs").mount(new_config) + + @on(Button.Pressed, "#next") + def save_and_load_next_screen(self) -> None: + try: + config_list = [] + for config_widget in self.query("ProcessConfig"): + tmp_config = {} + for text_input in config_widget.query("TextInput"): + this_input = text_input.query_one(Input) + validation_result = this_input.validate(this_input.value) + tmp_config[text_input.field_id] = this_input.value + if not validation_result.is_valid: + text_input.query_one(".validation_msg").update("\n".join(validation_result.failure_descriptions)) + else: + text_input.query_one(".validation_msg").update("") + # Validate the config + with init_context(self.parent.get_context()): + ConfigsCreateConfig(**tmp_config) + # Add to the config list + config_list.append(tmp_config) + # Add to the final config + key = self.config_key + new_config = {self.config_key: {}} + for tmp_config in config_list: + process_id = tmp_config.get('custom_process_id') + new_config[self.config_key][process_id] = tmp_config + self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) + # Push the next screen + self.parent.push_screen(self.next_screen) + except ValueError: + pass + + @on(Button.Pressed, "#skip") + def skip_to_next_screen(self) -> None: + self.parent.push_screen(self.next_screen) + + +class MultiNamedProcessConfig(MultiProcessConfig): + def __init__(self) -> None: + super().__init__( + title='Configure processes by name', + selector_type='name', + config_key='named_process_resources' + ) + + @on(Mount) + @on(ScreenResume) + def set_next_screen(self) -> None: + next_screen = "final" + if self.parent.PIPE_CONF_LABELLED: + next_screen = "multi_labelled_process_config" + self._set_next_screen(next_screen) + + +class MultiLabelledProcessConfig(MultiProcessConfig): + def __init__(self) -> None: + super().__init__( + title='Configure processes by label', + selector_type='label', + config_key='labelled_process_resources' + ) + + @on(Mount) + @on(ScreenResume) + def set_next_screen(self) -> None: + self._set_next_screen('final') \ No newline at end of file diff --git a/nf_core/configs/create/namedprocessres.py b/nf_core/configs/create/namedprocessres.py deleted file mode 100644 index 3cf335afe2..0000000000 --- a/nf_core/configs/create/namedprocessres.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Get information about which process/label the user wants to configure.""" - -from textwrap import dedent - -from textual import on -from textual.app import ComposeResult -from textual.containers import Center, Horizontal -from textual.screen import Screen -from textual.widgets import Button, Footer, Header, Input, Markdown, Switch, Label -from enum import Enum -from nf_core.utils import add_hide_class, remove_hide_class - -from nf_core.configs.create.utils import ( - ConfigsCreateConfig, - TextInput, - init_context -) ## TODO Move somewhere common? -from nf_core.utils import add_hide_class, remove_hide_class - - -class NamedProcess(Screen): - """Get named process resource requirements.""" - - def __init__(self) -> None: - super().__init__() - self.config_stack = [] - self.current_config = {} - - def compose(self) -> ComposeResult: - yield Header() - yield Footer() - yield Markdown( - dedent( - """ - # Custom process resources by name - """ - ) - ) - yield TextInput( - "custom_process_name", - "", - "The name of the process you wish to configure.", - "", - classes="column", - ) - yield TextInput( - "custom_process_ncpus", - "2", - "Number of CPUs to use for the process.", - "2", - classes="column", - ) - yield TextInput( - "custom_process_memgb", - "8", - "Amount of memory in GB to use for the process.", - "8", - classes="column", - ) - yield TextInput( - "custom_process_hours", - "1", - "The number of hours of walltime required for the process:", - "1", - classes="column", - ) - yield Center( - Button("Back", id="back", variant="default"), - Button("Configure another process", id="another"), - Button("Next", id="next", variant="success"), - classes="cta", - ) - - # Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the ConfigsCreateConfig class) with the values from the text inputs - @on(Button.Pressed) - def on_button_pressed(self, event: Button.Pressed) -> None: - """Save fields to the config.""" - if event.button.id in ["next", "another"]: - tmp_config = {} - for text_input in self.query("TextInput"): - this_input = text_input.query_one(Input) - validation_result = this_input.validate(this_input.value) - tmp_config[text_input.field_id] = this_input.value - if not validation_result.is_valid: - text_input.query_one(".validation_msg").update("\n".join(validation_result.failure_descriptions)) - else: - text_input.query_one(".validation_msg").update("") - # Validate the config - try: - with init_context(self.parent.get_context()): - ConfigsCreateConfig(**tmp_config) - # Add to the config stack - self.config_stack.append(tmp_config) - if event.button.id == "another": - # If configuring another process, push a blank config to the config stack - # and push a new copy of this screen to the screen stack - self.config_stack.append({}) - self.parent.push_screen("named_process_resources") - else: - # If finalising the custom resources, add them all to the config now - key = "named_process_resources" - new_config = {key: {}} - for tmp_config in self.config_stack: - process_name = tmp_config.get('custom_process_name') - new_config[key][process_name] = tmp_config - self.parent.TEMPLATE_CONFIG = self.parent.TEMPLATE_CONFIG.model_copy(update=new_config) - # Push the next screen - if self.parent.PIPE_CONF_LABELLED: - self.parent.push_screen("labelled_process_resources") - else: - self.parent.push_screen("final") - except ValueError: - pass - - def on_screen_resume(self): - # Grab the last config in the stack if it exists - try: - self.current_config = self.config_stack.pop() - except IndexError: - self.current_config = {} - # Reset all input field values - for text_input in self.query("TextInput"): - this_input = text_input.query_one(Input) - this_input.clear() - field_id = text_input.field_id - if field_id in self.current_config: - this_input.insert(self.current_config[field_id], 0) - else: - text_input.refresh(repaint=True, layout=True, recompose=True) diff --git a/nf_core/configs/create/pipelineconfigquestion.py b/nf_core/configs/create/pipelineconfigquestion.py index a188e0b443..68e1dab0a2 100644 --- a/nf_core/configs/create/pipelineconfigquestion.py +++ b/nf_core/configs/create/pipelineconfigquestion.py @@ -114,8 +114,8 @@ def on_next_button_pressed(self, event: Button.Pressed) -> None: if self.config_defaults: self.parent.push_screen("default_process_resources") elif self.config_named_processes: - self.parent.push_screen("named_process_resources") + self.parent.push_screen("multi_named_process_config") elif self.config_labels: - self.parent.push_screen("labelled_process_resources") + self.parent.push_screen("multi_labelled_process_config") else: self.parent.push_screen("final") diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index f3ccb6efdf..4494944cd2 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -59,11 +59,7 @@ class ConfigsCreateConfig(BaseModel): """ Default amount of memory """ default_process_hours: Optional[str] = None """ Default walltime - hours """ - default_process_minutes: Optional[str] = None - """ Default walltime - minutes """ - default_process_seconds: Optional[str] = None - """ Default walltime - seconds """ - custom_process_name: Optional[str] = None + custom_process_id: Optional[str] = None """" Name or label of a process to configure """ custom_process_ncpus: Optional[str] = None """ Number of CPUs for process """ @@ -219,7 +215,7 @@ def url_prefix(cls, v: str, info: ValidationInfo) -> str: ) return v - @field_validator("custom_process_name") + @field_validator("custom_process_id") @classmethod def notempty_process_name(cls, v: str, info: ValidationInfo) -> str: """Check that the custom process name or label isn't empty."""