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..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,12 +102,13 @@ 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") 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/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 new file mode 100644 index 0000000000..8f49d92db2 --- /dev/null +++ b/nf_core/configs/create/defaultprocessres.py @@ -0,0 +1,96 @@ +"""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, + init_context +) ## 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.""" + 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: + pass diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 3f7dd54e9d..bf216fba64 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 """ @@ -78,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 @@ -103,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( @@ -122,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( @@ -142,6 +170,37 @@ def url_prefix(cls, v: str, info: ValidationInfo) -> str: ) return v + @field_validator("default_process_ncpus", "default_process_memgb") + @classmethod + def pos_integer_valid(cls, v: str, info: ValidationInfo) -> str: + """Check that integer values are non-empty and positive.""" + 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) class TextInput(Static):