diff --git a/CHANGELOG.md b/CHANGELOG.md index 7370bd57e4..cffaef4e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -336,6 +336,10 @@ | `-p` / `--parallel-downloads` | `-d` / `--parallel-downloads` | | new parameter | `-p` / (`--platform`) | +### Configs + +- New command: `nf-core configs create wizard` for generating configs for nf-core pipelines ([#3001](https://github.com/nf-core/tools/pull/3001)) + ### General - Change default branch to `main` for the nf-core/tools repository diff --git a/MANIFEST.in b/MANIFEST.in index ce2e08c090..4d10521730 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,5 +9,5 @@ include nf_core/assets/logo/nf-core-repo-logo-base-lightbg.png include nf_core/assets/logo/nf-core-repo-logo-base-darkbg.png include nf_core/assets/logo/placeholder_logo.svg include nf_core/assets/logo/MavenPro-Bold.ttf -include nf_core/pipelines/create/create.tcss +include nf_core/textual.tcss include nf_core/pipelines/create/template_features.yml diff --git a/nf_core/__main__.py b/nf_core/__main__.py index 64e0a5bf5f..0ea1691030 100644 --- a/nf_core/__main__.py +++ b/nf_core/__main__.py @@ -61,7 +61,12 @@ ) from nf_core.components.constants import NF_CORE_MODULES_REMOTE from nf_core.pipelines.download import DownloadError -from nf_core.utils import check_if_outdated, nfcore_logo, rich_force_colors, setup_nfcore_dir +from nf_core.utils import ( + check_if_outdated, + nfcore_logo, + rich_force_colors, + setup_nfcore_dir, +) # Set up logging as the root logger # Submodules should all traverse back to this @@ -77,7 +82,14 @@ "nf-core": [ { "name": "Commands", - "commands": ["pipelines", "modules", "subworkflows", "interface", "test-datasets"], + "commands": [ + "pipelines", + "modules", + "subworkflows", + "configs", + "interface", + "test-datasets", + ], }, ], "nf-core pipelines": [ @@ -87,7 +99,15 @@ }, { "name": "For developers", - "commands": ["create", "lint", "bump-version", "sync", "schema", "rocrate", "create-logo"], + "commands": [ + "create", + "lint", + "bump-version", + "sync", + "schema", + "rocrate", + "create-logo", + ], }, ], "nf-core modules": [ @@ -110,13 +130,18 @@ "commands": ["create", "lint", "test"], }, ], + "nf-core configs": [ + { + "name": "Config commands", + "commands": ["create"], + }, + ], "nf-core pipelines schema": [{"name": "Schema commands", "commands": ["validate", "build", "lint", "docs"]}], "nf-core test-datasets": [{"name": "For developers", "commands": ["search", "list", "list-branches"]}], } click.rich_click.OPTION_GROUPS = { "nf-core modules list local": [{"options": ["--dir", "--json", "--help"]}], } - # Set up rich stderr console stderr = rich.console.Console(stderr=True, force_terminal=rich_force_colors()) stdout = rich.console.Console(force_terminal=rich_force_colors()) @@ -260,7 +285,13 @@ def pipelines(ctx): @click.option("-d", "--description", type=str, help="A short description of your pipeline") @click.option("-a", "--author", type=str, help="Name of the main author(s)") @click.option("--version", type=str, default="1.0.0dev", help="The initial version number to use") -@click.option("-f", "--force", is_flag=True, default=False, help="Overwrite output directory if it already exists") +@click.option( + "-f", + "--force", + is_flag=True, + default=False, + help="Overwrite output directory if it already exists", +) @click.option("-o", "--outdir", help="Output directory for new pipeline (default: pipeline name)") @click.option("-t", "--template-yaml", help="Pass a YAML file to customize the template") @click.option( @@ -944,7 +975,13 @@ def command_modules_list_local(ctx, keywords, json, directory): # pylint: disab # nf-core modules install @modules.command("install") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -978,7 +1015,13 @@ def command_modules_install(ctx, tool, directory, prompt, force, sha): # nf-core modules update @modules.command("update") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1055,7 +1098,13 @@ def command_modules_update( # nf-core modules patch @modules.command("patch") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1075,7 +1124,13 @@ def command_modules_patch(ctx, tool, directory, remove): # nf-core modules remove @modules.command("remove") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1249,7 +1304,13 @@ def command_modules_test(ctx, tool, directory, no_prompts, update, once, profile # nf-core modules lint @modules.command("lint") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1303,7 +1364,13 @@ def command_modules_lint( # nf-core modules info @modules.command("info") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1322,7 +1389,13 @@ def command_modules_info(ctx, tool, directory): # nf-core modules bump-versions @modules.command("bump-versions") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1414,7 +1487,13 @@ def command_subworkflows_create(ctx, subworkflow, directory, author, force, migr # nf-core subworkflows test @subworkflows.command("test") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -1502,7 +1581,13 @@ def command_subworkflows_list_local(ctx, keywords, json, directory): # pylint: # nf-core subworkflows lint @subworkflows.command("lint") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -1551,7 +1636,13 @@ def command_subworkflows_lint( # nf-core subworkflows info @subworkflows.command("info") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -1570,7 +1661,13 @@ def command_subworkflows_info(ctx, subworkflow, directory): # nf-core subworkflows install @subworkflows.command("install") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -1647,7 +1744,13 @@ def subworkflows_patch(ctx, tool, dir, remove): # nf-core subworkflows remove @subworkflows.command("remove") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -1666,7 +1769,13 @@ def command_subworkflows_remove(ctx, directory, subworkflow): # nf-core subworkflows update @subworkflows.command("update") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -1749,6 +1858,17 @@ def command_subworkflows_update( ) +# nf-core configs subcommands +@nf_core_cli.group() +@click.pass_context +def configs(ctx): + """ + Commands to manage nf-core configs. + """ + # ensure that ctx.obj exists and is a dict (in case `cli()` is called + # by means other than the `if` block below) + ctx.ensure_object(dict) + # nf-core test-dataset subcommands @nf_core_cli.group() @click.pass_context @@ -1761,6 +1881,24 @@ def test_datasets(ctx): ctx.ensure_object(dict) +# nf-core configs create +@configs.command("create") +@click.pass_context +def create_configs(ctx): + """ + Command to interactively create a nextflow or nf-core config + """ + from nf_core.configs.create import ConfigsCreateApp + + try: + log.info("Launching interactive nf-core configs creation tool.") + app = ConfigsCreateApp() + app.run() + sys.exit(app.return_code or 0) + except UserWarning as e: + log.error(e) + sys.exit(1) + # nf-core test-dataset search @test_datasets.command("search") @click.pass_context diff --git a/nf_core/configs/__init__.py b/nf_core/configs/__init__.py new file mode 100644 index 0000000000..95c830c1b4 --- /dev/null +++ b/nf_core/configs/__init__.py @@ -0,0 +1 @@ +from .create import ConfigsCreateApp diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py new file mode 100644 index 0000000000..364601132f --- /dev/null +++ b/nf_core/configs/create/__init__.py @@ -0,0 +1,106 @@ +"""A Textual app to create a config.""" + +import logging + +import click +from rich.logging import RichHandler + +## Textual objects +from textual.app import App +from textual.widgets import Button + +from nf_core.configs.create import utils + +## nf-core question page (screen) imports +from nf_core.configs.create.basicdetails import BasicDetails +from nf_core.configs.create.configtype import ChooseConfigType +from nf_core.configs.create.final import FinalScreen +from nf_core.configs.create.hpcquestion import ChooseHpc +from nf_core.configs.create.nfcorequestion import ChooseNfcoreConfig +from nf_core.configs.create.welcome import WelcomeScreen + +## General utilities +from nf_core.utils import LoggingConsole + +## Logging +logger = logging.getLogger(__name__) +rich_log_handler = RichHandler( + console=LoggingConsole(classes="log_console"), + level=logging.INFO, + rich_tracebacks=True, + show_time=False, + show_path=False, + markup=True, + tracebacks_suppress=[click], +) +logger.addHandler(rich_log_handler) + + +## Main workflow +class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): + """A Textual app to create nf-core configs.""" + + CSS_PATH = "../../textual.tcss" + TITLE = "nf-core configs create" + SUB_TITLE = "Create a new nextflow config with an interactive interface" + BINDINGS = [ + ("d", "toggle_dark", "Toggle dark mode"), + ("q", "quit", "Quit"), + ] + + ## New question screens (sections) loaded here + SCREENS = { + "welcome": WelcomeScreen, + "choose_type": ChooseConfigType, + "nfcore_question": ChooseNfcoreConfig, + "basic_details": BasicDetails, + "final": FinalScreen, + "hpc_question": ChooseHpc, + } + + # Initialise config as empty + TEMPLATE_CONFIG = utils.ConfigsCreateConfig() + + # Tracking variables + CONFIG_TYPE = None + NFCORE_CONFIG = True + + # Log handler + LOG_HANDLER = rich_log_handler + # Logging state + LOGGING_STATE = None + + ## Question dialogue order defined here + def on_mount(self) -> None: + self.push_screen("welcome") + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle all button pressed events.""" + if event.button.id == "lets_go": + self.push_screen("choose_type") + elif event.button.id == "type_infrastructure": + self.CONFIG_TYPE = "infrastructure" + utils.CONFIG_ISINFRASTRUCTURE_GLOBAL = True + self.push_screen("nfcore_question") + elif event.button.id == "type_nfcore": + self.NFCORE_CONFIG = True + utils.NFCORE_CONFIG_GLOBAL = True + self.push_screen("basic_details") + elif event.button.id == "type_pipeline": + self.CONFIG_TYPE = "pipeline" + utils.CONFIG_ISINFRASTRUCTURE_GLOBAL = False + self.push_screen("nfcore_question") + elif event.button.id == "type_custom": + self.NFCORE_CONFIG = False + utils.NFCORE_CONFIG_GLOBAL = False + self.push_screen("basic_details") + ## General options + if event.button.id == "close_app": + self.exit(return_code=0) + if event.button.id == "back": + self.pop_screen() + + ## User theme options + 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" diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py new file mode 100644 index 0000000000..d9fb416c72 --- /dev/null +++ b/nf_core/configs/create/basicdetails.py @@ -0,0 +1,130 @@ +"""Get basic contact information to set in params to help with debugging. By +displaying such info in the pipeline run header on run execution""" + +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 + +config_exists_warn = """ +> ⚠️ **The config file you are trying to create already exists.** +> +> If you continue, you will **overwrite** the existing config. +> Please change the config name to create a different config!. +""" + + +class BasicDetails(Screen): + """Name, description, author, etc.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown( + dedent( + """ + # Basic details + """ + ) + ) + ## TODO Add validation, .conf already exists? + yield TextInput( + "general_config_name", + "custom", + "Config Name. Used for naming resulting file.", + "", + classes="column", + ) + with Horizontal(): + yield TextInput( + "config_profile_contact", + "Boaty McBoatFace", + "Author full name.", + classes="column hide" if self.parent.CONFIG_TYPE == "pipeline" else "column", + ) + yield TextInput( + "config_profile_handle", + "@BoatyMcBoatFace", + "Author Git(Hub) handle.", + classes="column hide" if self.parent.CONFIG_TYPE == "pipeline" else "column", + ) + yield TextInput( + "config_pipeline_name", + "Pipeline name", + "The pipeline name you want to create the config for.", + classes="hide" if self.parent.CONFIG_TYPE == "infrastructure" or not self.parent.NFCORE_CONFIG else "", + ) + yield TextInput( + "config_pipeline_path", + "Pipeline path", + "The path to the pipeline you want to create the config for.", + classes="hide" if self.parent.CONFIG_TYPE == "infrastructure" or self.parent.NFCORE_CONFIG else "", + ) + + yield TextInput( + "config_profile_description", + "Description", + "A short description of your config.", + ) + yield TextInput( + "config_profile_url", + "https://nf-co.re", + "URL of infrastructure website or owning institution (infrastructure configs only).", + classes="hide" if self.parent.CONFIG_TYPE == "pipeline" else "", + ) + 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.""" + config = {} + for text_input in self.query("TextInput"): + this_input = text_input.query_one(Input) + validation_result = this_input.validate(this_input.value) + 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: + 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") + except ValueError: + pass + + def on_screen_resume(self): + """Show or hide form fields on resume depending on config type.""" + if self.parent.CONFIG_TYPE == "pipeline": + add_hide_class(self.parent, "config_profile_contact") + add_hide_class(self.parent, "config_profile_handle") + add_hide_class(self.parent, "config_profile_url") + if self.parent.NFCORE_CONFIG: + remove_hide_class(self.parent, "config_pipeline_name") + add_hide_class(self.parent, "config_pipeline_path") + else: + remove_hide_class(self.parent, "config_pipeline_path") + add_hide_class(self.parent, "config_pipeline_name") + if self.parent.CONFIG_TYPE == "infrastructure": + remove_hide_class(self.parent, "config_profile_contact") + remove_hide_class(self.parent, "config_profile_handle") + remove_hide_class(self.parent, "config_profile_url") + add_hide_class(self.parent, "config_pipeline_name") + add_hide_class(self.parent, "config_pipeline_path") diff --git a/nf_core/configs/create/configtype.py b/nf_core/configs/create/configtype.py new file mode 100644 index 0000000000..bc6a350f7c --- /dev/null +++ b/nf_core/configs/create/configtype.py @@ -0,0 +1,82 @@ +"""Select which type of config to create to guide questions and order""" + +from textual.app import ComposeResult +from textual.containers import Center, Grid +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Markdown + +markdown_intro = """ +# Choose config type +""" + +markdown_type_nfcore = """ +## Choose _"Infrastructure config"_ if: + +* You want to only define the computational environment you will run all pipelines on + + +""" +markdown_type_custom = """ +## Choose _"Pipeline config"_ if: + +* You just want to tweak resources of a particular step of a specific pipeline. +""" + +markdown_details = """ +## What's the difference? + +_Infrastructure_ configs: + +- Describe the basic necessary information for any nf-core pipeline to +execute +- Define things such as which container engine to use, if there is a scheduler and +which queues to use etc. +- Are suitable for _all_ users on a given computing environment. +- Can be uploaded to [nf-core +configs](https://github.com/nf-core/tools/configs) to be directly accessible +in a nf-core pipeline with `-profile `. +- Are not used to tweak specific parts of a given pipeline (such as a process or +module) + +_Pipeline_ configs + +- Are config files that target specific component of a particular pipeline or pipeline run. + - Example: you have a particular step of the pipeline that often runs out +of memory using the pipeline's default settings. You would use this config to +increase the amount of memory Nextflow supplies that given task. +- Are normally only used by a _single or small group_ of users. +- _May_ also be shared amongst multiple users on the same +computing environment if running similar data with the same pipeline. +- Can _sometimes_ be uploaded to [nf-core +configs](https://github.com/nf-core/tools/configs) as a 'pipeline-specific' +config. + + +""" + + +class ChooseConfigType(Screen): + """Choose whether this will be an infrastructure or pipeline config.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown(markdown_intro) + yield Grid( + Center( + Markdown(markdown_type_nfcore), + Center( + Button( + "Infrastructure config", + id="type_infrastructure", + variant="success", + ) + ), + ), + Center( + Markdown(markdown_type_custom), + Center(Button("Pipeline config", id="type_pipeline", variant="primary")), + ), + classes="col-2 pipeline-type-grid", + ) + yield Markdown(markdown_details) diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py new file mode 100644 index 0000000000..0d58551089 --- /dev/null +++ b/nf_core/configs/create/create.py @@ -0,0 +1,53 @@ +"""Creates a nextflow config matching the current +nf-core organization specification. +""" + +from nf_core.configs.create.utils import ConfigsCreateConfig, generate_config_entry + + +class ConfigCreate: + def __init__(self, template_config: ConfigsCreateConfig): + self.template_config = template_config + + def construct_params(self, contact, handle, description, url): + final_params = {} + + if contact is not None: + if handle is not None: + config_contact = contact + " (" + handle + ")" + else: + config_contact = contact + final_params["config_profile_contact"] = config_contact + elif handle is not None: + final_params["config_profile_contact"] = handle + + if description is not None: + final_params["config_profile_description"] = description + + if url is not None: + final_params["config_profile_url"] = url + + return final_params + + def write_to_file(self): + ## File name option + filename = "_".join(self.template_config.general_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, + ) + + 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") diff --git a/nf_core/configs/create/final.py b/nf_core/configs/create/final.py new file mode 100644 index 0000000000..457293dd1c --- /dev/null +++ b/nf_core/configs/create/final.py @@ -0,0 +1,41 @@ +from textual import on +from textual.app import ComposeResult +from textual.containers import Center +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Markdown + +from nf_core.configs.create.create import ( + ConfigCreate, +) +from nf_core.configs.create.utils import TextInput + + +class FinalScreen(Screen): + """A welcome screen for the app.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown( + """ +# Final step +""" + ) + yield TextInput( + "savelocation", + ".", + "In which directory would you like to save the config?", + ".", + classes="row", + ) + yield Center(Button("Save and close!", id="close_app", variant="success"), classes="cta") + + def _create_config(self) -> None: + """Create the config.""" + create_obj = ConfigCreate(template_config=self.parent.TEMPLATE_CONFIG) + 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() diff --git a/nf_core/configs/create/hpcquestion.py b/nf_core/configs/create/hpcquestion.py new file mode 100644 index 0000000000..9663db1736 --- /dev/null +++ b/nf_core/configs/create/hpcquestion.py @@ -0,0 +1,52 @@ +from textual.app import ComposeResult +from textual.containers import Center, Grid +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Markdown + +markdown_intro = """ +# Is this configuration file for an HPC config? +""" + +markdown_type_hpc = """ +## Choose _"HPC"_ if: + +You want to create a config file for an HPC. +""" +markdown_type_pc = """ +## Choose _"PC"_ if: + +You want to create a config file to run your pipeline on a personal computer. +""" + +markdown_details = """ +## What's the difference? + +Choosing _"HPC"_ will add the following configurations: + +* Provide a scheduler +* Provide the name of a queue +* Select if a module system is used +* Select if you need to load other modules +""" + + +class ChooseHpc(Screen): + """Choose whether this will be a config for an HPC or not.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown(markdown_intro) + yield Grid( + Center( + Markdown(markdown_type_hpc), + Center(Button("HPC", id="type_hpc", variant="success")), + ), + Center( + Markdown(markdown_type_pc), + Center(Button("PC", id="type_pc", variant="primary")), + ), + classes="col-2 pipeline-type-grid", + ) + yield Markdown(markdown_details) + yield Center(Button("Back", id="back", variant="default"), classes="cta") diff --git a/nf_core/configs/create/nfcorequestion.py b/nf_core/configs/create/nfcorequestion.py new file mode 100644 index 0000000000..f93ad035a9 --- /dev/null +++ b/nf_core/configs/create/nfcorequestion.py @@ -0,0 +1,65 @@ +from textual.app import ComposeResult +from textual.containers import Center, Grid +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Markdown + +markdown_intro = """ +# Is this configuration file part of the nf-core organisation? +""" + +markdown_type_nfcore = """ +## Choose _"nf-core"_ if: + +Infrastructure configs: +* You want to add the configuration file to the nf-core/configs repository. + +Pipeline configs: +* The configuration file is for an nf-core pipeline. +""" +markdown_type_custom = """ +## Choose _"Custom"_ if: + +All configs: +* You want full control over *all* parameters or options in the config + (including those that are mandatory for nf-core). + +Infrastructure configs: +* You will _never_ add the configuration file to the nf-core/configs repository. + +Pipeline configs: +* The configuration file is for a custom pipeline which will _never_ be part of nf-core. +""" + +markdown_details = """ +## What's the difference? + +Choosing _"nf-core"_ will make the following of the options mandatory: + +Infrastructure configs: +* Providing the name and github handle of the author and contact person. +* Providing a description of the config. +* Providing the URL of the owning institution. +* Setting up `resourceLimits` to set the maximum resources. +""" + + +class ChooseNfcoreConfig(Screen): + """Choose whether this will be an nf-core config or not.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown(markdown_intro) + yield Grid( + Center( + Markdown(markdown_type_nfcore), + Center(Button("nf-core", id="type_nfcore", variant="success")), + ), + Center( + Markdown(markdown_type_custom), + Center(Button("Custom", id="type_custom", variant="primary")), + ), + classes="col-2 pipeline-type-grid", + ) + yield Markdown(markdown_details) + yield Center(Button("Back", id="back", variant="default"), classes="cta") diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py new file mode 100644 index 0000000000..1c5b91e94e --- /dev/null +++ b/nf_core/configs/create/utils.py @@ -0,0 +1,216 @@ +"""Config creation specific functions and classes""" + +import re +from contextlib import contextmanager +from contextvars import ContextVar +from pathlib import Path +from typing import Any, Dict, Iterator, Optional, Union + +from pydantic import BaseModel, ConfigDict, ValidationError, ValidationInfo, field_validator +from textual import on +from textual.app import ComposeResult +from textual.containers import Grid +from textual.validation import ValidationResult, Validator +from textual.widgets import Input, Static + +# Use ContextVar to define a context on the model initialization +_init_context_var: ContextVar = ContextVar("_init_context_var", default={}) + + +@contextmanager +def init_context(value: Dict[str, Any]) -> Iterator[None]: + token = _init_context_var.set(value) + try: + yield + finally: + _init_context_var.reset(token) + + +# Define a global variable to store the config type +CONFIG_ISINFRASTRUCTURE_GLOBAL: bool = True +NFCORE_CONFIG_GLOBAL: bool = True + + +class ConfigsCreateConfig(BaseModel): + """Pydantic model for the nf-core configs create config.""" + + general_config_type: Optional[str] = None + """ Config file type (infrastructure or pipeline) """ + config_pipeline_name: Optional[str] = None + """ The name of the pipeline """ + config_pipeline_path: Optional[str] = None + """ The path to the pipeline """ + general_config_name: Optional[str] = None + """ Config name """ + config_profile_contact: Optional[str] = None + """ Config contact name """ + config_profile_handle: Optional[str] = None + """ Config contact GitHub handle """ + config_profile_description: Optional[str] = None + """ Config description """ + config_profile_url: Optional[str] = None + """ Config institution URL """ + is_nfcore: Optional[bool] = None + """ Whether the config is part of the nf-core organisation """ + + model_config = ConfigDict(extra="allow") + + def __init__(self, /, **data: Any) -> None: + """Custom init method to allow using a context on the model initialization.""" + self.__pydantic_validator__.validate_python( + data, + self_instance=self, + context=_init_context_var.get(), + ) + + @field_validator("general_config_name") + @classmethod + def notempty(cls, v: str) -> str: + """Check that string values are not empty.""" + if v.strip() == "": + raise ValueError("Cannot be left empty.") + return v + + @field_validator("config_pipeline_path") + @classmethod + 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 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") + @classmethod + def notempty_nfcore(cls, v: str, info: ValidationInfo) -> str: + """Check that string values are not empty when the config is nf-core.""" + context = info.context + if context and context["is_nfcore"]: + if v.strip() == "": + raise ValueError("Cannot be left empty.") + return v + + @field_validator( + "config_profile_handle", + ) + @classmethod + 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 v.strip() == "": + raise ValueError("Cannot be left empty.") + elif not re.match( + r"^@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$", v + ): ## Regex from: https://github.com/shinnn/github-username-regex + raise ValueError("Handle must start with '@'.") + else: + if not v.strip() == "" and not re.match(r"^@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$", v): + raise ValueError("Handle must start with '@'.") + return v + + @field_validator( + "config_profile_url", + ) + @classmethod + 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 v.strip() == "": + raise ValueError("Cannot be left empty.") + elif not re.match( + r"https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)", + v, + ): ## Regex from: https://stackoverflow.com/a/3809435 + raise ValueError( + "Handle must be a valid URL starting with 'https://' or 'http://' and include the domain (e.g. .com)." + ) + else: + if not v.strip() == "" and not re.match( + r"https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)", + v, + ): ## Regex from: https://stackoverflow.com/a/3809435 + raise ValueError( + "Handle must be a valid URL starting with 'https://' or 'http://' and include the domain (e.g. .com)." + ) + 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): + """Widget for text inputs. + + Provides standard interface for a text input with help text + and validation messages. + """ + + def __init__(self, field_id, placeholder, description, default=None, password=None, **kwargs) -> None: + """Initialise the widget with our values. + + Pass on kwargs upstream for standard usage.""" + super().__init__(**kwargs) + self.field_id: str = field_id + self.id: str = field_id + self.placeholder: str = placeholder + self.description: str = description + self.default: str = default + self.password: bool = password + + def compose(self) -> ComposeResult: + yield Grid( + Static(self.description, classes="field_help"), + Input( + placeholder=self.placeholder, + validators=[ValidateConfig(self.field_id)], + value=self.default, + password=self.password, + ), + Static(classes="validation_msg"), + classes="text-input-grid", + ) + + @on(Input.Changed) + @on(Input.Submitted) + def show_invalid_reasons(self, event: Union[Input.Changed, Input.Submitted]) -> None: + """Validate the text input and show errors if invalid.""" + val_msg = self.query_one(".validation_msg") + if not isinstance(val_msg, Static): + raise ValueError("Validation message not found.") + + if event.validation_result is not None and not event.validation_result.is_valid: + # check that val_msg is instance of Static + if isinstance(val_msg, Static): + val_msg.update("\n".join(event.validation_result.failure_descriptions)) + else: + val_msg.update("") + + +## TODO Duplicated from pipelines utils - move to common location if possible (validation seems to be context specific so possibly not) +class ValidateConfig(Validator): + """Validate any config value, using Pydantic.""" + + def __init__(self, key) -> None: + """Initialise the validator with the model key to validate.""" + super().__init__() + self.key = key + + def validate(self, value: str) -> ValidationResult: + """Try creating a Pydantic object with this key set to this value. + + If it fails, return the error messages.""" + try: + with init_context({"is_nfcore": NFCORE_CONFIG_GLOBAL, "is_infrastructure": CONFIG_ISINFRASTRUCTURE_GLOBAL}): + ConfigsCreateConfig(**{f"{self.key}": value}) + return self.success() + except ValidationError as e: + return self.failure(", ".join([err["msg"] for err in e.errors()])) + + +def generate_config_entry(self, key, value): + parsed_entry = " " + key + ' = "' + value + '"\n' + return parsed_entry diff --git a/nf_core/configs/create/welcome.py b/nf_core/configs/create/welcome.py new file mode 100644 index 0000000000..7bca8100d0 --- /dev/null +++ b/nf_core/configs/create/welcome.py @@ -0,0 +1,43 @@ +"""Intro information to help inform user what we are about to do""" + +from textual.app import ComposeResult +from textual.containers import Center +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Markdown, Static + +from nf_core.utils import nfcore_logo + +markdown = """ +# Welcome to the nf-core config creation wizard + +This app will help you create **Nextflow configuration files** +for both: + +- **Infrastructure** configs for defining computing environment for all + pipelines, and +- **Pipeline** configs for defining pipeline-specific resource requirements + +## Using Configs + +The resulting config file can be used with a pipeline with adding `-c +.conf` to a `nextflow run` command. + +They can also be added to the centralised +[nf-core/configs](https://github.com/nf-core/configs) repository, where they +can be used directly by anyone running nf-core pipelines on your infrastructure +specifying `nextflow run -profile `. +""" + + +class WelcomeScreen(Screen): + """A welcome screen for the app.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Static( + "\n" + "\n".join(nfcore_logo) + "\n", + id="logo", + ) + yield Markdown(markdown) + yield Center(Button("Let's go!", id="lets_go", variant="success"), classes="cta") diff --git a/nf_core/pipelines/create/__init__.py b/nf_core/pipelines/create/__init__.py index a4d457d623..bb103ce1fa 100644 --- a/nf_core/pipelines/create/__init__.py +++ b/nf_core/pipelines/create/__init__.py @@ -18,10 +18,11 @@ from nf_core.pipelines.create.nfcorepipeline import NfcorePipeline from nf_core.pipelines.create.pipelinetype import ChoosePipelineType from nf_core.pipelines.create.welcome import WelcomeScreen +from nf_core.utils import LoggingConsole logger = logging.getLogger(__name__) rich_log_handler = RichHandler( - console=utils.LoggingConsole(classes="log_console"), + console=LoggingConsole(classes="log_console"), level=logging.INFO, rich_tracebacks=True, show_time=False, @@ -32,10 +33,10 @@ logger.addHandler(rich_log_handler) -class PipelineCreateApp(App[utils.CreateConfig]): +class PipelineCreateApp(App[utils.PipelinesCreateConfig]): """A Textual app to manage stopwatches.""" - CSS_PATH = "create.tcss" + CSS_PATH = "../../textual.tcss" TITLE = "nf-core pipelines create" SUB_TITLE = "Create a new pipeline with the nf-core pipeline template" BINDINGS = [ @@ -57,7 +58,7 @@ class PipelineCreateApp(App[utils.CreateConfig]): } # Initialise config as empty - TEMPLATE_CONFIG = utils.CreateConfig() + TEMPLATE_CONFIG = utils.PipelinesCreateConfig() # Initialise pipeline type NFCORE_PIPELINE = True diff --git a/nf_core/pipelines/create/basicdetails.py b/nf_core/pipelines/create/basicdetails.py index 2bd2ea1c79..e511945d9a 100644 --- a/nf_core/pipelines/create/basicdetails.py +++ b/nf_core/pipelines/create/basicdetails.py @@ -9,7 +9,8 @@ from textual.screen import Screen from textual.widgets import Button, Footer, Header, Input, Markdown -from nf_core.pipelines.create.utils import CreateConfig, TextInput, add_hide_class, remove_hide_class +from nf_core.pipelines.create.utils import PipelinesCreateConfig, TextInput +from nf_core.utils import add_hide_class, remove_hide_class pipeline_exists_warn = """ > ⚠️ **The pipeline you are trying to create already exists.** @@ -100,7 +101,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: else: text_input.query_one(".validation_msg").update("") try: - self.parent.TEMPLATE_CONFIG = CreateConfig(**config) + self.parent.TEMPLATE_CONFIG = PipelinesCreateConfig(**config) if event.button.id == "next": if self.parent.NFCORE_PIPELINE: self.parent.push_screen("type_nfcore") diff --git a/nf_core/pipelines/create/create.py b/nf_core/pipelines/create/create.py index 1800a5f10e..97a3140423 100644 --- a/nf_core/pipelines/create/create.py +++ b/nf_core/pipelines/create/create.py @@ -18,7 +18,7 @@ import nf_core import nf_core.pipelines.schema import nf_core.utils -from nf_core.pipelines.create.utils import CreateConfig, features_yml_path, load_features_yaml +from nf_core.pipelines.create.utils import PipelinesCreateConfig, features_yml_path, load_features_yaml from nf_core.pipelines.create_logo import create_logo from nf_core.pipelines.lint_utils import run_prettier_on_file from nf_core.pipelines.rocrate import ROCrate @@ -39,7 +39,7 @@ class PipelineCreate: force (bool): Overwrites a given workflow directory with the same name. Defaults to False. Used for tests and sync command. May the force be with you. outdir (str): Path to the local output directory. - template_config (str|CreateConfig): Path to template.yml file for pipeline creation settings. or pydantic model with the customisation for pipeline creation settings. + template_config (str|PipelinesCreateConfig): Path to template.yml file for pipeline creation settings. or pydantic model with the customisation for pipeline creation settings. organisation (str): Name of the GitHub organisation to create the pipeline. Will be the prefix of the pipeline. from_config_file (bool): If true the pipeline will be created from the `.nf-core.yml` config file. Used for tests and sync command. default_branch (str): Specifies the --initial-branch name. @@ -54,21 +54,21 @@ def __init__( no_git: bool = False, force: bool = False, outdir: Optional[Union[Path, str]] = None, - template_config: Optional[Union[CreateConfig, str, Path]] = None, + template_config: Optional[Union[PipelinesCreateConfig, str, Path]] = None, organisation: str = "nf-core", from_config_file: bool = False, default_branch: str = "master", is_interactive: bool = False, ) -> None: - if isinstance(template_config, CreateConfig): + if isinstance(template_config, PipelinesCreateConfig): self.config = template_config elif from_config_file: # Try reading config file try: _, config_yml = nf_core.utils.load_tools_config(outdir if outdir else Path().cwd()) - # Obtain a CreateConfig object from `.nf-core.yml` config file + # Obtain a PipelinesCreateConfig object from `.nf-core.yml` config file if config_yml is not None and getattr(config_yml, "template", None) is not None: - self.config = CreateConfig(**config_yml["template"].model_dump(exclude_none=True)) + self.config = PipelinesCreateConfig(**config_yml["template"].model_dump(exclude_none=True)) else: raise UserWarning("The template configuration was not provided in '.nf-core.yml'.") # Update the output directory @@ -78,7 +78,7 @@ def __init__( elif (name and description and author) or ( template_config and (isinstance(template_config, str) or isinstance(template_config, Path)) ): - # Obtain a CreateConfig object from the template yaml file + # Obtain a PipelinesCreateConfig object from the template yaml file self.config = self.check_template_yaml_info(template_config, name, description, author) self.update_config(organisation, version, force, outdir) else: @@ -140,12 +140,12 @@ def check_template_yaml_info(self, template_yaml, name, description, author): UserWarning: if template yaml file does not exist. """ # Obtain template customization info from template yaml file or `.nf-core.yml` config file - config = CreateConfig() + config = PipelinesCreateConfig() if template_yaml: try: with open(template_yaml) as f: template_yaml = yaml.safe_load(f) - config = CreateConfig(**template_yaml) + config = PipelinesCreateConfig(**template_yaml) except FileNotFoundError: raise UserWarning(f"Template YAML file '{template_yaml}' not found.") diff --git a/nf_core/pipelines/create/finaldetails.py b/nf_core/pipelines/create/finaldetails.py index dad81689a9..d8a1757a0b 100644 --- a/nf_core/pipelines/create/finaldetails.py +++ b/nf_core/pipelines/create/finaldetails.py @@ -10,7 +10,8 @@ from textual.widgets import Button, Footer, Header, Input, Markdown from nf_core.pipelines.create.create import PipelineCreate -from nf_core.pipelines.create.utils import ShowLogs, TextInput, add_hide_class, remove_hide_class +from nf_core.pipelines.create.utils import TextInput +from nf_core.utils import ShowLogs, add_hide_class, remove_hide_class pipeline_exists_warn = """ > ⚠️ **The pipeline you are trying to create already exists.** diff --git a/nf_core/pipelines/create/githubrepo.py b/nf_core/pipelines/create/githubrepo.py index 4ddf0092e1..6c126a8408 100644 --- a/nf_core/pipelines/create/githubrepo.py +++ b/nf_core/pipelines/create/githubrepo.py @@ -14,7 +14,8 @@ from textual.screen import Screen from textual.widgets import Button, Footer, Header, Input, Markdown, Static, Switch -from nf_core.pipelines.create.utils import ShowLogs, TextInput, remove_hide_class +from nf_core.pipelines.create.utils import TextInput +from nf_core.utils import ShowLogs, remove_hide_class log = logging.getLogger(__name__) diff --git a/nf_core/pipelines/create/loggingscreen.py b/nf_core/pipelines/create/loggingscreen.py index f862dccea1..bb98717e57 100644 --- a/nf_core/pipelines/create/loggingscreen.py +++ b/nf_core/pipelines/create/loggingscreen.py @@ -5,8 +5,7 @@ from textual.screen import Screen from textual.widgets import Button, Footer, Header, Markdown, Static -from nf_core.pipelines.create.utils import add_hide_class -from nf_core.utils import nfcore_logo +from nf_core.utils import add_hide_class, nfcore_logo class LoggingScreen(Screen): diff --git a/nf_core/pipelines/create/utils.py b/nf_core/pipelines/create/utils.py index 7f435421a5..a0ce68567e 100644 --- a/nf_core/pipelines/create/utils.py +++ b/nf_core/pipelines/create/utils.py @@ -9,13 +9,11 @@ from textual import on from textual.app import ComposeResult from textual.containers import Grid, HorizontalScroll -from textual.message import Message from textual.validation import ValidationResult, Validator -from textual.widget import Widget -from textual.widgets import Button, Input, Markdown, RichLog, Static, Switch +from textual.widgets import Button, Input, Static, Switch import nf_core -from nf_core.utils import NFCoreTemplateConfig +from nf_core.utils import HelpText, NFCoreTemplateConfig # Use ContextVar to define a context on the model initialization _init_context_var: ContextVar = ContextVar("_init_context_var", default={}) @@ -37,8 +35,8 @@ def init_context(value: Dict[str, Any]) -> Iterator[None]: features_yml_path = Path(nf_core.__file__).parent / "pipelines" / "create" / "template_features.yml" -class CreateConfig(NFCoreTemplateConfig): - """Pydantic model for the nf-core create config.""" +class PipelinesCreateConfig(NFCoreTemplateConfig): + """Pydantic model for the nf-core pipelines create config.""" model_config = ConfigDict(extra="allow") @@ -152,27 +150,12 @@ def validate(self, value: str) -> ValidationResult: If it fails, return the error messages.""" try: with init_context({"is_nfcore": NFCORE_PIPELINE_GLOBAL}): - CreateConfig(**{f"{self.key}": value}) + PipelinesCreateConfig(**{f"{self.key}": value}) return self.success() except ValidationError as e: return self.failure(", ".join([err["msg"] for err in e.errors()])) -class HelpText(Markdown): - """A class to show a text box with help text.""" - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - - def show(self) -> None: - """Method to show the help text box.""" - self.add_class("displayed") - - def hide(self) -> None: - """Method to hide the help text box.""" - self.remove_class("displayed") - - class PipelineFeature(Static): """Widget for the selection of pipeline features.""" @@ -208,31 +191,6 @@ def compose(self) -> ComposeResult: yield HelpText(markdown=self.markdown, classes="help_box") -class LoggingConsole(RichLog): - file = False - console: Widget - - def print(self, content): - self.write(content) - - -class ShowLogs(Message): - """Custom message to show the logging messages.""" - - pass - - -## Functions -def add_hide_class(app, widget_id: str) -> None: - """Add class 'hide' to a widget. Not display widget.""" - app.get_widget_by_id(widget_id).add_class("hide") - - -def remove_hide_class(app, widget_id: str) -> None: - """Remove class 'hide' to a widget. Display widget.""" - app.get_widget_by_id(widget_id).remove_class("hide") - - def load_features_yaml() -> Dict: """Load the YAML file describing template features.""" with open(features_yml_path) as fh: diff --git a/nf_core/pipelines/create/create.tcss b/nf_core/textual.tcss similarity index 100% rename from nf_core/pipelines/create/create.tcss rename to nf_core/textual.tcss diff --git a/nf_core/utils.py b/nf_core/utils.py index 4d096ec3ac..5f4fd91ade 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -34,6 +34,9 @@ from pydantic import BaseModel, ValidationError, field_validator from rich.live import Live from rich.spinner import Spinner +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Markdown, RichLog import nf_core @@ -1140,7 +1143,7 @@ class NFCoreTemplateConfig(BaseModel): skip_features: Optional[list] = None """ Skip features. See https://nf-co.re/docs/nf-core-tools/pipelines/create for a list of features. """ is_nfcore: Optional[bool] = None - """ Whether the pipeline is an nf-core pipeline. """ + """ Whether the pipeline is an nf-core pipeline """ # convert outdir to str @field_validator("outdir") @@ -1567,3 +1570,46 @@ def get_wf_files(wf_path: Path): wf_files.append(str(path)) return wf_files + + +# General textual-related functions and objects + + +class HelpText(Markdown): + """A class to show a text box with help text.""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + def show(self) -> None: + """Method to show the help text box.""" + self.add_class("displayed") + + def hide(self) -> None: + """Method to hide the help text box.""" + self.remove_class("displayed") + + +class LoggingConsole(RichLog): + file = False + console: Widget + + def print(self, content): + self.write(content) + + +class ShowLogs(Message): + """Custom message to show the logging messages.""" + + pass + + +# Functions +def add_hide_class(app, widget_id: str) -> None: + """Add class 'hide' to a widget. Not display widget.""" + app.get_widget_by_id(widget_id).add_class("hide") + + +def remove_hide_class(app, widget_id: str) -> None: + """Remove class 'hide' to a widget. Display widget.""" + app.get_widget_by_id(widget_id).remove_class("hide")